tests/test_one_definition.py
Rama main · réplica de lectura del repositorio original.
"""一件事只能有一個定義。
這裡的每一條規則都對應一次真的發生過的故障,不是風格意見:
路徑 2026-07-28 資料目錄搬家,兩半管線各自推導位置,卡片全被吃掉。
xkb_paths 是為了這件事寫的,但十五支腳本 import 了它之後,
又用 WORKSPACE / "memory" / ... 自己拼一次,共二十八處。
數量 「有幾張卡片」有兩個答案,1,538 和 1,539:一支用 rglob,
把治理判定重複而移走的卡片也算了進去。
出處 self-derived 的判斷曾經有三個寫法,於是 913 條自產知識繞過了
回音室降權。
斷詞 「這段文字的詞是哪些」有過八份實作。那個正則對中文是壞的:
整串中文被吃成一個 token,於是中文問句幾乎命中不了任何東西,
看起來像知識庫沒內容。xkb_text 修好了它,但四支腳本仍各留一份。
門檻 餘弦門檻曾經有三個數字、兩個預設值共用同一個環境變數
(XKB_CARD_MIN_SIMILARITY 同時被 0.55 和 0.58 兩處讀走)。
舊的防護存在,但白名單只列了兩支腳本,第三支自動繞過。
尺度 score_scale 是散在各處的字串字面量。打錯一個字不會報錯,
會靜默掉到 0.5 的 fallback,整層排序錯位。
編碼 「檔案是什麼編碼」曾經有兩個答案:程式沒說,於是 Linux 上是
UTF-8、Windows 上是 cp950。同一份卡片在 VPS 讀得到、在本機
炸掉,而本機是 Pan 每次改動的第一站。
一個新的定義出現時,這裡會失敗,而不是等它在某個早上說出錯的數字。
"""
from __future__ import annotations
import ast
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))
import xkb_score # noqa: E402
SHARED_PATHS = (
"CARDS_DIR", "BOOKMARKS_DIR", "WIKI_DIR", "WIKI_TOPICS_DIR",
"INDEX_FILE", "VECTOR_FILE", "TOPIC_PROFILE_FILE", "DATA_DIR",
)
ASSIGNMENT = re.compile(r"^(%s)\s*=\s*(.+)$" % "|".join(SHARED_PATHS), re.M)
def _sources() -> list[Path]:
return [p for p in sorted(SCRIPTS.glob("*.py")) if p.name != "xkb_paths.py"]
class OneDefinitionTest(unittest.TestCase):
def test_shared_paths_come_from_xkb_paths(self) -> None:
"""別名可以,重新推導不行。
`CARDS_DIR = xkb_paths.CARDS_DIR` 只是本地名字;
`CARDS_DIR = WORKSPACE / "memory" / "cards"` 是第二個定義,
而且它不會跟著 .xkb.json 或 XKB_DATA_DIR 走。
"""
offenders = []
for path in _sources():
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
match = ASSIGNMENT.match(line)
if not match:
continue
rhs, j = match.group(2), i + 1
while rhs.count("(") > rhs.count(")") and j < len(lines):
rhs += " " + lines[j].strip()
j += 1
if "xkb_paths" not in rhs:
offenders.append(f"{path.name}:{i + 1} {match.group(1)} = {rhs[:60]}")
self.assertEqual(offenders, [], "這些路徑沒有取自 xkb_paths,資料目錄一搬就會指到舊位置")
def test_cards_are_counted_in_one_place(self) -> None:
"""卡片是哪些檔案,只有 xkb_paths.card_files 說了算。
cards/_deduped_redundant/ 裡是治理判定重複而移走的卡片。用 rglob
會把它們算回來,用 glob 不會,於是同一個問題有兩個答案。
"""
offenders = [
f"{path.name}:{i + 1}"
for path in _sources()
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines())
if re.search(r"\bCARDS_DIR\b.*\.r?glob\(", line)
]
self.assertEqual(offenders, [], "改用 xkb_paths.card_files() / card_ids()")
def test_provenance_has_one_reader(self) -> None:
"""判斷「這是不是自己產的」只有 xkb_provenance 一份規則。
健檢曾經自己寫一套,於是把 322 條正確標記的知識報成壞的。
"""
offenders = []
for path in _sources():
text = path.read_text(encoding="utf-8")
if path.name == "xkb_provenance.py":
continue
if re.search(r'["\']self-derived["\']|self-derived\|', text) \
and "xkb_provenance" not in text and "is_self_derived" not in text:
offenders.append(path.name)
self.assertEqual(offenders, [], "self-derived 的判斷要走 xkb_provenance")
def test_tokenising_goes_through_xkb_text(self) -> None:
"""切詞的規則只有 xkb_text 一份。
判準是「有沒有自己拿一條含 CJK 範圍的正則去掃文字」——re.sub 做
slug、re.search 判斷有沒有中文都不算,那些不是在決定「詞是哪些」。
"""
tokenising = re.compile(r"\b(findall|finditer|re\.compile|re\.split)\b")
# 原始碼兩種寫法都算:跳脫的 \u4e00-\u9fff,和直接寫的字元範圍。
cjk_range = re.compile(r"(?:\\u4e00|一)\s*-")
offenders = []
for path in _sources():
if path.name == "xkb_text.py":
continue
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
if line.lstrip().startswith("#"):
continue
if tokenising.search(line) and cjk_range.search(line):
offenders.append(f"{path.name}:{i + 1}")
self.assertEqual(offenders, [], "自己寫斷詞正則;改用 xkb_text.tokenize()")
def test_similarity_thresholds_come_from_xkb_relevance(self) -> None:
"""餘弦門檻只有 xkb_relevance 說了算。
別名可以(`CARD_MIN_SIMILARITY = xkb_relevance.threshold("card_recall")`),
自己算一個不行。這條規則刻意掃全部腳本:上一版的防護寫死了兩支
呼叫端的白名單,於是第三支寫自己的門檻時沒有任何東西發現。
recall_router 的 MIN_SCORE_HARD / SOFT 不在這裡,因為它們不是餘弦:
那是 xkb_score.rank() 之後的合併分數,另一套尺度、另一個量。
"""
assignment = re.compile(r"^([A-Z_]*(?:MIN_SIMILARITY|MIN_RELEVANCE)[A-Z_]*)\s*=\s*(.+)$", re.M)
offenders = []
for path in _sources():
if path.name == "xkb_relevance.py":
continue
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
match = assignment.match(line)
if match and "xkb_relevance" not in match.group(2):
offenders.append(f"{path.name}:{i + 1} {match.group(1)}")
self.assertEqual(
offenders, [],
"這些門檻沒有取自 xkb_relevance;改用 threshold() 並把值寫進 DEFAULT_THRESHOLDS",
)
def test_every_score_scale_is_in_the_anchor_table(self) -> None:
"""宣告出來的尺度,跨層排序時必須認得。
xkb_score.rank() 對不認得的鍵會靜默退回 0.5,那一層的排序整段錯位,
而且不會有任何錯誤訊息——只會是一個看起來排好、實際上翻轉了的順序。
"""
literal = re.compile(r"""["\']score_scale["\']\]?\s*[:=]\s*["\']([a-z_]+)["\']""")
known = set(xkb_score.DEFAULT_ANCHORS)
offenders = []
for path in _sources():
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
for name in literal.findall(line):
if name not in known:
offenders.append(f"{path.name}:{i + 1} score_scale={name!r}")
self.assertEqual(offenders, [], "不在 xkb_score.DEFAULT_ANCHORS 裡的尺度 = 靜默錯位")
def test_file_io_says_which_encoding(self) -> None:
"""讀寫檔案一律講明 utf-8,不要讓作業系統替我們決定。
不指定時 Python 用平台預設:VPS 是 UTF-8,Windows 是 cp950。
知識庫裡幾乎每個檔案都有中文,於是同一支腳本在 VPS 正常、在本機
丟 UnicodeDecodeError——而本機是每次改動的第一站。
這條連 tests/ 一起掃:抓到的第一個違規就在測試自己身上。
"""
offenders = []
for path in sorted(list(SCRIPTS.glob("*.py")) + list((ROOT / "tests").glob("*.py"))):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
if not (isinstance(fn, ast.Attribute) and fn.attr in ("read_text", "write_text")):
continue
if any(kw.arg == "encoding" or kw.arg is None for kw in node.keywords):
continue
offenders.append(f"{path.name}:{node.lineno} .{fn.attr}()")
self.assertEqual(offenders, [], "加上 encoding=\"utf-8\";平台預設編碼不是一個定義")
if __name__ == "__main__":
unittest.main()