Files
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

887 lines
48 KiB
Python
Raw Permalink 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
"""歌曲表(keeps)专项测试:**名单 → 下载 → 删除文件 → 媒体扫描** 的一致性(不启动服务、不联网)。
覆盖需求里的每一条:
1) 增 / 删 / 查 / 分页 / 统计(**曲库没有的编号也允许加入**,标记 matched=false);
2) **删除歌曲表条目会删除媒体文件**(临时目录造文件;断言删除与 freed_bytes;并断言
**不会**删到 media_dir 之外的同名文件,也不会跟软链接逃逸到目录外);
3) 导入 merge / replace 与导出往返(列与曲库清单导出一致);
**只给「编号」的 CSV 导入后,用曲库自动补全歌名/歌手等字段**(端到端,见 TestKeepSelfFill);
4) `keep_next_batch` 只返回歌曲表里未完成、且能从曲库匹配到的歌(不在表里的歌永不下);
5) `keep_adopt_media` 四种情形(未知文件**登记不删** / 在表里不是 done → 更新为 done /
**done 但文件缺失或过小 → 回队** / 目录不可用 → 什么都不改);
6) 批量驱动器与下载器只认歌曲表;管理接口 /api/keeps* 全链路。
"""
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import admin
from ktv.batch import BatchManager
from ktv.config import Config
from ktv.downloader import Downloader
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 rows(*specs):
"""(tid, name, lang, singers, hot) → import_library 需要的一行。"""
return [(tid, name, lang, "", singers, f"{tid}.ts", hot, "") for tid, name, lang, singers, hot in specs]
class KeepCase(unittest.TestCase):
"""公共夹具:4 首歌的小曲库 + 一个空的媒体目录(都在临时目录里)。"""
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歌", "国语", "s1", 10),
(2, "B歌", "粤语", "s2", 9),
(3, "C歌", "英语", "s3", 8),
(4, "D歌", "国语", "s4", 7)), "v1")
def tearDown(self):
try:
self.store.conn.close() # 显式关连接:避免 ResourceWarning 污染验收输出
except Exception:
pass
def write_media(self, tid: int, size: int = 2048, base: str | None = None) -> str:
path = os.path.join(base or self.media, f"{tid}.ts")
with open(path, "wb") as fh:
fh.write(b"\x47" * size)
return path
def keeps_state(self, tid: int) -> str:
row = self.store.q1("SELECT state FROM keeps WHERE tid=?", (tid,))
return row["state"] if row else ""
class TestKeepCrud(KeepCase):
"""1) 增 / 删 / 查 / 分页 / 统计(曲库暂缺的编号也允许先加入)。"""
def test_add_allows_unknown_tid_and_reports_match(self):
r = self.store.keep_add([1, 2, 999, "x"])
# 一律允许加入:999 曲库暂时没有也加进去(matched=false),非法项 "x" 丢弃
self.assertEqual((r["added"], r["matched"], r["unmatched"]), (3, 2, 1))
self.assertEqual(r["added"], r["matched"] + r["unmatched"])
# 重复加入不覆盖状态(已下完的不能被重新拉回待下载)
self.store.keep_mark_done(1, 111, "/m/1.ts")
r = self.store.keep_add([1, 3], user="前台", note="客户点的")
self.assertEqual((r["added"], r["skipped_existing"], r["matched"], r["unmatched"]), (1, 1, 1, 0))
self.assertEqual(self.keeps_state(1), "done")
self.assertEqual(self.store.keep_stats()["total"], 4)
self.assertEqual(self.store.keep_rows(keyword="C歌")["items"][0]["note"], "客户点的")
def test_add_by_filter(self):
r = self.store.keep_add_by_filter(lang="国语")
self.assertEqual((r["added"], r["matched"], r["unmatched"]), (2, 2, 0)) # A歌、D歌
self.assertEqual(sorted(x["tid"] for x in self.store.keep_rows()["items"]), [1, 4])
r2 = self.store.keep_add_by_filter(keyword="B歌", limit=0)
self.assertEqual(r2["added"], 1)
self.assertEqual(self.store.keep_stats()["total"], 3)
self.assertEqual(self.store.keep_add_by_filter(lang="国语")["added"], 0) # 已在表里
def test_remove_clears_keeps_and_downloads(self):
self.store.keep_add([1, 2])
self.store.enqueue([1, 2])
res = self.store.keep_remove([1, 999], delete_files=False)
self.assertEqual(res["removed"], 1)
self.assertIsNone(self.store.q1("SELECT 1 FROM keeps WHERE tid=1"))
self.assertIsNone(self.store.q1("SELECT 1 FROM downloads WHERE tid=1")) # 登记一并清掉
self.assertIsNotNone(self.store.q1("SELECT 1 FROM downloads WHERE tid=2"))
def test_rows_filters_paging_and_counts(self):
self.store.keep_add([1, 2, 3])
self.store.keep_mark_done(3, 5, "")
d = self.store.keep_rows(page=1, page_size=2, order="hot", desc=True)
self.assertEqual(d["total"], 3)
self.assertEqual([x["tid"] for x in d["items"]], [1, 2]) # 热度降序
d2 = self.store.keep_rows(page=2, page_size=2, order="hot", desc=True)
self.assertEqual([x["tid"] for x in d2["items"]], [3])
self.assertEqual(self.store.keep_rows(state="done")["total"], 1)
self.assertEqual(self.store.keep_rows(state="want")["total"], 2)
self.assertEqual(self.store.keep_rows(lang="粤语")["total"], 1)
self.assertEqual(self.store.keep_rows(keyword="s3")["total"], 1) # 歌手命中
self.assertEqual(self.store.keep_rows(keyword="2")["total"], 1) # 编号命中
# counts 是整表统计,不受筛选影响;行内有曲库清单的列 + matched + 状态相关列
self.assertEqual(self.store.keep_rows(state="want")["counts"]["total"], 3)
item = self.store.keep_rows(keyword="A歌")["items"][0]
for k in ("tid", "name", "lang", "album", "singers", "filename", "hot_score", "matched",
"state", "bytes", "attempts", "last_error", "user", "note",
"added_at", "updated_at", "finished_at", "download_state"):
self.assertIn(k, item)
self.assertIs(item["matched"], True)
def test_stats_and_bytes(self):
self.store.keep_add([1, 2, 3, 4])
self.store.keep_mark_done(1, 2048, "/m/1.ts")
self.store.keep_mark_failed(2, "取址失败")
self.store.keep_mark_downloading(3)
st = self.store.keep_stats()
self.assertEqual(st["total"], 4)
self.assertEqual((st["done"], st["failed"], st["downloading"], st["want"]), (1, 1, 1, 1))
self.assertEqual((st["matched"], st["unmatched"]), (4, 0))
self.assertEqual(st["bytes"], 2048)
self.assertEqual(st["remaining"], 3)
class TestRemoveDeletesFiles(KeepCase):
"""2) 删除歌曲表条目 = 删除媒体文件(且绝不越界删)。"""
def test_remove_deletes_file_and_reports_freed_bytes(self):
size = 5000
self.write_media(1, size)
self.store.keep_add([1])
self.store.keep_mark_done(1, size, os.path.join(self.media, "1.ts"))
res = self.store.keep_remove([1], delete_files=True, media_dir=self.media)
self.assertEqual(res["removed"], 1)
self.assertEqual(res["deleted_files"], 1)
self.assertEqual(res["freed_bytes"], size)
self.assertEqual(res["errors"], [])
self.assertFalse(os.path.exists(os.path.join(self.media, "1.ts")))
# 日志里要说明删了哪个文件、多大
self.assertTrue(any("删除媒体文件" in e["text"] and f"{size} 字节" in e["text"]
for e in self.store.events(20)))
def test_never_deletes_file_outside_media_dir(self):
outside = os.path.join(self.tmp, "outside")
os.makedirs(outside, exist_ok=True)
self.write_media(1, 1000) # media/1.ts
self.write_media(1, 1000, base=outside) # outside/1.ts(同名的目录外文件)
self.store.keep_add([1])
res = self.store.keep_remove([1], delete_files=True, media_dir=self.media)
self.assertEqual(res["deleted_files"], 1)
self.assertFalse(os.path.exists(os.path.join(self.media, "1.ts")))
self.assertTrue(os.path.isfile(os.path.join(outside, "1.ts")), "目录外的同名文件绝不能被删")
def test_refuses_symlink_escape(self):
outside = os.path.join(self.tmp, "outside")
os.makedirs(outside, exist_ok=True)
target = self.write_media(2, 777, base=outside)
os.symlink(target, os.path.join(self.media, "2.ts")) # 软链接指向目录外
self.store.keep_add([2])
res = self.store.keep_remove([2], delete_files=True, media_dir=self.media)
self.assertEqual(res["deleted_files"], 0)
self.assertTrue(any("不在媒体目录内" in e for e in res["errors"]))
self.assertTrue(os.path.isfile(target), "软链接目标不能被删")
def test_delete_files_false_keeps_file(self):
self.write_media(1, 100)
self.store.keep_add([1])
res = self.store.keep_remove([1], delete_files=False, media_dir=self.media)
self.assertEqual((res["removed"], res["deleted_files"], res["freed_bytes"]), (1, 0, 0))
self.assertTrue(os.path.isfile(os.path.join(self.media, "1.ts")))
def test_missing_file_is_not_an_error(self):
self.store.keep_add([1])
res = self.store.keep_remove([1], delete_files=True, media_dir=self.media)
self.assertEqual((res["removed"], res["deleted_files"], res["errors"]), (1, 0, []))
def test_safe_delete_rejects_bad_input(self):
self.write_media(1, 10)
self.assertEqual(Store._safe_delete_media(self.media, "../1")[2], "编号非法")
self.assertEqual(Store._safe_delete_media(self.media, 1)[0], True)
self.assertEqual(Store._safe_delete_media("", 1)[2], "媒体目录未配置")
self.assertEqual(Store._safe_delete_media(self.media, 404)[2], "文件不存在")
class TestKeepCsv(KeepCase):
"""3) 导入 merge / replace 与导出往返(列与曲库清单导出一致)。"""
def _seed(self):
self.store.keep_add([1, 2, 3])
self.store.keep_mark_done(2, 2048, os.path.join(self.media, "2.ts"))
def test_export_columns_match_library_export(self):
self._seed()
head = self.store.keep_csv().splitlines()[0]
self.assertEqual(head, self.store.library_csv().splitlines()[0])
self.assertEqual(head, "编号,歌名,语言,专辑,歌手,文件名,热度,下载状态,字节")
self.assertIn("已下", self.store.keep_csv()) # 状态用中文可读值
self.assertIn("待下载", self.store.keep_csv())
def test_merge_roundtrip_preserves_states(self):
self._seed()
self.store.keep_mark_failed(3, "网络抖动")
text = self.store.keep_csv()
before = {x["tid"]: x["state"] for x in self.store.keep_rows(page_size=100)["items"]}
self.store.keep_import_csv("编号\n4\n", mode="replace") # 先整表换成只有 4
self.assertEqual(self.store.keep_stats()["total"], 1)
res = self.store.keep_import_csv(text, mode="merge") # 再把导出的内容合并回来
self.assertEqual(res["errors"], [])
after = {x["tid"]: x["state"] for x in self.store.keep_rows(page_size=100)["items"]}
for tid, state in before.items():
self.assertEqual(after[tid], state)
def test_replace_mode_replaces_whole_table(self):
size = 333
self.write_media(1, size)
self.write_media(2, size)
self._seed()
self.store.enqueue([1, 2])
res = self.store.keep_import_csv("编号,下载状态\n3,待下载\n", mode="replace")
self.assertEqual((res["mode"], res["removed"], res["updated"]), ("replace", 3, 1))
self.assertEqual([x["tid"] for x in self.store.keep_rows()["items"]], [3])
self.assertIsNone(self.store.q1("SELECT 1 FROM downloads WHERE tid=1")) # 登记清掉
# 媒体文件**不在这里删**(删本地文件只发生在「显式移除」,避免误导入清空媒体目录)
self.assertTrue(os.path.isfile(os.path.join(self.media, "1.ts")))
def test_import_aliases_and_bad_rows(self):
text = ("编号,歌名,下载状态\n"
"1,A歌,待下载\n"
"2,B歌,已就绪\n"
"x,X歌,待下载\n"
"3,C歌,啥玩意\n"
"404,不存在,待下载\n")
res = self.store.keep_import_csv(text, mode="merge")
# 404 曲库里没有也照样写入(matched=false);坏编号/坏状态才跳过
self.assertEqual((res["updated"], res["skipped"], res["matched"], res["unmatched"]),
(3, 2, 2, 1))
self.assertEqual(res["updated"], res["matched"] + res["unmatched"])
self.assertEqual(len(res["errors"]), 2)
self.assertTrue(any("编号无效" in e for e in res["errors"]))
self.assertTrue(any("状态无法识别" in e for e in res["errors"]))
self.assertEqual(self.keeps_state(1), "want")
self.assertEqual(self.keeps_state(2), "done")
self.assertEqual(self.keeps_state(404), "want")
un = self.store.keep_rows(state="unmatched")["items"] # 特殊筛选:曲库匹配不到
self.assertEqual([x["tid"] for x in un], [404])
self.assertIs(un[0]["matched"], False)
def test_import_only_tid_column_and_empty(self):
res = self.store.keep_import_csv("编号\n1\n4\n")
self.assertEqual((res["updated"], res["matched"], res["unmatched"]), (2, 2, 0))
self.assertEqual(self.keeps_state(1), "want")
res2 = self.store.keep_import_csv("")
self.assertEqual(res2["updated"], 0)
self.assertTrue(res2["errors"])
self.assertEqual(self.store.keep_import_csv("歌名,歌手\nA,s\n")["errors"],
["缺少「编号」列(首行应为表头,或第 1 列为编号)"])
def test_import_many_rows_is_chunked(self):
"""大 CSV:曲库存在性校验/删除登记都要分批(SQLite 的 SQL 变量数有上限,且省内存)。"""
n = 1200
self.store.import_library(rows(*[(1000 + i, f"S{i}", "国语", "s", i) for i in range(n)]), "v2")
max_ph = 0
orig_q = self.store.q
def spy(sql, args=()):
nonlocal max_ph
if " IN (" in sql:
max_ph = max(max_ph, len(tuple(args)))
return orig_q(sql, args)
self.store.q = spy # 盯住所有 IN (...) 查询的参数个数
text = "编号,下载状态\n" + "".join(f"{1000 + i},待下载\n" for i in range(n))
res = self.store.keep_import_csv(text, mode="merge")
self.assertEqual((res["updated"], res["unmatched"]), (n, 0))
self.assertEqual(self.store.keep_stats()["total"], n)
res = self.store.keep_import_csv("编号\n1000\n", mode="replace")
self.assertEqual((res["removed"], res["updated"]), (n, 1))
self.assertLessEqual(max_ph, 400, f"单条 SQL 参数过多({max_ph}),没有分批")
class TestKeepSelfFill(KeepCase):
"""3b) 自行填充:只给「编号」也能用,列表字段实时 LEFT JOIN 曲库补全(端到端,不联网)。"""
def test_import_only_tid_csv_then_filled_from_library(self):
"""端到端:内存 CSV 只含「编号」列 → 导入 → 查询/导出立刻被曲库补全。"""
res = self.store.keep_import_csv("编号\n1\n3\n777\n", mode="merge")
self.assertEqual((res["updated"], res["matched"], res["unmatched"]), (3, 2, 1))
rows = {x["tid"]: x for x in self.store.keep_rows(page_size=100)["items"]}
# 1 / 3:歌名、歌手、语言、文件名、热度全部由曲库补全
self.assertIs(rows[1]["matched"], True)
self.assertEqual((rows[1]["name"], rows[1]["singers"], rows[1]["lang"],
rows[1]["filename"], rows[1]["hot_score"]),
("A歌", "s1", "国语", "1.ts", 10))
self.assertIs(rows[3]["matched"], True)
self.assertEqual(rows[3]["name"], "C歌")
# 777:曲库暂时没有也允许加入,matched=false、列表字段为空
self.assertIs(rows[777]["matched"], False)
self.assertEqual((rows[777]["name"], rows[777]["singers"], rows[777]["hot_score"]),
("", "", 0))
# 导出同样带上补全后的信息,且未匹配的行不会丢
text = self.store.keep_csv()
self.assertIn("A歌", text)
self.assertIn("777", text)
def test_unmatched_becomes_matched_after_library_sync(self):
"""先加曲库没有的编号(matched=false)→ 曲库同步进来 → 自动 matched=true 且字段补全。"""
self.assertEqual(self.store.keep_add([777])["unmatched"], 1)
row = self.store.keep_rows(page_size=10)["items"][0]
self.assertIs(row["matched"], False)
self.assertEqual((row["name"], row["hot_score"]), ("", 0))
self.assertEqual(self.store.keep_stats()["unmatched"], 1)
self.assertEqual([x["tid"] for x in self.store.keep_unmatched()], [777])
self.assertEqual([x["tid"] for x in self.store.keep_rows(state="unmatched")["items"]], [777])
self.assertEqual(self.store.keep_remaining(), 0) # 匹配不到 → 不参与下载
self.store.import_library(rows((777, "新歌", "国语", "新歌手", 88)), "v2") # 模拟曲库同步
row = self.store.keep_rows(page_size=10)["items"][0]
self.assertIs(row["matched"], True) # JOIN 自动生效,无需额外动作
self.assertEqual((row["name"], row["singers"], row["lang"], row["hot_score"]),
("新歌", "新歌手", "国语", 88))
self.assertEqual(self.store.keep_stats()["unmatched"], 0)
self.assertEqual(self.store.keep_unmatched(), [])
self.assertEqual(self.store.keep_rows(state="matched")["total"], 1)
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [777]) # 现在能下
def test_library_metadata_update_is_live(self):
"""用户表里不存冗余列:曲库改了歌名,歌曲表显示立刻跟着变。"""
self.store.keep_add([1])
self.assertEqual(self.store.keep_rows()["items"][0]["name"], "A歌")
self.store.import_library(rows((1, "A歌改名", "国语", "s1", 10)), "v3")
self.assertEqual(self.store.keep_rows()["items"][0]["name"], "A歌改名")
def test_import_empty_other_columns_is_fine(self):
"""CSV 带了歌名/歌手/语言等列但留空:一律以曲库为准,不会被空值覆盖。"""
res = self.store.keep_import_csv("编号,歌名,歌手,语言\n1,,,\n")
self.assertEqual((res["updated"], res["matched"]), (1, 1))
row = self.store.keep_rows(page_size=10)["items"][0]
self.assertEqual((row["name"], row["singers"], row["lang"]), ("A歌", "s1", "国语"))
class TestKeepNextToEnqueue(KeepCase):
"""4) 补队候选只来自歌曲表里未完成、且能从曲库匹配到的歌。"""
def test_only_keeps_and_unfinished(self):
self.store.keep_add([1, 3])
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [1, 3]) # 热度降序
# 曲库里有、但不在歌曲表里的 2/4 永远不出现
self.assertNotIn(2, [c["tid"] for c in self.store.keep_next_batch(10)])
def test_order_limit_and_state_filter(self):
self.store.keep_add([1, 2, 3, 4])
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(2, "hot", True)], [1, 2])
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(2, "tid", False)], [1, 2])
self.store.keep_mark_done(1, 1, "")
self.store.keep_mark_downloading(2)
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [3, 4])
self.store.keep_mark_failed(3, "失败也仍会被重试")
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [3, 4])
def test_removed_upstream_not_offered(self):
self.store.keep_add([1, 2])
self.store.diff_and_import(rows((2, "B歌", "粤语", "s2", 9)), "v2") # 1 上游已删除
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [2])
def test_unmatched_not_offered_nor_counted(self):
"""曲库暂无的编号:允许在歌曲表里等着,但不参与补队、也不计入"还没下完"。"""
self.store.keep_add([1, 777])
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [1])
self.assertEqual(self.store.keep_remaining(), 1) # 只算能匹配到的
self.assertEqual(self.store.keep_stats()["unmatched"], 1)
def test_reset_stuck_requeues_downloading_keeps(self):
"""进程重启:卡在 downloading 的条目必须回到待下载,否则永远补不到队。"""
self.store.keep_add([1, 2])
self.store.keep_mark_downloading(1)
self.store.enqueue([1])
self.store.claim_one(1)
self.store.reset_stuck()
self.assertEqual(self.keeps_state(1), "want")
self.assertEqual(self.store.q1("SELECT state FROM downloads WHERE tid=1")["state"], "pending")
self.assertEqual([c["tid"] for c in self.store.keep_next_batch(10)], [1, 2])
class TestKeepAdoptMedia(KeepCase):
"""5) 媒体目录扫描(`keep_adopt_media`):认领新文件 / 更新状态 / **缺失回队**(绝不删文件)。"""
def test_missing_requeues_and_resets_downloads(self):
"""done 但文件不存在 → 回到 want,并把 downloads 的完成登记放回 pending。"""
self.store.keep_add([1])
self.store.enqueue([1])
self.store.claim_one(1)
self.store.finish(1, True, bytes_=5, path=os.path.join(self.media, "1.ts"))
self.store.keep_mark_done(1, 5, os.path.join(self.media, "1.ts"))
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertTrue(res["ok"], res)
self.assertEqual(res["requeued"], 1)
self.assertEqual(self.keeps_state(1), "want")
self.assertEqual(self.store.q1("SELECT state FROM downloads WHERE tid=1")["state"], "pending")
def test_small_file_requeues(self):
"""文件在、但小于最小字节数 → 同样回队(不能当成"已下好")。"""
self.write_media(1, 10)
self.store.keep_add([1])
self.store.keep_mark_done(1, 10, os.path.join(self.media, "1.ts"))
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(res["requeued"], 1)
self.assertEqual(self.keeps_state(1), "want")
def test_done_with_file_present_stays_done(self):
self.write_media(1, 4096)
self.store.keep_add([1])
self.store.keep_mark_done(1, 4096, os.path.join(self.media, "1.ts"))
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(res["requeued"], 0)
self.assertEqual(self.keeps_state(1), "done")
def test_adopts_existing_file(self):
"""在表里但不是 done、文件够大 → 认领为 done(避免重复下载)。"""
self.write_media(1, 4096)
self.store.keep_add([1])
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(res["updated"], 1)
item = self.store.keep_rows(keyword="A歌")["items"][0]
self.assertEqual((item["state"], item["bytes"]), ("done", 4096))
def test_unknown_file_is_adopted_never_deleted(self):
"""媒体目录里不在歌曲表的文件:**登记进歌曲表**(用户放进来的文件要留住)。"""
size = 4096
self.write_media(9, size)
self.store.keep_add([1])
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual((res["adopted_count"], res["updated"]), (1, 0))
self.assertTrue(os.path.isfile(os.path.join(self.media, "9.ts"))) # 文件还在
self.assertEqual(self.keeps_state(9), "done")
self.assertEqual(self.store.q1("SELECT bytes FROM keeps WHERE tid=9")["bytes"], size)
self.assertEqual(self.store.keep_rows(state="unmatched")["total"], 1) # 曲库没有 9
def test_small_unknown_file_skipped_and_kept(self):
"""过小的未知文件:跳过(不登记),但**绝不删除**。"""
self.write_media(9, 10)
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual((res["adopted_count"], res["skipped"]), (0, 1))
self.assertTrue(os.path.isfile(os.path.join(self.media, "9.ts")))
self.assertIsNone(self.store.q1("SELECT 1 FROM keeps WHERE tid=9"))
def test_media_dir_unavailable_is_skipped(self):
self.store.keep_add([1])
self.store.keep_mark_done(1, 5, "")
res = self.store.keep_adopt_media(os.path.join(self.tmp, "nope"), min_bytes=100, timeout_sec=5)
self.assertFalse(res["ok"])
self.assertEqual(res["requeued"], 0)
self.assertEqual(self.keeps_state(1), "done") # 挂载丢失时绝不误判全部缺失
def test_requeue_only_touches_done_rows(self):
self.store.keep_add([2, 9999999]) # 未下载 + 曲库外
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(res["requeued"], 0)
self.assertEqual([self.keeps_state(t) for t in (2, 9999999)], ["want", "want"])
class TestRetiredTablesCleanup(KeepCase):
"""6) 老库升级:`Store._RETIRED_TABLES` 里列出的历史表会被幂等清除。"""
def test_retired_tables_dropped_on_open(self):
for old in Store._RETIRED_TABLES: # 造出历史遗留表
self.store.ex(f"CREATE TABLE IF NOT EXISTS {old}(tid INTEGER PRIMARY KEY, v TEXT)")
self.assertEqual(len(Store._RETIRED_TABLES), 1)
self.store.conn.close()
self.store = Store(os.path.join(self.tmp, "t.db")) # 重新打开 = 走一次 _migrate
names = {r[0] for r in self.store.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
for old in Store._RETIRED_TABLES:
self.assertNotIn(old, names)
self.assertIn("keeps", names) # 正常表不受影响
class TestBatchKeeps(KeepCase):
"""7a) 批量驱动器:只补歌曲表里的歌;全部下完 → 正常收工。"""
def setUp(self):
super().setUp()
self.batch = BatchManager(self.cfg, self.store, None, None)
self.batch.state["enabled"] = True # 模拟"已在运行"
def test_tick_enqueues_only_keeps(self):
self.store.keep_add([2])
self.assertTrue(self.batch.tick())
queued = [r["tid"] for r in self.store.q("SELECT tid FROM downloads ORDER BY tid")]
self.assertEqual(queued, [2]) # 不在歌曲表里的 1/3/4 不入队
def test_status_exposes_keeps_counters(self):
self.store.keep_add([1, 2])
self.store.keep_mark_done(1, 100, "/m/1.ts")
st = self.batch.status()
self.assertEqual((st["keeps_total"], st["keeps_done"], st["keeps_want"], st["keeps_failed"]),
(2, 1, 1, 0))
self.assertEqual(st["keeps_remaining"], 1)
self.assertEqual(st["keeps_bytes"], 100)
def test_finishes_when_all_keeps_done(self):
self.store.keep_add([1])
self.assertTrue(self.batch.tick()) # 补队
self.assertEqual(self.store.stats()["pending"], 1)
self.store.claim_one(1)
self.store.finish(1, True, bytes_=1)
self.store.keep_mark_done(1, 1, os.path.join(self.media, "1.ts"))
self.assertFalse(self.batch.tick()) # 队列空 + 歌曲表无未完成 → 收工
st = self.batch.status()
self.assertFalse(st["enabled"])
self.assertFalse(st["fatal"]) # **不是致命错误**
self.assertIn("已按歌曲表下载完毕", st["reason"])
self.assertTrue(any(e["level"] == "INFO" and "BATCH 已按歌曲表下载完毕" in e["text"]
for e in self.store.events(20)))
def test_untouched_library_is_not_fatal(self):
self.batch.state["last_progress_at"] = 0 # 极端:从没有过进展
self.assertFalse(self.batch.tick())
self.assertFalse(self.batch.status()["fatal"])
def test_keeps_remaining_still_running(self):
self.store.keep_add([1])
self.assertTrue(self.batch.tick())
self.assertTrue(self.batch.status()["enabled"])
class TestDownloaderKeeps(KeepCase):
"""7b) 下载器:成功/失败/抢占都同步歌曲表状态(且能容忍没有该方法的假 store)。"""
def test_early_done_path_sets_keeps_done(self):
self.cfg.update({"min_file_bytes": 1024})
path = self.write_media(1, 2048)
self.store.keep_add([1])
self.store.enqueue([1])
self.store.claim_one(1)
self.store.finish(1, True, bytes_=2048, path=path)
dl = Downloader(self.cfg, self.store)
dl._download({"tid": 1, "name": "A歌"}) # 走"已就绪"分支,不联网
item = self.store.keep_rows(keyword="A歌")["items"][0]
self.assertEqual((item["state"], item["bytes"]), ("done", 2048))
self.assertEqual(self.store.keep_remaining(), 0)
def test_failure_and_downloading_states(self):
self.store.keep_add([1, 2])
dl = Downloader(self.cfg, self.store)
dl._note_downloading(2)
self.assertEqual(self.keeps_state(2), "downloading")
self.assertEqual(self.store.q1("SELECT attempts FROM keeps WHERE tid=2")["attempts"], 1)
dl._fail(1, "文件过小 3B(阈值 6MB)")
self.assertEqual(self.keeps_state(1), "failed")
self.assertIn("文件过小", self.store.keep_rows(keyword="A歌")["items"][0]["last_error"])
def test_tolerates_store_without_keep_methods(self):
dl = Downloader(self.cfg, self.store)
class Bare:
def event(self, *a, **k):
pass
dl.store = Bare()
dl._note_done(1, 1, "p") # 不抛异常即通过
dl._note_failed(1, "e")
dl._note_downloading(1)
class TestAdminKeepsEndpoints(KeepCase):
"""7c) 管理接口 /api/keeps*(以及曲库清单别名)全链路。"""
def setUp(self):
super().setUp()
self.router = admin.build_router(self.cfg, self.store, None)
def call(self, method, path, query=None, body=b""):
return self.router.dispatch(req(method, path, query, body))
def test_add_list_stats(self):
d = json.loads(self.call("POST", "/api/keeps/add", body=jbody({"tids": [1, 2]})).body)
self.assertTrue(d["ok"])
self.assertEqual((d["added"], d["stats"]["total"]), (2, 2))
self.assertEqual((d["matched"], d["unmatched"]), (2, 0))
d = json.loads(self.call("GET", "/api/keeps", {"state": "want", "page_size": "10"}).body)
self.assertEqual(d["total"], 2)
self.assertIn("counts", d)
d = json.loads(self.call("POST", "/api/keeps/add", body=jbody({"lang": "英语", "limit": 5})).body)
self.assertEqual(d["added"], 1)
d = json.loads(self.call("GET", "/api/keeps/stats").body)
self.assertEqual((d["ok"], d["total"], d["want"]), (True, 3, 3))
def test_add_unknown_tid_is_allowed_and_unmatched_endpoint(self):
"""只给编号(曲库暂时没有)也能加;/api/keeps/unmatched 能列出来核对。"""
d = json.loads(self.call("POST", "/api/keeps/add", body=jbody({"tids": [1, 777]})).body)
self.assertTrue(d["ok"])
self.assertEqual((d["added"], d["matched"], d["unmatched"]), (2, 1, 1))
d = json.loads(self.call("GET", "/api/keeps", {"state": "unmatched"}).body)
self.assertEqual([x["tid"] for x in d["items"]], [777])
self.assertIs(d["items"][0]["matched"], False)
d = json.loads(self.call("GET", "/api/keeps/unmatched").body)
self.assertEqual((d["ok"], d["total"], [x["tid"] for x in d["items"]]), (True, 1, [777]))
# 曲库同步进来后,未匹配列表自动清空,行内字段也补全
self.store.import_library(rows((777, "新歌", "国语", "新歌手", 88)), "v2")
d = json.loads(self.call("GET", "/api/keeps/unmatched").body)
self.assertEqual(d["total"], 0)
d = json.loads(self.call("GET", "/api/keeps", {"state": "matched", "keyword": "新歌"}).body)
self.assertEqual([x["tid"] for x in d["items"]], [777])
self.assertEqual(d["items"][0]["name"], "新歌")
def test_add_without_target_is_rejected(self):
d = json.loads(self.call("POST", "/api/keeps/add", body=jbody({})).body)
self.assertFalse(d["ok"])
self.assertTrue(d["error"])
d = json.loads(self.call("POST", "/api/keeps/add", body=jbody({"limit": -1})).body)
self.assertFalse(d["ok"])
def test_remove_endpoint_returns_impact(self):
size = 4096
self.write_media(1, size)
self.call("POST", "/api/keeps/add", body=jbody({"tids": [1]}))
self.store.keep_mark_done(1, size, os.path.join(self.media, "1.ts"))
d = json.loads(self.call("POST", "/api/keeps/remove", body=jbody({"tids": [1]})).body)
self.assertTrue(d["ok"])
self.assertEqual((d["removed"], d["deleted_files"], d["freed_bytes"]), (1, 1, size))
self.assertFalse(os.path.exists(os.path.join(self.media, "1.ts")))
d2 = json.loads(self.call("POST", "/api/keeps/remove", body=jbody({})).body)
self.assertFalse(d2["ok"])
def test_export_csv_has_bom_and_same_columns(self):
self.call("POST", "/api/keeps/add", body=jbody({"tids": [1]}))
resp = self.call("GET", "/api/keeps/export.csv")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.content_type, "text/csv; charset=utf-8")
self.assertEqual(resp.headers["Content-Disposition"], 'attachment; filename="keeps.csv"')
self.assertTrue(resp.body.startswith(b"\xef\xbb\xbf")) # utf-8-sig BOM
head = resp.body.decode("utf-8-sig").splitlines()[0]
self.assertEqual(head, self.store.library_csv().splitlines()[0])
def test_import_endpoint_modes(self):
raw = "编号,下载状态\n1,待下载\n2,已下\n"
d = json.loads(self.call("POST", "/api/keeps/import", {"mode": "merge"}, raw.encode()).body)
self.assertTrue(d["ok"])
self.assertEqual((d["mode"], d["updated"]), ("merge", 2))
d = json.loads(self.call("POST", "/api/keeps/import",
body=jbody({"text": "编号\n3\n", "mode": "replace"})).body)
self.assertEqual((d["mode"], d["removed"], d["updated"]), ("replace", 2, 1))
d = json.loads(self.call("POST", "/api/keeps/import", {"mode": "oops"}, raw.encode()).body)
self.assertFalse(d["ok"])
d = json.loads(self.call("POST", "/api/keeps/import", {"mode": "merge"}, b" ").body)
self.assertFalse(d["ok"])
def test_scan_endpoint_registers_and_requeues(self):
"""界面上的「扫描媒体目录」:未知文件登记进歌曲表 + done 但文件缺失的条目回队。"""
self.cfg.update({"media_scan_min_bytes": 100})
self.write_media(9, 2048)
self.call("POST", "/api/keeps/add", body=jbody({"tids": [1]}))
self.store.keep_mark_done(1, 0, "") # done 但文件不存在
d = json.loads(self.call("POST", "/api/keeps/scan").body)
self.assertTrue(d["ok"], d)
self.assertEqual(d["adopted_count"], 1) # 9 被登记
self.assertEqual(d["requeued"], 1) # 1 被放回待下载
self.assertTrue(os.path.exists(os.path.join(self.media, "9.ts"))) # 绝不删文件
self.assertEqual(self.keeps_state(1), "want")
self.assertEqual(self.keeps_state(9), "done")
# 最近一次扫描结果(界面「上次媒体扫描」一行)也带上"缺失回队"
st = json.loads(self.call("GET", "/api/keeps/scan/status").body)["scan"]
self.assertEqual((st["requeued"], st["adopted"]), (1, 1))
def test_removed_reconcile_endpoints_are_gone(self):
"""两个「对账」接口已删除(旧脚本拿到 404,而不是被静默当成别的接口)。"""
for path in ("/api/keeps/reconcile", "/api/maintenance/reconcile"):
resp = self.call("POST", path, body=jbody({}))
self.assertEqual(resp.status, 404, path)
def test_library_endpoints_and_enqueue_explicit_tids(self):
d = json.loads(self.call("GET", "/api/library/songs", {"page_size": "10"}).body)
self.assertEqual(d["total"], 4)
resp = self.call("GET", "/api/library/export.csv")
self.assertTrue(resp.body.startswith(b"\xef\xbb\xbf"))
self.assertEqual(resp.body.decode("utf-8-sig").splitlines()[0],
self.store.keep_csv().splitlines()[0])
# 自动入队(mode=auto)已删除:不传 tids 一律拒绝
d = json.loads(self.call("POST", "/api/download/enqueue", body=jbody({"mode": "auto"})).body)
self.assertFalse(d["ok"])
# 显式传编号的入队路径保留(下载队列页的"下载选中/本页"还在用)
self.call("POST", "/api/keeps/add", body=jbody({"tids": [2]}))
d = json.loads(self.call("POST", "/api/download/enqueue", body=jbody({"tids": [2]})).body)
self.assertTrue(d["ok"], d)
self.assertEqual(d["added"], 1)
class TestConfigKeeps(KeepCase):
"""配置项的取舍:旧「对账」相关键已随按钮一起删除,媒体扫描键保留。"""
def test_removed_reconcile_keys_are_gone(self):
for key in ("keep_prune_orphans", "keep_prune_orphans_on_start",
"keep_delete_missing_files", "keep_reconcile_interval_sec"):
self.assertIsNone(self.cfg.get(key))
def test_media_scan_defaults(self):
self.assertTrue(self.cfg.get("media_scan_enabled"))
self.assertEqual(self.cfg.get("media_scan_interval_sec"), 600)
self.assertEqual(self.cfg.get("media_scan_timeout_sec"), 30)
class TestKeepAdoptMediaNeverDeletes(KeepCase):
"""8) ★回归:媒体扫描(认领新文件 + 缺失回队)**永不删除 keeps 行**、也永不删除文件。
背景(线上实测):通过 /api/keeps/add 或 /api/keeps/import 加入 3 条(其中 1 条曲库里没有),
列表正常显示 3 条;**重启服务或跑一次扫描之后变成 0 条** 是绝不能出现的事。
这里把语义钉死:曲库匹配不到、文件还没下、文件缺失 —— 一律只改状态,绝不删行。
"""
def test_unmatched_tid_survives_scan(self):
"""曲库里没有的编号:扫描后仍在,且 matched=false(绝不允许因此删行)。"""
self.store.keep_add([9999999])
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(res["requeued"], 0)
self.assertEqual(self.store.keep_stats()["total"], 1)
row = self.store.keep_rows()["items"][0]
self.assertEqual(row["tid"], 9999999)
self.assertIs(row["matched"], False)
self.assertEqual(self.store.keep_rows(state="unmatched")["total"], 1)
def test_not_downloaded_yet_survives_scan(self):
"""曲库有、但文件还没下(want):扫描后仍在且仍是 want。"""
self.store.keep_add([1, 2, 9999999])
self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(self.store.keep_stats()["total"], 3)
self.assertEqual([self.keeps_state(t) for t in (1, 2)], ["want", "want"])
def test_done_but_file_missing_goes_back_to_want_not_deleted(self):
"""登记 done 但文件不存在:回到 want(回队重下),**不是删掉**。"""
self.store.keep_add([1])
self.store.keep_mark_done(1, 12345, os.path.join(self.media, "1.ts"))
res = self.store.keep_adopt_media(self.media, min_bytes=100, timeout_sec=5)
self.assertEqual(res["requeued"], 1)
self.assertEqual(self.keeps_state(1), "want")
self.assertEqual(self.store.keep_stats()["total"], 1)
def test_every_scan_mode_keeps_rows(self):
"""目录里有过小文件 / 未知文件 / 目录不可用 —— 每种情形都不许**删掉** keeps 行。"""
self.write_media(9, 10) # 过小:跳过且不删
self.store.keep_add([1, 2, 9999999])
self.store.keep_mark_done(1, 1, "") # done 但文件缺失 → 只回队
for media, kw in ((self.media, {"min_bytes": 100}), (self.media, {"min_bytes": 0}),
(os.path.join(self.tmp, "nope"), {"min_bytes": 100})):
self.store.keep_adopt_media(media, timeout_sec=5, **kw)
tids = [r["tid"] for r in self.store.keep_rows(page_size=50)["items"]]
for tid in (1, 2, 9999999): # 原有 3 行一行都不能丢
self.assertIn(tid, tids)
self.assertLessEqual(self.store.keep_stats()["total"], 4) # 只有 9 可能被认领进来
self.assertTrue(os.path.isfile(os.path.join(self.media, "9.ts"))) # 目录里的小文件也不删
def test_media_dir_unavailable_scan_keeps_rows(self):
"""媒体目录不可用(FUSE 掉线/很慢):跳过 + 一行不删 + 返回结构键齐全。"""
self.store.keep_add([4100081, 4100293, 9999999])
res = self.store.keep_adopt_media(os.path.join(self.tmp, "nope"), min_bytes=100, timeout_sec=5)
self.assertFalse(res["ok"])
self.assertEqual(res["requeued"], 0)
self.assertEqual(self.store.keep_stats()["total"], 3)
def test_reopen_and_scan_keeps_rows_and_content(self):
"""模拟"重启服务":重开同一个库 + 跑维护任务同一套扫描 → 行数与内容完全不变。"""
self.store.keep_add([4100081, 4100293, 9999999], user="前台", note="客户点的")
before_stats = self.store.keep_stats()
before_rows = [(r["tid"], r["state"], r["note"], r["matched"])
for r in self.store.keep_rows(page_size=100)["items"]]
self.store.conn.close()
self.store = Store(os.path.join(self.tmp, "t.db")) # 重开 = 重启
self.store.reset_stuck() # main.py 启动流程
self.cfg.update({"media_scan_min_bytes": 100})
admin.run_media_scan(self.cfg, self.store) # 维护任务的扫描步骤
self.assertEqual(self.store.keep_stats(), before_stats)
self.assertEqual([(r["tid"], r["state"], r["note"], r["matched"])
for r in self.store.keep_rows(page_size=100)["items"]], before_rows)
def test_scan_adopts_unknown_file_and_requeues_missing(self):
"""扫描的核心作用(旧"对账"的行为)一个都不能丢:未知文件登记、缺失回队,且不删文件。"""
self.cfg.update({"media_scan_min_bytes": 100})
self.write_media(9, 4096) # 未知文件
self.store.keep_add([1, 2])
self.store.keep_mark_done(1, 4096, os.path.join(self.media, "1.ts")) # done 但文件不存在
res = admin.run_media_scan(self.cfg, self.store)
self.assertTrue(res["ok"], res)
self.assertEqual(res["adopted_count"], 1) # 9 被登记
self.assertEqual(res["requeued"], 1) # 1 被回队
self.assertTrue(os.path.isfile(os.path.join(self.media, "9.ts"))) # 绝不删除
self.assertEqual(self.keeps_state(9), "done")
self.assertEqual(self.keeps_state(1), "want")
class TestKeepImportReplaceSafety(KeepCase):
"""9) ★回归:整表替换必须**原子** + 空 CSV **拒执行**("导入一次就整表没了"的两个入口)。"""
def test_replace_with_header_only_is_refused(self):
self.store.keep_add([1, 2, 3])
res = self.store.keep_import_csv("编号\n", mode="replace")
self.assertTrue(res["replace_cancelled"])
self.assertEqual((res["updated"], res["removed"]), (0, 0))
self.assertEqual(self.store.keep_stats()["total"], 3) # 一行都没丢
self.assertEqual(sorted(r["tid"] for r in self.store.keep_rows(page_size=50)["items"]), [1, 2, 3])
def test_replace_with_no_valid_row_is_refused(self):
self.store.keep_add([1, 2, 3])
for bad in ("编号\nabc\ndef\n", "编号,下载状态\n1,状态乱写\n", "编号\n\n\n"):
res = self.store.keep_import_csv(bad, mode="replace")
self.assertTrue(res["replace_cancelled"], bad)
self.assertTrue(res["errors"])
self.assertEqual(self.store.keep_stats()["total"], 3) # 每次都被拒,旧行保留
def test_replace_still_replaces_when_csv_has_rows(self):
self.store.keep_add([1, 2, 3])
res = self.store.keep_import_csv("编号\n2\n4\n", mode="replace")
self.assertEqual((res["updated"], res["removed"], res["replace_cancelled"]), (2, 3, False))
self.assertEqual(sorted(r["tid"] for r in self.store.keep_rows(page_size=50)["items"]), [2, 4])
self.assertEqual(self.store.keep_stats()["total"], 2)
def test_replace_rolls_back_when_write_fails(self):
"""写入阶段抛异常 → 旧行原样保留(旧实现先单独提交 DELETE,会整表清空且无法恢复)。"""
self.store.keep_add([1, 2, 3])
def _boom(conn, payload):
raise RuntimeError("模拟写入失败(磁盘满 / 库被锁 / FUSE 掉线)")
self.store._write_keeps_rows = _boom # 只让写入步骤失败
with self.assertRaises(RuntimeError):
self.store.keep_import_csv("编号\n2\n", mode="replace")
self.assertFalse(self.store.conn.in_transaction) # 事务已整体回滚
self.assertEqual(sorted(r["tid"] for r in self.store.keep_rows(page_size=50)["items"]), [1, 2, 3])
self.assertEqual(self.store.keep_stats()["total"], 3)
def test_merge_never_deletes(self):
self.store.keep_add([1, 2, 3])
res = self.store.keep_import_csv("编号\n4\n", mode="merge")
self.assertEqual((res["updated"], res["removed"]), (1, 0))
self.assertEqual(sorted(r["tid"] for r in self.store.keep_rows(page_size=50)["items"]), [1, 2, 3, 4])
class TestKeepDeleteCallSites(KeepCase):
"""10) 守卫:全项目**只有两处** `DELETE FROM keeps`(源码级检查,防后来人加"清理")。"""
def test_only_two_delete_call_sites(self):
import re
base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ktv")
with open(os.path.join(base, "store.py"), encoding="utf-8") as fh:
src = fh.read()
hits = [m.start() for m in re.finditer(r"DELETE FROM keeps", src)]
self.assertEqual(len(hits), 2, "DELETE FROM keeps 只允许 keep_remove() 与 replace_keeps() 两处")
for pos in hits: # 两处必须落在允许的函数里
func = re.findall(r"\n def (\w+)\(", src[:pos])[-1]
self.assertIn(func, ("keep_remove", "replace_keeps"), f"DELETE FROM keeps 出现在 {func}()")
others = []
for name in sorted(os.listdir(base)):
if not name.endswith(".py"):
continue
with open(os.path.join(base, name), encoding="utf-8") as fh:
if "DELETE FROM keeps" in fh.read():
others.append(name)
self.assertEqual(others, ["store.py"]) # 别的模块一律不许删 keeps
self.assertNotIn("keeps", Store._RETIRED_TABLES) # 升级清理也不许 DROP keeps
if __name__ == "__main__":
unittest.main(verbosity=2)