Files
maidong-ktv/selfhost-source/server/tests/test_maintain.py
T
zijunchiang 87aa979abd 合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
2026-10-14 02:06:28 +08:00

744 lines
35 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.
#!/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=<mac>_<sn>` 用的是新身份
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=<mac>_<sn>` 用同一份
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)