#!/usr/bin/env python3 """「歌曲表自动维护」+「设备心跳/身份整合」专项测试(**不启动服务、不联网、不真等 10 分钟**)。 覆盖(对应 2026 需求): 1. 一轮维护的**完整顺序**:身份时效 → 扫描 → 重试 → 补队 → drain → 停心跳(断言调用顺序); 2. `next_run_at = 本轮结束 + interval`;`restart` **立即**开始新一轮(不等 interval); 3. 没有要下载的歌 → **不启动心跳**;要下载 → 启动、drain 后停止(drain 抛异常也停); 4. 身份 24h 过期 → 自动 regenerate(mac/sn 变了、事件写了、i.php/do.php/whoami 三处同源); 未过期 → 不换;`GET /api/device` 带 age_sec/expires_in_sec/ttl_hours/expired; 5. `stop()` 后状态 `stopped` 且不再有任何轮询动作; 6. 扫描/重试失败**不影响本轮后续步骤**(各自 try/except); 7. 接口层 `/api/maintain/{status,start,stop,restart,run_now}` 全链路(`run_now` 立刻返回); 8. 旧 `/api/batch/*` 仍是可用的薄包装(向后兼容)。 所有"时间"都由**虚拟时钟**注入(`now_fn` / `run_cycle(now=…)`),测试总耗时 < 1 秒。 """ import json import os import random import sys import tempfile import time import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from ktv import admin, identity, ui, whoami from ktv import resolver as R from ktv.config import Config from ktv.downloader import Downloader from ktv.maintain import MaintainTask from ktv.store import Store from ktv.web import Request def req(method, path, query=None, body=b""): return Request(method, path, query or {}, {}, body) def jbody(data: dict) -> bytes: return json.dumps(data, ensure_ascii=False).encode() def wait_until(pred, timeout: float = 3.0, step: float = 0.02) -> bool: """小步轮询等待(**不等固定时长**:满足条件立刻返回,测试保持快)。""" end = time.time() + timeout while time.time() < end: if pred(): return True time.sleep(step) return pred() def rows(*specs): """(tid, name, lang, hot) → import_library 需要的一行。""" return [(tid, name, lang, "", f"s{tid}", f"{tid}.ts", hot, "") for tid, name, lang, hot in specs] class Clock: """可变虚拟时钟(秒)。测试里想"过多久"就直接改 `t`。""" def __init__(self, t: int): self.t = int(t) def now(self) -> int: return self.t class FakeWhoami: """打桩的心跳:只记录 start/stop 调用(**绝不发请求**)。""" def __init__(self, log: list): self.log = log self.running = False self.started = 0 self.stopped = 0 def start(self): self.log.append("whoami.start") self.running = True self.started += 1 def stop(self): self.log.append("whoami.stop") self.running = False self.stopped += 1 def status(self): return {"running": self.running} class FakeIdentity: """打桩的设备身份:记录 regenerate,并真的接进配置(模拟 apply_to_cfg)。""" def __init__(self, cfg, created_at: int, mac: str = "001ec0000001", sn: str = "aaaa00000001", log: list | None = None): self.cfg = cfg self.log = log if log is not None else [] self.identity = {"mac": mac, "sn": sn, "created_at": int(created_at), "preset": "fake"} self.regenerated = 0 def regenerate(self, preset: str = ""): self.log.append("identity.regenerate") self.regenerated += 1 self.identity = {"mac": f"1ca41000{self.regenerated:04d}", "sn": f"bbbb{self.regenerated:08d}", "created_at": int(time.time()), "preset": "fake"} identity.apply_to_cfg(self.cfg, self.identity) return dict(self.identity) class SpyStore: """包一层 store,只为记录 `enqueue`(断言"补队"在哪个位置发生)。""" def __init__(self, inner, log: list): self._inner = inner self._log = log def __getattr__(self, name): return getattr(self._inner, name) def enqueue(self, tids, **kw): self._log.append("enqueue") return self._inner.enqueue(tids, **kw) class Case(unittest.TestCase): """公共夹具:临时数据/媒体目录 + 4 首小曲库(显式关连接,避免 ResourceWarning)。 ⚠️ 维护任务是**真线程**:`tearDown` 必须先 `stop()` + `join()`(并等本轮 `cycle_lock` 释放)才关 SQLite —— 否则"关连接"与"子线程正在写事件"撞在一起会让 sqlite 段错误。 """ def setUp(self): self.tmp = tempfile.mkdtemp() self.cfg = Config(os.path.join(self.tmp, "c.json")) self.media = os.path.join(self.tmp, "media") self.cfg.update({"data_dir": self.tmp, "media_dir": self.media, "tmp_dir": os.path.join(self.tmp, "t")}) os.makedirs(self.media, exist_ok=True) self.store = Store(os.path.join(self.tmp, "t.db")) self.store.import_library(rows((1, "A歌", "国语", 10), (2, "B歌", "粤语", 9), (3, "C歌", "英语", 8), (4, "D歌", "国语", 7)), "v1") self._tasks: list = [] def task(self, *args, **kw) -> MaintainTask: """建一个维护任务并登记,交给 tearDown 统一收尾。""" t = MaintainTask(*args, **kw) self._tasks.append(t) return t def tearDown(self): for t in self._tasks: try: t.stop("tearDown") except Exception: pass for t in self._tasks: # 等调度线程 + run_now 线程都结束 try: t.join(3.0) except Exception: pass try: self.store.conn.close() except Exception: pass # 常用打桩 @staticmethod def _ok_scan(): return {"ok": True, "scanned": 0, "adopted": [], "adopted_count": 0, "updated": 0, "skipped": 0} @staticmethod def _no_retry(): return 0 @staticmethod def _noop_drain(_timeout): return {"drain_timeout": False, "pending_left": 0, "active_left": 0} # ──────────────────────────── 需求① 配置 ──────────────────────────── class TestMaintainConfig(Case): def test_defaults(self): self.assertFalse(self.cfg.get("maintain_enabled")) # 默认要人点启动 self.assertEqual(self.cfg.get("maintain_interval_sec"), 600) # 10 分钟 self.assertTrue(self.cfg.get("maintain_scan_media")) self.assertTrue(self.cfg.get("maintain_retry_failed")) self.assertTrue(self.cfg.get("maintain_heartbeat")) self.assertEqual(self.cfg.get("maintain_drain_timeout_sec"), 3600) self.assertEqual(self.cfg.get("device_identity_ttl_hours"), 24) def test_interval_and_ttl_are_clamped(self): warns = self.cfg.update({"maintain_interval_sec": 5, "device_identity_ttl_hours": -1}) self.assertEqual(self.cfg.get("maintain_interval_sec"), 30) self.assertEqual(self.cfg.get("device_identity_ttl_hours"), 24) self.assertTrue(any("维护间隔" in w for w in warns), warns) class TestMaintainScanRequeuesMissing(Case): """★核心行为保留:维护任务每轮的「扫描媒体目录」= 登记新文件 + **done 但文件缺失/过小 → 回队**。 旧的独立「对账」概念(按钮/接口/定时线程)已删除,但它的核心作用不能丢 —— 这里走**真实的**扫描函数(`admin.run_media_scan` → `Store.keep_adopt_media`),不是打桩。 """ def test_real_scan_step_requeues_missing_and_adopts(self): self.cfg.update({"media_scan_min_bytes": 100}) self.store.keep_add([1, 2]) self.store.keep_mark_done(1, 4096, os.path.join(self.media, "1.ts")) # done 但文件不存在 with open(os.path.join(self.media, "9.ts"), "wb") as fh: # 目录里的未知文件 fh.write(b"\x47" * 4096) task = self.task(self.cfg, self.store, None, None, retry_fn=self._no_retry, drain_fn=self._noop_drain) res = task.run_cycle(now=1_700_000_000) self.assertEqual(res["scanned"], 1, res) self.assertEqual(res["adopted"], 1, res) # 9 被登记进歌曲表 self.assertEqual(res["requeued"], 1, res) # 1 被放回待下载 self.assertEqual(self.store.q1("SELECT state FROM keeps WHERE tid=1")["state"], "want") self.assertEqual(self.store.q1("SELECT state FROM keeps WHERE tid=9")["state"], "done") self.assertTrue(os.path.isfile(os.path.join(self.media, "9.ts"))) # 绝不删文件 # 上一轮结果里也带着"缺失回队",界面/日志能看到 self.assertEqual(task.status()["last_result"]["requeued"], 1) # ──────────────────────────── 需求①① 一轮顺序 ──────────────────────────── class TestCycleOrder(Case): """第 1 项:一轮的完整顺序,断言调用序列。""" def test_full_order_identity_scan_retry_enqueue_drain_heartbeat(self): now = 1_700_000_000 log: list = [] spy = SpyStore(self.store, log) ident = FakeIdentity(self.cfg, created_at=now - 25 * 3600, log=log) # 已过期 → 触发 regenerate w = FakeWhoami(log) self.store.keep_add([1]) # 有歌要下 task = self.task(self.cfg, spy, None, None, whoami=w, identity=ident, scan_fn=lambda: (log.append("scan"), self._ok_scan())[1], retry_fn=lambda: (log.append("retry"), 7)[1], drain_fn=lambda t: (log.append("drain"), {"drain_timeout": False, "pending_left": 0})[1], now_fn=lambda: now) res = task.run_cycle(now=now) self.assertEqual(log, ["identity.regenerate", "scan", "retry", "enqueue", "whoami.start", "drain", "whoami.stop"], log) self.assertTrue(res["identity_regenerated"]) self.assertEqual(res["retried"], 7) self.assertEqual(res["enqueued"], 1) self.assertTrue(res["heartbeat"]) self.assertTrue(res["ok"]) def test_states_walk_through_machine_values(self): """状态机取值必须在跑动中被依次用到(checking_identity→scanning→retrying→downloading)。""" now = 1_700_000_000 seen: list = [] task = self.task(self.cfg, self.store, None, None, scan_fn=lambda: (seen.append(task.state["state"]), self._ok_scan())[1], retry_fn=lambda: (seen.append(task.state["state"]), 0)[1], drain_fn=lambda t: (seen.append(task.state["state"]), {"drain_timeout": False})[1], now_fn=lambda: now) self.store.keep_add([1]) task.run_cycle(now=now) self.assertEqual(seen, ["scanning", "retrying", "downloading"], seen) self.assertIn(task.status()["state"], ("idle", "stopped")) # ──────────────────────────── 需求① 排程 / 重启 ──────────────────────────── class TestSchedule(Case): """第 2 项:next_run_at = 本轮结束 + interval;restart 立即开始新一轮。""" def test_next_run_at_is_end_plus_interval(self): now = 1_700_000_000 clock = Clock(now) def scan(): clock.t = now + 42 # 模拟"这一轮跑了 42 秒" return self._ok_scan() task = self.task(self.cfg, self.store, None, None, scan_fn=scan, retry_fn=self._no_retry, drain_fn=self._noop_drain, now_fn=clock.now) task.state["enabled"] = True # 模拟"已在运行"(只测排程,不真起线程) res = task.run_cycle(now=now) self.assertEqual(res["ended_at"], now + 42) self.assertEqual(res["next_run_at"], now + 42 + 600) self.assertEqual(task.status()["next_run_at"], now + 42 + 600) self.assertEqual(task.status()["last_run_at"], now) self.assertEqual(task.status()["interval_sec"], 600) self.assertEqual(task.status()["state"], "idle") def test_restart_starts_a_new_cycle_immediately(self): log: list = [] task = self.task(self.cfg, self.store, None, None, scan_fn=lambda: (log.append("scan"), self._ok_scan())[1], retry_fn=self._no_retry, drain_fn=self._noop_drain) task.start() self.assertTrue(wait_until(lambda: task.status()["cycle_count"] >= 1), "首轮没跑起来") with task._lock: # 把下一轮排到很远 task.state["next_run_at"] = int(time.time()) + 10 ** 6 task.restart() st = task.status() self.assertTrue(st["enabled"]) self.assertLessEqual(st["next_run_at"], int(time.time())) # 立即,不留 interval self.assertTrue(wait_until(lambda: task.status()["cycle_count"] >= 1, 3.0), "restart 没有立即开始新一轮") def test_stop_then_state_is_stopped_and_no_more_polling(self): log: list = [] calls = {"n": 0} def scan(): calls["n"] += 1 log.append("scan") return self._ok_scan() task = self.task(self.cfg, self.store, None, None, whoami=FakeWhoami(log), scan_fn=scan, retry_fn=self._no_retry, drain_fn=self._noop_drain) task.start() self.assertTrue(wait_until(lambda: task.status()["cycle_count"] >= 1)) task.stop("测试停止") st = task.status() self.assertEqual(st["state"], "stopped") self.assertFalse(st["enabled"]) self.assertEqual(st["next_run_at"], 0) self.assertGreaterEqual(log.count("whoami.stop"), 1) # 停维护 → 必须停心跳 n, c = calls["n"], st["cycle_count"] time.sleep(0.3) self.assertEqual(calls["n"], n, "stop() 之后还有轮询动作") self.assertEqual(task.status()["cycle_count"], c) # ──────────────────────────── 需求② 心跳启停 ──────────────────────────── class TestHeartbeat(Case): """第 3 项:没歌要下不启动;要下载则启动、drain 后停止(含 drain 抛异常)。""" def _task(self, log, drain=None): return self.task(self.cfg, self.store, None, None, whoami=FakeWhoami(log), scan_fn=self._ok_scan, retry_fn=self._no_retry, drain_fn=drain or (lambda t: (log.append("drain"), {"drain_timeout": False})[1]), now_fn=lambda: 1_700_000_000) def test_no_songs_to_download_never_starts_heartbeat(self): log: list = [] task = self._task(log) res = task.run_cycle(now=1_700_000_000) self.assertFalse(res["heartbeat"]) self.assertNotIn("whoami.start", log) self.assertNotIn("drain", log) # 没歌要下 → 连 drain 都不进 self.assertEqual(task.status()["heartbeat_running"], False) def test_songs_to_download_starts_then_stops_heartbeat(self): log: list = [] self.store.keep_add([1]) task = self._task(log) res = task.run_cycle(now=1_700_000_000) self.assertTrue(res["heartbeat"]) self.assertEqual(log.count("whoami.start"), 1) self.assertEqual(log.count("whoami.stop"), 1) # drain 结束 → finally 停心跳 self.assertIn("drain", log) self.assertLess(log.index("whoami.start"), log.index("drain")) # start 在 downloading 前 def test_drain_raising_still_stops_heartbeat(self): log: list = [] def boom(_timeout): log.append("drain") raise RuntimeError("drain 炸了") self.store.keep_add([1]) task = self._task(log, drain=boom) res = task.run_cycle(now=1_700_000_000) self.assertEqual(log.count("whoami.start"), 1) self.assertEqual(log.count("whoami.stop"), 1) # finally 保证停 self.assertIn("drain 炸了", res["drain_error"]) self.assertTrue(res["ok"]) # 不算本轮致命错误 def test_heartbeat_switch_off(self): log: list = [] self.cfg.update({"whoami_enabled": False}) self.store.keep_add([1]) task = self._task(log) res = task.run_cycle(now=1_700_000_000) self.assertFalse(res["heartbeat"]) self.assertNotIn("whoami.start", log) def test_maintain_heartbeat_flag_off(self): log: list = [] self.cfg.update({"maintain_heartbeat": False}) self.store.keep_add([1]) task = self._task(log) task.run_cycle(now=1_700_000_000) self.assertNotIn("whoami.start", log) # ──────────────────────────── 需求② 身份 24h 时效 ──────────────────────────── class TestIdentityTtl(Case): """第 4 项:过期自动 regenerate,且 i.php/do.php/whoami 三处立刻同源。""" def _install_identity(self, created_at: int): path = os.path.join(self.tmp, "device-identity.json") ident = identity.generate(preset="aml_s905x", rnd=random.Random(7)) ident["created_at"] = int(created_at) identity.save(ident, path) mgr = identity.DeviceIdentity(self.cfg, self.store) got = mgr.ensure() self.assertIsNotNone(got) return mgr, dict(got) def _task(self, mgr, downloader, now): return self.task(self.cfg, self.store, downloader, None, identity=mgr, scan_fn=self._ok_scan, retry_fn=self._no_retry, drain_fn=self._noop_drain, now_fn=lambda: now) def test_expired_identity_is_regenerated_and_synced(self): now = 1_700_000_000 mgr, old = self._install_identity(now - 25 * 3600) dl = Downloader(self.cfg, self.store) dl.resolver.tokens["http://upstream"] = "old-token" # 旧 token 绑旧 device task = self._task(mgr, dl, now) res = task.run_cycle(now=now) self.assertTrue(res["identity_regenerated"]) new = mgr.identity self.assertNotEqual(new["mac"], old["mac"]) self.assertNotEqual(new["sn"], old["sn"]) # ① i.php:`mac=_` 用的是新身份 self.assertEqual(dl.resolver.mac_sn(), (new["mac"], new["sn"])) self.assertIn(f"mac={new['mac']}_{new['sn']}", R.token_params(*dl.resolver.mac_sn())) # ② do.php:`device=_` 用同一份 self.assertIn(f"device={new['mac']}_{new['sn']}", R.song_params(1, "tk", *dl.resolver.mac_sn())) # ③ whoami:`_mac`/`androidid`/`_cid` 也来自同一份身份 self.assertEqual(whoami.device_facts(self.cfg)["_mac"], new["mac"]) self.assertEqual(identity.active_identity(self.cfg)["mac"], new["mac"]) # 旧 token 必须清掉 self.assertEqual(dl.resolver.tokens, {}) # 事件日志 evs = [e["text"] for e in self.store.events(50)] self.assertTrue(any("设备身份已过期" in x and f"新 mac={new['mac']}" in x for x in evs), evs) # 新身份年龄归零、有效期回到整段 TTL self.assertEqual(res["identity_age_sec"], 0) self.assertEqual(res["identity_expires_in_sec"], 24 * 3600) def test_fresh_identity_is_kept(self): now = 1_700_000_000 mgr, old = self._install_identity(now - 3600) # 只用了 1 小时 dl = Downloader(self.cfg, self.store) task = self._task(mgr, dl, now) res = task.run_cycle(now=now) self.assertFalse(res["identity_regenerated"]) self.assertEqual(mgr.identity["mac"], old["mac"]) self.assertEqual(mgr.identity["sn"], old["sn"]) self.assertEqual(dl.resolver.mac_sn(), (old["mac"], old["sn"])) evs = [e["text"] for e in self.store.events(50)] self.assertFalse(any("设备身份已过期" in x for x in evs), evs) def test_status_and_device_api_expose_ttl_fields(self): now = int(time.time()) mgr, _ = self._install_identity(now - 25 * 3600) st = mgr.status() for key in ("age_sec", "expires_in_sec", "ttl_hours", "expired"): self.assertIn(key, st) self.assertEqual(st["ttl_hours"], 24) self.assertTrue(st["expired"]) self.assertEqual(st["expires_in_sec"], 0) mgr2, _ = self._install_identity(now - 3600) st2 = mgr2.status() self.assertFalse(st2["expired"]) self.assertGreater(st2["expires_in_sec"], 0) self.assertGreaterEqual(st2["age_sec"], 3000) # GET /api/device 也带这四个字段 router = admin.build_router(self.cfg, self.store, None, device_identity=mgr2) dev = json.loads(router.dispatch(req("GET", "/api/device")).body) for key in ("age_sec", "expires_in_sec", "ttl_hours", "expired"): self.assertIn(key, dev) # ──────────────────────────── 第 6 项 扫描/重试失败不阻断 ──────────────────────────── class TestStepFailuresDoNotAbort(Case): def test_scan_and_retry_errors_do_not_block_enqueue_and_drain(self): now = 1_700_000_000 log: list = [] def bad_scan(): raise RuntimeError("FUSE 掉线") def bad_retry(): raise RuntimeError("数据库忙") self.store.keep_add([1]) task = self.task(self.cfg, self.store, None, None, whoami=FakeWhoami(log), scan_fn=bad_scan, retry_fn=bad_retry, drain_fn=lambda t: (log.append("drain"), {"drain_timeout": False})[1], now_fn=lambda: now) res = task.run_cycle(now=now) self.assertIn("FUSE 掉线", res["scan_error"]) self.assertIn("数据库忙", res["retry_error"]) self.assertEqual(res["enqueued"], 1) # 后续步骤照跑 self.assertIn("drain", log) self.assertTrue(res["ok"]) # 不是本轮致命错误 self.assertNotEqual(task.status()["state"], "error") evs = [e["text"] for e in self.store.events(50)] self.assertTrue(any("扫描媒体目录失败" in x for x in evs), evs) self.assertTrue(any("失败重试失败" in x for x in evs), evs) def test_scan_result_ok_false_is_recorded(self): now = 1_700_000_000 task = self.task(self.cfg, self.store, None, None, scan_fn=lambda: {"ok": False, "error": "媒体目录列举超时(>30s)"}, retry_fn=self._no_retry, drain_fn=self._noop_drain, now_fn=lambda: now) res = task.run_cycle(now=now) self.assertIn("超时", res["scan_error"]) self.assertTrue(res["ok"]) # ──────────────────────────── 第 7/8 项 接口层 ──────────────────────────── class TestMaintainApi(Case): """接口全链路 + 旧 /api/batch/* 薄包装。""" def setUp(self): super().setUp() self.log: list = [] self.whoami = FakeWhoami(self.log) self.maint = self.task(self.cfg, self.store, None, None, whoami=self.whoami, scan_fn=lambda: (self.log.append("scan"), self._ok_scan())[1], retry_fn=self._no_retry, drain_fn=self._noop_drain) self.router = admin.build_router(self.cfg, self.store, None, whoami=self.whoami, maintain=self.maint) def call(self, method, path, body=b""): return self.router.dispatch(req(method, path, body=body)) def test_status_keys(self): d = json.loads(self.call("GET", "/api/maintain/status").body) for key in ("state", "state_text", "enabled", "interval_sec", "last_run_at", "next_run_at", "last_result", "cycle_count", "error", "identity_age_sec", "identity_expires_in_sec", "heartbeat_running", "keeps_want", "keeps_done", "keeps_total", "keeps_remaining", "pending", "library"): self.assertIn(key, d, f"状态缺少 {key}") self.assertEqual(d["state"], "stopped") self.assertFalse(d["enabled"]) self.assertEqual(d["interval_sec"], 600) def test_start_stop_restart_run_now_chain(self): d = json.loads(self.call("POST", "/api/maintain/start", jbody({})).body) self.assertTrue(d["ok"]) self.assertTrue(wait_until(lambda: self.maint.status()["cycle_count"] >= 1)) st = json.loads(self.call("GET", "/api/maintain/status").body) self.assertTrue(st["enabled"]) self.assertIn(st["state"], ("idle", "downloading", "scanning", "retrying", "checking_identity")) d = json.loads(self.call("POST", "/api/maintain/restart", jbody({})).body) self.assertTrue(d["ok"]) self.assertTrue(d["restart"]) self.assertTrue(wait_until(lambda: self.maint.status()["cycle_count"] >= 1)) # run_now:**立刻返回**(后台执行) t0 = time.time() d = json.loads(self.call("POST", "/api/maintain/run_now", jbody({})).body) self.assertLess(time.time() - t0, 0.5, "run_now 不能同步跑长任务") self.assertTrue(d["started"]) d = json.loads(self.call("POST", "/api/maintain/stop", jbody({"reason": "接口测试"})).body) self.assertTrue(d["ok"]) st = json.loads(self.call("GET", "/api/maintain/status").body) self.assertEqual(st["state"], "stopped") self.assertEqual(st["next_run_at"], 0) def test_run_now_returns_immediately_even_when_cycle_is_slow(self): log: list = [] slow = self.task(self.cfg, self.store, None, None, scan_fn=lambda: (time.sleep(1.0), self._ok_scan())[1], retry_fn=self._no_retry, drain_fn=self._noop_drain) router = admin.build_router(self.cfg, self.store, None, maintain=slow) t0 = time.time() d = json.loads(router.dispatch(req("POST", "/api/maintain/run_now", body=b"{}")).body) self.assertLess(time.time() - t0, 0.4, "run_now 阻塞了 HTTP 请求线程") self.assertTrue(d["ok"]) self.assertTrue(wait_until(lambda: slow.status()["cycle_count"] >= 1, 3.0)) def test_run_now_does_not_change_schedule(self): """`run_now` 只是"额外跑一轮",**不改** next_run_at(排程照旧)。""" self.maint.start() self.assertTrue(wait_until(lambda: self.maint.status()["cycle_count"] >= 1)) with self.maint._lock: # 固定一个未来的排程 self.maint.state["next_run_at"] = int(time.time()) + 5000 nxt = self.maint.status()["next_run_at"] self.maint.run_now() self.assertTrue(wait_until(lambda: self.maint.status()["cycle_count"] >= 2, 3.0), "run_now 没有真的跑一轮") self.assertEqual(self.maint.status()["next_run_at"], nxt) def test_legacy_batch_endpoints_are_wrappers(self): d = json.loads(self.call("POST", "/api/batch/start", jbody({})).body) self.assertTrue(d["ok"]) self.assertTrue(wait_until(lambda: self.maint.status()["cycle_count"] >= 1)) st = json.loads(self.call("GET", "/api/batch/status").body) self.assertIn("state", st) # 指向 maintain 的状态 self.assertIn("next_run_at", st) self.assertTrue(st["enabled"]) self.assertEqual(st["interval_sec"], 600) d = json.loads(self.call("POST", "/api/batch/stop", jbody({"reason": "旧脚本"})).body) self.assertTrue(d["ok"]) self.assertFalse(self.maint.status()["enabled"]) # 概览接口的 batch 字段也指向维护状态(老界面/脚本读 d.batch) ov = json.loads(self.call("GET", "/api/stats").body) self.assertIn("state", ov["batch"]) self.assertEqual(ov["batch"]["interval_sec"], 600) def test_manual_whoami_send_still_available(self): """手工上报不受维护任务限制:路由仍在,且能拿到结果(这里只验证路由与打桩)。""" calls = {"n": 0} class _Rep: def send_now(self): calls["n"] += 1 return {"ok": True, "http": 200} def status(self): return {"enabled": True, "running": False} router = admin.build_router(self.cfg, self.store, None, whoami=_Rep()) d = json.loads(router.dispatch(req("POST", "/api/whoami/send", body=b"{}")).body) self.assertTrue(d["ok"]) self.assertEqual(calls["n"], 1) # ──────────────────────────── 管理界面契约(源码级)──────────────────────────── class TestMaintainUiContract(unittest.TestCase): def setUp(self): self.html = ui.PAGE def test_card_title_and_buttons(self): self.assertIn("歌曲表自动维护", self.html) i = self.html.index("歌曲表自动维护") block = self.html[i:i + 1600] for label in (">启动<", ">停止<", ">重启<", ">立即维护一次<"): self.assertIn(label, block, f"卡片缺少按钮 {label}") for action in ("start", "stop", "restart", "run_now"): self.assertIn(f"maintainAction(this,'{action}')", block) def test_status_cell_shows_state_only(self): """① 状态单元格只保留状态本身;「下一次维护 / whoami 心跳 / 设备身份有效期」三行删掉。""" for token in ("已停止", "运行中 · 空闲", "运行中 · 正在下载", "上一轮结果", "扫描登记", "缺失回队", "重试", "入队", "完成", "失败", "keeps_total", "keeps_want", "keeps_done", "keeps_remaining", "预计剩余", "首/小时"): self.assertIn(token, self.html, f"界面缺少 {token}") for gone in ("下一次维护", "whoami 心跳", "设备身份有效期"): self.assertNotIn(gone, self.html, f"维护卡片里不该再有 {gone}") def test_maintain_card_has_no_long_paragraphs(self): """② 两段长说明文字删干净(只留按钮 + 状态/进度)。""" for gone in ("有歌要下才上报 whoami 心跳、排空后自动停", "一轮顺序固定:", "设备身份时效 → "): self.assertNotIn(gone, self.html, f"维护卡片里不该再有长说明:{gone}") # 统计口径:维护卡片区块内不再有 100 字以上的说明行 i = self.html.index("歌曲表自动维护") block = self.html[i:i + 900] for line in block.splitlines(): self.assertLess(len(line), 120, f"维护卡片仍有长段落:{line[:60]}…") def test_polling_and_patch_not_full_rebuild(self): self.assertIn("/api/maintain/status", self.html) self.assertIn("ovBatch()", self.html) # 挂在统一轮询里 self.assertIn("patch('ovBatchBody'", self.html) # 局部刷新,不整页重绘 self.assertIn("id=\"ovBatchBody\"", self.html) self.assertIn("REFRESH_MS = 4000", self.html) # 4 秒轮询 def test_settings_expose_maintain_keys(self): for key in ("maintain_enabled", "maintain_interval_sec", "maintain_scan_media", "maintain_retry_failed", "maintain_heartbeat", "maintain_drain_timeout_sec", "device_identity_ttl_hours"): self.assertIn(f"'{key}'", self.html, f"设置页缺少 {key}") class TestRemovedSyncButtonsUiContract(unittest.TestCase): """① 概览页「同步曲库」区块的三个按钮(自动入队 / 对账 / 歌曲表对账)彻底消失。""" def setUp(self): self.html = ui.PAGE def test_button_texts_gone(self): for gone in ("自动入队", "对账", "孤儿清理"): self.assertNotIn(gone, self.html, f"页面里不该再出现「{gone}」") def test_button_handlers_and_endpoints_gone(self): for gone in ("autoEnqueue", "reconcileKeeps", "auto_enqueue", "/api/maintenance/reconcile", "/api/keeps/reconcile", "keep_reconcile_interval_sec", "keep_prune_orphans", "keep_delete_missing_files", "mode:'auto'"): self.assertNotIn(gone, self.html, f"页面里不该再残留 {gone}") def test_other_sync_buttons_untouched(self): for kept in ("同步曲库(官方源)", "取消同步", "启动下载池", "暂停", "继续", "停止", "扫描媒体目录", "加入歌曲表"): self.assertIn(kept, self.html, f"页面缺少仍然保留的按钮 {kept}") class TestMergedDeviceCardUiContract(unittest.TestCase): """② 设备心跳(whoami)与设备身份(伪装为安卓盒子)合并为一个「设备身份与心跳」单元。""" def setUp(self): self.html = ui.PAGE def test_single_merged_card(self): self.assertIn("设备身份与心跳", self.html) self.assertEqual(self.html.count("设备身份与心跳"), 1, "合并后的标题只应出现一次") # 旧的两张独立卡片标题不再出现(「设备身份」作为普通设置项文案不算卡片标题) self.assertNotIn("设备心跳", self.html) self.assertNotIn(">设备身份(伪装为安卓盒子)<", self.html) self.assertNotIn("ovWhoamiBody", self.html) def test_merged_card_has_both_buttons_and_local_patch(self): i = self.html.index("设备身份与心跳") block = self.html[i:i + 1200] for label in (">重新生成身份<", ">立即上报心跳<"): self.assertIn(label, block, f"合并卡片缺少按钮 {label}") for path in ("'/api/whoami/send'", "regenDevice(this)"): self.assertIn(path, block, f"合并卡片缺少 {path}") # 一个渲染函数 + 一处局部刷新(不整页重绘) self.assertIn("async function ovDeviceHeartbeat(){", self.html) self.assertEqual(self.html.count("patch('ovDeviceBody'"), 1) self.assertIn("ovDeviceHeartbeat()", self.html) # 挂在统一轮询里 self.assertIn('id="ovDeviceBody"', self.html) self.assertNotIn("ovWhoami()", self.html) if __name__ == "__main__": unittest.main(verbosity=2)