Files
climperor/web/tests/test_refresh_reliability.py
T

412 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()