版本号:
- 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 例)
744 lines
35 KiB
Python
744 lines
35 KiB
Python
#!/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)
|