版本号:
- 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 例)
212 lines
9.5 KiB
Python
212 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
||
"""管理界面性能与前端约定的回归测试(任务②:点「同步曲库」不再卡住)。
|
||
|
||
覆盖三条硬要求:
|
||
1) `/api/stats` 在 69 万行级别的库上必须快 —— 走 `stats_cache_sec` TTL 缓存(目标 < 300ms),
|
||
且写入 keeps/songs/downloads 后缓存必须立刻失效(不能显示陈旧数字);
|
||
2) 曲库导入/清单比对**必须分块**:长事务不再独占 `Store._lock`,
|
||
"同步进行中"轮询接口依然能响应(这里用一个并发轮询线程测最大延迟);
|
||
3) 管理页的 JS 结构:一个共享的、防重入的轮询 + 只更新"变化的 DOM 块",
|
||
整页 `innerHTML` 重建只允许出现在"进入页签时渲染骨架"的那几个函数里。
|
||
"""
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from ktv import admin, ui
|
||
from ktv.config import Config
|
||
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 _library(n: int) -> list[tuple]:
|
||
return [(i, f"歌{i}", "国语", "", f"歌手{i % 37}", f"{i}.ts", i % 1000, "") for i in range(1, n + 1)]
|
||
|
||
|
||
class CountingStore(Store):
|
||
"""统计真算次数的 Store(用来验证 TTL 缓存真的生效/真的失效)。"""
|
||
|
||
def __init__(self, *a, **k):
|
||
super().__init__(*a, **k)
|
||
self.stats_calls = 0
|
||
self.keep_stats_calls = 0
|
||
|
||
def _stats_uncached(self):
|
||
self.stats_calls += 1
|
||
return super()._stats_uncached()
|
||
|
||
def _keep_stats_uncached(self):
|
||
self.keep_stats_calls += 1
|
||
return super()._keep_stats_uncached()
|
||
|
||
|
||
class StatsCacheCase(unittest.TestCase):
|
||
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.db = os.path.join(self.tmp, "t.db")
|
||
|
||
def tearDown(self):
|
||
try:
|
||
self.store.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class TestStatsCache(StatsCacheCase):
|
||
"""1) 概览统计:TTL 缓存 + 写入即失效。"""
|
||
|
||
def test_ttl_cache_hits_and_write_invalidates(self):
|
||
self.store = CountingStore(self.db, "DELETE", stats_cache_sec=5)
|
||
self.store.import_library(_library(200), "v1")
|
||
self.store.keep_add([1, 2, 3])
|
||
base_stats = self.store.stats_calls
|
||
base_keeps = self.store.keep_stats_calls
|
||
|
||
for _ in range(5): # TTL 内反复取 → 只真算一次
|
||
self.store.stats()
|
||
self.store.keep_stats()
|
||
self.assertEqual(self.store.stats_calls - base_stats, 1)
|
||
self.assertEqual(self.store.keep_stats_calls - base_keeps, 1)
|
||
|
||
self.store.keep_add([4]) # 写 keeps → 缓存立刻失效
|
||
self.assertEqual(self.store.keep_stats()["total"], 4) # 不是陈旧的 3
|
||
self.assertEqual(self.store.keep_stats_calls - base_keeps, 2)
|
||
self.store.stats()
|
||
self.assertEqual(self.store.stats_calls - base_stats, 2) # keeps 的写入也让 stats 重算
|
||
|
||
self.store.import_library(_library(201), "v2") # 写 songs → 同样失效且数字是新的
|
||
self.assertEqual(self.store.stats()["songs"], 201)
|
||
self.assertEqual(self.store.stats_calls - base_stats, 3)
|
||
|
||
def test_zero_ttl_means_no_cache(self):
|
||
self.store = CountingStore(self.db, "DELETE", stats_cache_sec=0)
|
||
self.store.import_library(_library(50), "v1")
|
||
self.store.stats(); self.store.stats()
|
||
self.assertEqual(self.store.stats_calls, 2) # 0 = 每次都真查(现有行为不变)
|
||
|
||
def test_api_stats_is_fast_with_cache(self):
|
||
"""69 万行级别的统计:缓存命中后 /api/stats 必须 < 300ms。"""
|
||
self.store = Store(self.db, "DELETE", stats_cache_sec=0)
|
||
self.store.import_library(_library(50000), "v1")
|
||
self.store.keep_add([1, 2, 3])
|
||
router = admin.build_router(self.cfg, self.store, None)
|
||
|
||
self.store.stats_cache_sec = 0
|
||
self.store.clear_stats_cache()
|
||
admin._stats_side_cache.clear()
|
||
t0 = time.perf_counter()
|
||
resp = router.dispatch(req("GET", "/api/stats"))
|
||
uncached_ms = (time.perf_counter() - t0) * 1000
|
||
self.assertEqual(resp.status, 200)
|
||
|
||
self.store.stats_cache_sec = 4
|
||
self.store.clear_stats_cache()
|
||
admin._stats_side_cache.clear()
|
||
router.dispatch(req("GET", "/api/stats")) # 第一次:填缓存
|
||
t0 = time.perf_counter()
|
||
resp2 = router.dispatch(req("GET", "/api/stats")) # 第二次:命中缓存
|
||
cached_ms = (time.perf_counter() - t0) * 1000
|
||
self.assertEqual(resp2.status, 200)
|
||
self.assertLess(cached_ms, 300.0, f"缓存命中后仍然 {cached_ms:.0f}ms(无缓存时 {uncached_ms:.0f}ms)")
|
||
self.assertLessEqual(cached_ms, uncached_ms + 5.0)
|
||
|
||
def test_library_snapshot_is_chunked_and_complete(self):
|
||
self.store = Store(self.db, "DELETE")
|
||
self.store.import_library(_library(12345), "v1")
|
||
snap = self.store._library_snapshot(chunk=1000) # 分块读也不许漏行
|
||
self.assertEqual(len(snap), 12345)
|
||
self.assertEqual(snap[12345]["name"], "歌12345")
|
||
|
||
|
||
class TestImportDoesNotBlockPolling(StatsCacheCase):
|
||
"""2) 导入/比对分块提交:同步进行中轮询接口仍可响应。"""
|
||
|
||
def test_poll_latency_during_import(self):
|
||
self.store = Store(self.db, "DELETE", stats_cache_sec=0)
|
||
self.store.import_library(_library(2000), "v0")
|
||
lat: list[float] = []
|
||
stop = threading.Event()
|
||
|
||
def poller():
|
||
while not stop.is_set():
|
||
t0 = time.perf_counter()
|
||
self.store.stats()
|
||
lat.append((time.perf_counter() - t0) * 1000)
|
||
time.sleep(0.02)
|
||
|
||
th = threading.Thread(target=poller, name="poll")
|
||
th.start()
|
||
try:
|
||
self.store.import_library(_library(40000), "v1") # 4 万行:分块提交
|
||
finally:
|
||
stop.set()
|
||
th.join(timeout=5)
|
||
self.assertGreaterEqual(len(lat), 5, "轮询根本没跑起来")
|
||
self.assertLess(max(lat), 1000.0, f"导入期间轮询最大延迟 {max(lat):.0f}ms(挡住了就是整页卡住的根源)")
|
||
self.assertEqual(self.store.stats()["songs"], 40000)
|
||
|
||
|
||
class TestUiAsyncContract(unittest.TestCase):
|
||
"""3) 管理页 JS 的结构约定(把"卡住"的两大原因钉死:前端 await + 整页重建)。"""
|
||
|
||
def setUp(self):
|
||
self.html = ui.PAGE
|
||
m = re.search(r"<script>(.*)</script>", self.html, re.S)
|
||
self.js = m.group(1)
|
||
|
||
def test_shared_polling_with_reentrancy_guard(self):
|
||
self.assertIn("setInterval(()=>tick(false), REFRESH_MS)", self.js)
|
||
self.assertIn("Promise.allSettled(jobs)", self.js)
|
||
self.assertRegex(self.js, r"if\(tickBusy\) return;") # 防重入
|
||
self.assertIn("id=\"autoRefresh\"", self.html) # 自动刷新开关
|
||
self.assertIn("id=\"lastUpdate\"", self.html) # 最后更新时间
|
||
self.assertIn("touchUpdate()", self.js)
|
||
|
||
def test_action_buttons_do_not_block(self):
|
||
"""动作接口一律走 act()(loading + 顶部提示 + 轮询出结果),不能裸 await。"""
|
||
for path in ("/api/library/sync", "/api/library/cancel", "/api/download/retry_failed",
|
||
"/api/upstream/probe", "/api/download/start", "/api/download/stop"):
|
||
self.assertNotIn(f"await api('{path}'", self.js, f"{path} 不能让前端等长任务")
|
||
self.assertTrue(f"'{path}'" in self.js, f"页面里没有 {path}")
|
||
self.assertRegex(self.js, r"async function act\(")
|
||
self.assertTrue("已发起" in self.js)
|
||
|
||
def test_full_page_rebuild_only_in_skeletons(self):
|
||
"""整页 innerHTML 只允许出现在"进入页签时渲染骨架"的函数里(轮询绝不重建整页)。"""
|
||
allowed = {"renderOverview", "renderSongs", "renderKeeps", "loadTasks",
|
||
"renderSettings", "loadSettings", "renderLogs"}
|
||
for m in re.finditer(r"\$\('#tab-(\w+)'\)\.innerHTML", self.js):
|
||
func = re.findall(r"\n(?:async )?function (\w+)\(", self.js[:m.start()])[-1]
|
||
self.assertIn(func, allowed, f"{func}() 里整页重建了 #tab-{m.group(1)}")
|
||
|
||
def test_blocks_are_patched_by_signature(self):
|
||
self.assertRegex(self.js, r"function patch\(id, sig, html\)")
|
||
self.assertTrue("SIG[id] === sig" in self.js) # 内容没变就不动 DOM
|
||
for block in ("ovCards", "ovBatchBody", "ovStorageBody", "upstream",
|
||
"syncTasks", "syncTasksAll", "dlTasks", "activeRows", "kRows", "sRows", "logPre"):
|
||
self.assertTrue(f"'{block}'" in self.js, f"没有按块更新 {block}")
|
||
|
||
def test_progress_is_visible(self):
|
||
"""同步有进度条/百分比/已用时;批量有 keeps 计数、百分比、速率与预计剩余。"""
|
||
for token in ("syncBar", "syncElapsed", "已用时", "keeps_total", "keeps_want",
|
||
"keeps_done", "keeps_remaining", "预计剩余", "首/小时", "%"):
|
||
self.assertTrue(token in self.html, f"页面缺少进度元素 {token}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|