v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,7 +52,7 @@ from shared.paths import (
|
||||
|
||||
from serve_relations import WEB_DIR, build_payload
|
||||
|
||||
SITE_VERSION = "0.5.111"
|
||||
SITE_VERSION = "0.5.112"
|
||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ Approach (verified 2026-07):
|
||||
``webcast/room/web/enter`` API was considered but returns empty bodies
|
||||
without request signing, so the SSR page is the source of truth.)
|
||||
|
||||
Everything is soft-fail: network errors, empty or non-JSON responses keep the
|
||||
previous ``is_live`` value and never abort a refresh tier (exit code is
|
||||
always 0). Only the two probe fields are touched; all other keys (including
|
||||
``live_url``) are preserved. Note the ``daily`` tier means the badge trails
|
||||
reality by up to a day — truly real-time would need a higher-frequency job.
|
||||
Everything is soft-fail: network errors, empty or non-JSON responses clear
|
||||
``is_live`` to False and drop ``live_probed_at`` (so consumers treat the
|
||||
badge as stale/unknown) and never abort a refresh tier (exit code is always
|
||||
0). Only the two probe fields are touched; all other keys (including
|
||||
``live_url``) are preserved. Production live badges are owned by the
|
||||
visit-triggered ``/api/live-status`` edge probe; this daily write is only a
|
||||
``data.json`` fallback until that API returns.
|
||||
|
||||
Preview only — do not merge into relations/heroes or recommend.
|
||||
|
||||
@@ -212,7 +214,11 @@ def probe_streamers(
|
||||
print(f"skip {sid}: platform={platform!r} unsupported", flush=True)
|
||||
continue
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||
print(f" FAIL {sid}: {e} (keeping previous is_live)", flush=True)
|
||||
# Align with /api/live-status and local serve_relations: unknown is
|
||||
# not live, and must not preserve a stale positive badge.
|
||||
row["is_live"] = False
|
||||
row.pop("live_probed_at", None)
|
||||
print(f" FAIL {sid}: {e} (is_live=false, stale)", flush=True)
|
||||
fail += 1
|
||||
continue
|
||||
row["is_live"] = is_live
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||
var SITE_VERSION = "0.5.111";
|
||||
var SITE_VERSION = "0.5.112";
|
||||
var ABILITY_VIDEO_BASE = "";
|
||||
var STATIC_ASSET_BASE = "";
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DOTA2 上分帝</title>
|
||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.111" />
|
||||
<script src="/mobile-gate.js?v=0.5.111"></script>
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.112" />
|
||||
<script src="/mobile-gate.js?v=0.5.112"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||
@@ -178,8 +178,8 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.5.111"></script>
|
||||
<script src="/router.js?v=0.5.111"></script>
|
||||
<script src="/app.js?v=0.5.111"></script>
|
||||
<script src="/config.js?v=0.5.112"></script>
|
||||
<script src="/router.js?v=0.5.112"></script>
|
||||
<script src="/app.js?v=0.5.112"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-11
@@ -1514,11 +1514,16 @@ body {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* Skill detail: official-page split — demo clip left, name/desc right. */
|
||||
/* Skill detail: official-page split — demo clip left, name/desc right.
|
||||
Use the full inspect width when the center column has room (no artificial
|
||||
1320px cap that leaves empty bands beside a cropped square video). */
|
||||
.hero-inspect .skill-info-body {
|
||||
max-width: 1320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.hero-inspect .item-detail-large-icon {
|
||||
width: 96px;
|
||||
@@ -1700,17 +1705,21 @@ body {
|
||||
max-width: none;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
/* Stretch to the body's definite height (flex chain from #detail) so the
|
||||
16:9 intrinsic size cannot push past the panel; cover crops to fill. */
|
||||
/* Official demos are 16:9. Prefer a real widescreen frame (grow toward
|
||||
720px / ~58% when the inspect column has room); never cover-crop sides.
|
||||
Height follows width via aspect-ratio, capped by the panel so tall
|
||||
#detail no longer stretches the box into a near-square. */
|
||||
.skill-info-video {
|
||||
flex: 0 1 42%;
|
||||
width: 42%;
|
||||
max-width: 480px;
|
||||
flex: 0 1 auto;
|
||||
align-self: center;
|
||||
min-width: 220px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: min(720px, 58%);
|
||||
max-width: min(720px, 58%);
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #000;
|
||||
|
||||
@@ -550,7 +550,7 @@ def _nested_timestamp(payload: dict, *path: str) -> datetime | None:
|
||||
|
||||
|
||||
def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool:
|
||||
if not summary.get("ok"):
|
||||
if not summary.get("ok") or summary.get("skipped") or summary.get("dry_run"):
|
||||
return False
|
||||
step_ok = any(
|
||||
isinstance(row, dict)
|
||||
@@ -765,6 +765,18 @@ def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
|
||||
return f"- **{w.label}**:{' / '.join(parts)}{latest}"
|
||||
|
||||
|
||||
def _fmt_refresh_summary_result(summary: dict) -> str:
|
||||
"""Human label for a refresh_web.py summary.json payload."""
|
||||
if summary.get("skipped"):
|
||||
reason = str(summary.get("skip_reason") or "").strip()
|
||||
return f"已跳过({reason})" if reason else "已跳过"
|
||||
if not summary.get("ok"):
|
||||
return "刷新失败"
|
||||
if summary.get("deployed"):
|
||||
return "已部署"
|
||||
return "无需部署"
|
||||
|
||||
|
||||
def build_card(st: DayStats, extras: DigestExtras) -> dict:
|
||||
"""Feishu interactive card (msg_type=interactive)."""
|
||||
countries = "、".join(
|
||||
@@ -796,9 +808,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
|
||||
(label for file, label in REFRESH_WORKFLOWS if file == workflow_file),
|
||||
workflow_file,
|
||||
)
|
||||
result = "已部署" if summary.get("deployed") else "无需部署"
|
||||
if not summary.get("ok"):
|
||||
result = "刷新失败"
|
||||
result = _fmt_refresh_summary_result(summary)
|
||||
stale_count = int(
|
||||
(summary.get("health") or {}).get("stratz_matchup_stale") or 0
|
||||
)
|
||||
|
||||
+76
-17
@@ -3,9 +3,9 @@
|
||||
Tiers (see AGENTS.md / Gitea Actions workflows):
|
||||
daily — OpenDota stats, leaderboards, matches, pro matches, streamers,
|
||||
streamer live probe; patch check
|
||||
(live badge still trails reality by up to a day — truly real-time
|
||||
would need a dedicated higher-frequency workflow, e.g. 15-min)
|
||||
weekly — STRATZ meta, hero items, items_meta, item_fears
|
||||
(live badge is served by /api/live-status on visit; daily probe is
|
||||
only a data.json fallback until the edge API returns)
|
||||
weekly — STRATZ meta, hero items, items_meta, item_counter_stats, item_fears
|
||||
patch — patch list check; on has_new fetch details + version-linked scripts
|
||||
all — weekly then daily (patch check included in daily/patch)
|
||||
|
||||
@@ -29,9 +29,11 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(ROOT.parent))
|
||||
REPO_ROOT = ROOT.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from shared.http_utils import write_json_atomic
|
||||
from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND
|
||||
|
||||
# Paths whose content change should trigger deploy / OSS.
|
||||
DATA_WATCH = [
|
||||
@@ -50,6 +52,21 @@ DATA_WATCH = [
|
||||
ROOT / "data" / "item_shop.json",
|
||||
ROOT / "data" / "item_counter_stats.json",
|
||||
]
|
||||
# Static site inputs that are not rewritten by fetch tiers but still ship in
|
||||
# data.json / dist (frontend + qualitative relations + hero table / grid).
|
||||
INPUT_WATCH = [
|
||||
RELATIONS_JSON,
|
||||
HEROES_JSON,
|
||||
ROOT / "data" / "hero_grid_order.json",
|
||||
WEB_FRONTEND / "index.html",
|
||||
WEB_FRONTEND / "app.js",
|
||||
WEB_FRONTEND / "style.css",
|
||||
WEB_FRONTEND / "router.js",
|
||||
WEB_FRONTEND / "mobile-gate.js",
|
||||
WEB_FRONTEND / "config.js",
|
||||
WEB_FRONTEND / "_headers",
|
||||
]
|
||||
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
|
||||
ASSET_DIRS = [
|
||||
ROOT / "assets" / "item_icons",
|
||||
ROOT / "assets" / "ability_icons",
|
||||
@@ -67,12 +84,24 @@ VOLATILE_JSON_KEYS = frozenset(
|
||||
"live_probed_at",
|
||||
"profile_fetched_at",
|
||||
"updated_at",
|
||||
# Live badge is owned by /api/live-status; flipping is_live alone must
|
||||
# not trigger a full static redeploy.
|
||||
"is_live",
|
||||
}
|
||||
)
|
||||
RUN_RESULTS: list[dict] = []
|
||||
PATCH_RESULT: dict = {}
|
||||
|
||||
|
||||
def _watch_key(path: Path) -> str:
|
||||
"""Stable snapshot key relative to the repo root when possible."""
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def _file_digest(path: Path) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
@@ -131,27 +160,39 @@ def _dir_digest(path: Path) -> str | None:
|
||||
def snapshot() -> dict[str, str | None]:
|
||||
out: dict[str, str | None] = {}
|
||||
for p in DATA_WATCH:
|
||||
out[str(p.relative_to(ROOT))] = _semantic_file_digest(p)
|
||||
out[_watch_key(p)] = _semantic_file_digest(p)
|
||||
for p in INPUT_WATCH:
|
||||
# Frontend/source files use byte digests; JSON inputs use semantic hash.
|
||||
if p.suffix.lower() == ".json":
|
||||
out[_watch_key(p)] = _semantic_file_digest(p)
|
||||
else:
|
||||
out[_watch_key(p)] = _file_digest(p)
|
||||
out[_watch_key(FRONTEND_FUNCTIONS_DIR) + "/"] = _dir_digest(FRONTEND_FUNCTIONS_DIR)
|
||||
for d in ASSET_DIRS:
|
||||
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d)
|
||||
out[_watch_key(d) + "/"] = _dir_digest(d)
|
||||
return out
|
||||
|
||||
|
||||
def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]:
|
||||
"""Return (data_changed, assets_changed)."""
|
||||
"""Return (data_changed, assets_changed).
|
||||
|
||||
``data_changed`` covers generated JSON, frontend/source inputs, and Pages
|
||||
Functions. Only ``web/assets/...`` directory digests count as assets
|
||||
(OSS upload).
|
||||
"""
|
||||
data_changed = False
|
||||
assets_changed = False
|
||||
asset_keys = {_watch_key(d) + "/" for d in ASSET_DIRS}
|
||||
keys = set(before) | set(after)
|
||||
for k in keys:
|
||||
if before.get(k) == after.get(k):
|
||||
continue
|
||||
if k.endswith("/"):
|
||||
if k in asset_keys:
|
||||
assets_changed = True
|
||||
else:
|
||||
data_changed = True
|
||||
return data_changed, assets_changed
|
||||
|
||||
|
||||
def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool:
|
||||
cmd = [sys.executable, str(ROOT / script), *args]
|
||||
print(f"+ {' '.join(cmd)}", flush=True)
|
||||
@@ -234,9 +275,9 @@ def run_daily(*, dry_run: bool = False) -> bool:
|
||||
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
|
||||
run_script("fetch_pro_matches.py", "--include-pubs", dry_run=dry_run)
|
||||
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
|
||||
run_script("fetch_streamers.py", dry_run=dry_run)
|
||||
# Soft-fail live probe (exits 0; probe failures keep previous is_live).
|
||||
run_script("fetch_streamer_live.py", dry_run=dry_run)
|
||||
run_script("fetch_streamers.py", dry_run=dry_run, soft_fail=True)
|
||||
# Soft-fail live probe (exits 0; probe failures clear is_live / live_probed_at).
|
||||
run_script("fetch_streamer_live.py", dry_run=dry_run, soft_fail=True)
|
||||
check = patch_check()
|
||||
if check.get("has_new"):
|
||||
print(
|
||||
@@ -323,11 +364,16 @@ def _stale_metrics() -> dict:
|
||||
return metrics
|
||||
|
||||
|
||||
def _write_summary(summary: dict) -> None:
|
||||
def _finalize_summary(summary: dict) -> dict:
|
||||
summary["steps"] = list(RUN_RESULTS)
|
||||
summary["patch_check"] = dict(PATCH_RESULT)
|
||||
summary["health"] = _stale_metrics()
|
||||
summary["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return summary
|
||||
|
||||
|
||||
def _write_summary(summary: dict) -> None:
|
||||
_finalize_summary(summary)
|
||||
write_json_atomic(SUMMARY_PATH, summary)
|
||||
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
|
||||
|
||||
@@ -441,7 +487,9 @@ def main() -> None:
|
||||
_write_summary(summary)
|
||||
return
|
||||
try:
|
||||
cache_enabled = os.environ.get("REFRESH_CACHE_ENABLED") == "1"
|
||||
cache_enabled = (
|
||||
os.environ.get("REFRESH_CACHE_ENABLED") == "1" and not args.dry_run
|
||||
)
|
||||
if cache_enabled:
|
||||
run_script("refresh_cache.py", "restore")
|
||||
before = snapshot() if not args.dry_run else {}
|
||||
@@ -455,7 +503,10 @@ def main() -> None:
|
||||
if args.dry_run:
|
||||
summary["ok"] = True
|
||||
summary["dry_run"] = True
|
||||
print("dry-run complete (no deploy/oss)", flush=True)
|
||||
print(
|
||||
"dry-run complete (no cache restore/save, deploy, or oss)",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
|
||||
after = snapshot()
|
||||
@@ -484,7 +535,7 @@ def main() -> None:
|
||||
if assets_changed and not args.skip_oss:
|
||||
run_script("_oss_static_assets.py", "upload")
|
||||
elif assets_changed:
|
||||
print("assets changed but deploy is disabled; skipping OSS upload", flush=True)
|
||||
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
|
||||
|
||||
should_deploy = args.force_deploy or data_changed or assets_changed
|
||||
if should_deploy and not args.skip_deploy:
|
||||
@@ -502,7 +553,15 @@ def main() -> None:
|
||||
raise
|
||||
finally:
|
||||
summary["duration_s"] = round(time.monotonic() - started, 3)
|
||||
_write_summary(summary)
|
||||
if args.dry_run:
|
||||
# Print for humans; do not persist dry-run artifacts.
|
||||
_finalize_summary(summary)
|
||||
print(
|
||||
"REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False),
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
_write_summary(summary)
|
||||
release_refresh_lock(lock_handle)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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
|
||||
@@ -14,8 +16,10 @@ 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):
|
||||
@@ -45,6 +49,82 @@ class RefreshDigestTests(unittest.TestCase):
|
||||
)
|
||||
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(
|
||||
@@ -58,6 +138,31 @@ class RefreshDigestTests(unittest.TestCase):
|
||||
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"
|
||||
@@ -132,6 +237,51 @@ class RefreshDigestTests(unittest.TestCase):
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user