412 lines
16 KiB
Python
412 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
import urllib.error
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
sys.path.insert(0, str(ROOT / "web"))
|
||
|
||
from web import notify_site_traffic, refresh_web
|
||
from web.fetch_stratz_meta import preserve_failed_meta_brackets
|
||
from web.fetch_streamer_live import probe_streamers
|
||
from web.refresh_cache import restore_patches, restore_streamers
|
||
from shared.http_utils import write_json_atomic
|
||
from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND
|
||
|
||
|
||
class RefreshDigestTests(unittest.TestCase):
|
||
def test_atomic_json_replaces_complete_document(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "data.json"
|
||
write_json_atomic(path, {"value": [1, 2, 3]})
|
||
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), {"value": [1, 2, 3]})
|
||
self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), [])
|
||
|
||
def test_semantic_digest_ignores_refresh_timestamps(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "data.json"
|
||
path.write_text(
|
||
json.dumps({"fetched_at": "old", "by_hero": {"axe": {"pick": 1}}}),
|
||
encoding="utf-8",
|
||
)
|
||
before = refresh_web._semantic_file_digest(path)
|
||
path.write_text(
|
||
json.dumps({"fetched_at": "new", "by_hero": {"axe": {"pick": 1}}}),
|
||
encoding="utf-8",
|
||
)
|
||
self.assertEqual(before, refresh_web._semantic_file_digest(path))
|
||
path.write_text(
|
||
json.dumps({"fetched_at": "new", "by_hero": {"axe": {"pick": 2}}}),
|
||
encoding="utf-8",
|
||
)
|
||
self.assertNotEqual(before, refresh_web._semantic_file_digest(path))
|
||
|
||
def test_semantic_digest_ignores_streamer_is_live(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "streamers.json"
|
||
path.write_text(
|
||
json.dumps(
|
||
{
|
||
"fetched_at": "old",
|
||
"streamers": [
|
||
{
|
||
"id": "a",
|
||
"nickname": "A",
|
||
"is_live": False,
|
||
"live_probed_at": "t1",
|
||
}
|
||
],
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
before = refresh_web._semantic_file_digest(path)
|
||
path.write_text(
|
||
json.dumps(
|
||
{
|
||
"fetched_at": "new",
|
||
"streamers": [
|
||
{
|
||
"id": "a",
|
||
"nickname": "A",
|
||
"is_live": True,
|
||
"live_probed_at": "t2",
|
||
}
|
||
],
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
self.assertEqual(before, refresh_web._semantic_file_digest(path))
|
||
path.write_text(
|
||
json.dumps(
|
||
{
|
||
"fetched_at": "new",
|
||
"streamers": [
|
||
{
|
||
"id": "a",
|
||
"nickname": "B",
|
||
"is_live": True,
|
||
"live_probed_at": "t2",
|
||
}
|
||
],
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
self.assertNotEqual(before, refresh_web._semantic_file_digest(path))
|
||
|
||
def test_snapshot_watches_frontend_and_shared_inputs(self) -> None:
|
||
snap = refresh_web.snapshot()
|
||
self.assertIn(refresh_web._watch_key(RELATIONS_JSON), snap)
|
||
self.assertIn(refresh_web._watch_key(HEROES_JSON), snap)
|
||
self.assertIn(refresh_web._watch_key(WEB_FRONTEND / "app.js"), snap)
|
||
self.assertIn(
|
||
refresh_web._watch_key(WEB_FRONTEND / "functions") + "/",
|
||
snap,
|
||
)
|
||
self.assertIn(
|
||
refresh_web._watch_key(refresh_web.ROOT / "data" / "hero_grid_order.json"),
|
||
snap,
|
||
)
|
||
|
||
def test_frontend_change_is_data_not_asset(self) -> None:
|
||
before = {"web/frontend/app.js": "a", "web/assets/item_icons/": "x"}
|
||
after = {"web/frontend/app.js": "b", "web/assets/item_icons/": "x"}
|
||
data_changed, assets_changed = refresh_web.diff_snapshots(before, after)
|
||
self.assertTrue(data_changed)
|
||
self.assertFalse(assets_changed)
|
||
|
||
def test_assets_cannot_deploy_when_oss_is_skipped(self) -> None:
|
||
with self.assertRaises(RuntimeError):
|
||
refresh_web.validate_publish_options(
|
||
assets_changed=True,
|
||
skip_oss=True,
|
||
skip_deploy=False,
|
||
)
|
||
refresh_web.validate_publish_options(
|
||
assets_changed=True,
|
||
skip_oss=True,
|
||
skip_deploy=True,
|
||
)
|
||
|
||
def test_dry_run_skips_cache_restore_and_summary_write(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
summary_path = Path(tmp) / "summary.json"
|
||
calls: list[tuple] = []
|
||
|
||
def fake_run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False):
|
||
calls.append((script, args, dry_run))
|
||
return True
|
||
|
||
env = {"REFRESH_CACHE_ENABLED": "1"}
|
||
with (
|
||
patch.object(refresh_web, "SUMMARY_PATH", summary_path),
|
||
patch.object(refresh_web, "acquire_refresh_lock", return_value=object()),
|
||
patch.object(refresh_web, "release_refresh_lock"),
|
||
patch.object(refresh_web, "run_script", side_effect=fake_run_script),
|
||
patch.object(refresh_web, "run_weekly"),
|
||
patch.object(refresh_web, "run_daily", return_value=False),
|
||
patch.object(refresh_web, "run_patch_tier", return_value=False),
|
||
patch.dict(os.environ, env, clear=False),
|
||
patch.object(sys, "argv", ["refresh_web.py", "--tier", "weekly", "--dry-run"]),
|
||
):
|
||
refresh_web.main()
|
||
self.assertFalse(summary_path.is_file())
|
||
self.assertFalse(any(script == "refresh_cache.py" for script, *_ in calls))
|
||
|
||
def test_streamer_cache_preserves_manual_rows_and_restores_runtime_fields(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cached = Path(tmp) / "cached.json"
|
||
checkout = Path(tmp) / "checkout.json"
|
||
write_json_atomic(
|
||
cached,
|
||
{
|
||
"fetched_at": "2026-07-29T10:00:00+00:00",
|
||
"streamers": [
|
||
{
|
||
"id": "known",
|
||
"nickname": "cached-name",
|
||
"follower_count": 99,
|
||
"live_url": "must-not-restore",
|
||
},
|
||
{"id": "deleted", "nickname": "deleted"},
|
||
],
|
||
},
|
||
)
|
||
write_json_atomic(
|
||
checkout,
|
||
{
|
||
"fetched_at": "2026-07-29T09:00:00+00:00",
|
||
"streamers": [
|
||
{
|
||
"id": "known",
|
||
"nickname": "git-name",
|
||
"live_url": "new-manual-url",
|
||
},
|
||
{"id": "new", "nickname": "new-manual-row"},
|
||
],
|
||
},
|
||
)
|
||
restore_streamers(cached, checkout)
|
||
payload = json.loads(checkout.read_text(encoding="utf-8"))
|
||
rows = {row["id"]: row for row in payload["streamers"]}
|
||
self.assertEqual(rows["known"]["nickname"], "cached-name")
|
||
self.assertEqual(rows["known"]["follower_count"], 99)
|
||
self.assertEqual(rows["known"]["live_url"], "new-manual-url")
|
||
self.assertIn("new", rows)
|
||
self.assertNotIn("deleted", rows)
|
||
|
||
def test_patch_cache_merges_runtime_and_committed_versions(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
cached = Path(tmp) / "cached.json"
|
||
checkout = Path(tmp) / "checkout.json"
|
||
write_json_atomic(
|
||
cached,
|
||
{
|
||
"meta": {"fetched_at": "cached"},
|
||
"patches": [{"version": "7.40", "timestamp": 200}],
|
||
"lookup": {"heroes": {"1": {"name": "cached"}}},
|
||
"details": {"7.40": {"general_notes": ["cached"]}},
|
||
},
|
||
)
|
||
write_json_atomic(
|
||
checkout,
|
||
{
|
||
"meta": {"source": "git"},
|
||
"patches": [{"version": "7.41", "timestamp": 300}],
|
||
"lookup": {"heroes": {"2": {"name": "committed"}}},
|
||
"details": {"7.41": {"general_notes": ["committed"]}},
|
||
},
|
||
)
|
||
restore_patches(cached, checkout)
|
||
payload = json.loads(checkout.read_text(encoding="utf-8"))
|
||
self.assertEqual(
|
||
[row["version"] for row in payload["patches"]],
|
||
["7.41", "7.40"],
|
||
)
|
||
self.assertEqual(set(payload["details"]), {"7.40", "7.41"})
|
||
self.assertEqual(set(payload["lookup"]["heroes"]), {"1", "2"})
|
||
|
||
|
||
class LiveProbeFallbackTests(unittest.TestCase):
|
||
def test_probe_failure_clears_stale_live_badge(self) -> None:
|
||
payload = {
|
||
"streamers": [
|
||
{
|
||
"id": "a",
|
||
"live_url": "https://live.bilibili.com/123",
|
||
"is_live": True,
|
||
"live_probed_at": "2026-07-29T00:00:00+00:00",
|
||
}
|
||
]
|
||
}
|
||
|
||
def boom(_room_id: str) -> bool:
|
||
raise urllib.error.URLError("blocked")
|
||
|
||
with patch("web.fetch_streamer_live.probe_bilibili", side_effect=boom):
|
||
live, offline, fail = probe_streamers(payload)
|
||
self.assertEqual((live, offline, fail), (0, 0, 1))
|
||
row = payload["streamers"][0]
|
||
self.assertFalse(row["is_live"])
|
||
self.assertNotIn("live_probed_at", row)
|
||
|
||
|
||
class NotifySummaryTests(unittest.TestCase):
|
||
def test_skipped_summary_is_labeled(self) -> None:
|
||
self.assertEqual(
|
||
notify_site_traffic._fmt_refresh_summary_result(
|
||
{
|
||
"ok": True,
|
||
"skipped": True,
|
||
"skip_reason": "full refresh already running",
|
||
}
|
||
),
|
||
"已跳过(full refresh already running)",
|
||
)
|
||
self.assertFalse(
|
||
notify_site_traffic._summary_proves_refresh(
|
||
{"ok": True, "skipped": True, "steps": [{"step": "fetch_hero_stats.py", "ok": True}]},
|
||
"英雄统计",
|
||
"fetch_hero_stats.py",
|
||
)
|
||
)
|
||
|
||
|
||
class StratzFallbackTests(unittest.TestCase):
|
||
def test_failed_bracket_keeps_previous_data_and_marks_stale(self) -> None:
|
||
fresh = {
|
||
"totals": {"immortal": {"pick": 0}},
|
||
"meta_board": {"immortal": []},
|
||
"by_hero": {
|
||
"axe": {
|
||
"weeks": {"immortal": []},
|
||
"latest": {},
|
||
"positions": {"immortal": {}},
|
||
}
|
||
},
|
||
}
|
||
previous = {
|
||
"totals": {"immortal": {"pick": 100}},
|
||
"meta_board": {"immortal": [{"key": "axe"}]},
|
||
"by_hero": {
|
||
"axe": {
|
||
"weeks": {"immortal": [{"week": 1, "pick": 100, "win": 50}]},
|
||
"latest": {"immortal": {"pick": 100, "win": 50}},
|
||
"positions": {"immortal": {"POSITION_3": {"pick": 40, "win": 20}}},
|
||
}
|
||
},
|
||
}
|
||
result = preserve_failed_meta_brackets(
|
||
fresh,
|
||
previous,
|
||
failed_weeks={"immortal"},
|
||
failed_positions={"immortal"},
|
||
)
|
||
self.assertTrue(result["stale"])
|
||
self.assertEqual(result["totals"]["immortal"]["pick"], 100)
|
||
self.assertEqual(
|
||
result["by_hero"]["axe"]["positions"]["immortal"]["POSITION_3"]["pick"],
|
||
40,
|
||
)
|
||
|
||
|
||
class ProductionHealthTests(unittest.TestCase):
|
||
def test_stale_payload_and_invalid_live_api_are_reported(self) -> None:
|
||
now = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
|
||
old = (now - timedelta(hours=48)).isoformat()
|
||
payload = {
|
||
"heroes": [{"key": "axe"}],
|
||
"meta": {"refresh_run_id": "123"},
|
||
"hero_stats": {"fetched_at": old},
|
||
"leaderboards": {"fetched_at": old},
|
||
"hero_matches": {"meta": {"fetched_at": old}},
|
||
"pro_matches": {"meta": {"fetched_at": old}},
|
||
"streamers": {"fetched_at": old},
|
||
"stratz_hero_meta": {"fetched_at": old},
|
||
"stratz_matchup_tops": {"fetched_at": old},
|
||
"hero_items": {"meta": {"fetched_at": old}},
|
||
}
|
||
responses = [
|
||
(200, b'var SITE_VERSION = "1.2.3";', "application/javascript", {}),
|
||
(200, json.dumps(payload).encode(), "application/json", {}),
|
||
(200, b"<html>fallback</html>", "text/html", {}),
|
||
]
|
||
with patch.object(
|
||
notify_site_traffic,
|
||
"http_response_soft",
|
||
side_effect=responses,
|
||
):
|
||
health = notify_site_traffic.fetch_production_health(now=now)
|
||
self.assertEqual(health.site_version, "1.2.3")
|
||
self.assertEqual(health.refresh_run_id, "123")
|
||
self.assertIn("英雄统计", health.stale)
|
||
self.assertIn("直播状态 API", health.stale)
|
||
self.assertFalse(health.live_api_ok)
|
||
|
||
def test_successful_refresh_summary_prevents_false_freshness_alarm(self) -> None:
|
||
now = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
|
||
old_daily = (now - timedelta(hours=48)).isoformat()
|
||
recent_weekly = (now - timedelta(days=2)).isoformat()
|
||
payload = {
|
||
"heroes": [{"key": "axe"}],
|
||
"meta": {"refresh_run_id": "456"},
|
||
"hero_stats": {"fetched_at": old_daily},
|
||
"leaderboards": {"fetched_at": old_daily},
|
||
"hero_matches": {"meta": {"fetched_at": old_daily}},
|
||
"pro_matches": {"meta": {"fetched_at": old_daily}},
|
||
"streamers": {"fetched_at": old_daily},
|
||
"stratz_hero_meta": {"fetched_at": recent_weekly},
|
||
"stratz_matchup_tops": {"fetched_at": recent_weekly},
|
||
"hero_items": {"meta": {"fetched_at": recent_weekly}},
|
||
}
|
||
responses = [
|
||
(200, b'var SITE_VERSION = "1.2.3";', "application/javascript", {}),
|
||
(200, json.dumps(payload).encode(), "application/json", {}),
|
||
(
|
||
200,
|
||
b'{"ok":true,"streamers":{}}',
|
||
"application/json",
|
||
{"x-live-cache": "hit"},
|
||
),
|
||
]
|
||
with patch.object(
|
||
notify_site_traffic,
|
||
"http_response_soft",
|
||
side_effect=responses,
|
||
):
|
||
health = notify_site_traffic.fetch_production_health(
|
||
now=now,
|
||
refresh_summaries={
|
||
"web-daily.yml": {
|
||
"ok": True,
|
||
"steps": [
|
||
{"step": "fetch_hero_stats.py", "ok": True},
|
||
{"step": "fetch_leaderboards.py", "ok": True},
|
||
{"step": "fetch_hero_matches.py", "ok": True},
|
||
{"step": "fetch_pro_matches.py", "ok": True},
|
||
{"step": "fetch_streamers.py", "ok": True},
|
||
],
|
||
"health": {
|
||
"streamer_profile_missing": 0,
|
||
"streamer_live_probe_missing": 0,
|
||
},
|
||
}
|
||
},
|
||
)
|
||
self.assertEqual(health.stale, [])
|
||
self.assertTrue(health.live_api_ok)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|