tools/tdc_collectgen.py: 静态导出槽位序 -> 按 #36 规则算「项->槽」-> 重排 -> 该构建密钥加密。 同一构建实测: 37 项 -> 38 槽, 明文 686B / collect 920 字符, 解密回环逐字节一致。 规则修正(重要): * kind=1 的串只按**括号外**逗号切槽 —— '[]'/'[360,360]'/'["zh-CN","zh"]' 内逗号不构成新槽 * kind=2 的项**仍产出 1 槽**(r3 清空转存 r4, r4 在收尾 @4905 拼回) 新增 tcaptcha/tests/test_collectgen.py (已并入 selftest) 与 fixture: outer_commas / 槽数推算 == 真机槽数 / 重排是置换 / 加解密回环 / 不匹配必须报错。 账本新增已知 #37(工具链跑通) 与 #38(纯离线生成尚未打通, 诚实标注)。
106 lines
3.9 KiB
Python
106 lines
3.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
按构建生成 collect 的回归测试(承 PROGRESS.md #34/#35/#36;AGENTS.md §5.9)。
|
||
|
||
固化的性质:
|
||
1. `outer_commas`: 只数**括号外**的逗号 —— 方括号内的逗号不构成顶层分隔
|
||
('[]' / '[360,360]' / '["zh-CN","zh"]' 拼回去仍是一个值);
|
||
2. `SlotSpec` 推算出的槽数 == 真机明文 cd 的实际槽数;
|
||
3. 重排是**置换**:值集合不变(排序后逐项相同);
|
||
4. 加密/解密**回环一致**(明文逐字节还原);
|
||
5. 槽数不匹配的样本**必须报错**,不能静默错位。
|
||
|
||
样本: tcaptcha/tests/fixtures/tdc_slotmap_case.json
|
||
= {seq, kinds, v0s, sample_cd},取自 2026-10-11 真机会话(37 项 -> 38 槽)。
|
||
|
||
运行:
|
||
.venv/bin/python -m tcaptcha.tests.test_collectgen
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
if str(ROOT / "tools") not in sys.path:
|
||
sys.path.insert(0, str(ROOT / "tools"))
|
||
|
||
from tcaptcha.crypto import xtea_decrypt_bytes # noqa: E402
|
||
|
||
import tdc_collectgen as cg # noqa: E402
|
||
|
||
FIX = ROOT / "tcaptcha" / "tests" / "fixtures" / "tdc_slotmap_case.json"
|
||
# 固定密钥,仅用于回环自洽(不代表任何真实构建)
|
||
KEY = [1497711441, 1213874789, 1799903316, 1835232858]
|
||
|
||
_fails: list = []
|
||
|
||
|
||
def check(name: str, cond: bool, detail: str = "") -> None:
|
||
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
|
||
if not cond:
|
||
_fails.append(name)
|
||
|
||
|
||
def main() -> int:
|
||
if not FIX.exists():
|
||
print(f"缺少 fixture: {FIX}")
|
||
return 1
|
||
case = json.loads(FIX.read_text(encoding="utf-8"))
|
||
seq, kinds, v0s, sample_cd = case["seq"], case["kinds"], case["v0s"], case["sample_cd"]
|
||
print(f"fixture: 项 {len(seq)} / kind {len(kinds)} / v0 {len(v0s)} / 真机 cd {len(sample_cd)}")
|
||
|
||
print("\n[1] outer_commas: 只数括号外的逗号")
|
||
check("'[]' -> 0", cg.outer_commas("[]") == 0)
|
||
check("'[360,360]' -> 0", cg.outer_commas("[360,360]") == 0)
|
||
check("'[\"zh-CN\",\"zh\"]' -> 0", cg.outer_commas('["zh-CN","zh"]') == 0)
|
||
check("'641547489,1791732001' -> 1", cg.outer_commas("641547489,1791732001") == 1)
|
||
check("'a,b,c' -> 2", cg.outer_commas("a,b,c") == 2)
|
||
|
||
print("\n[2] 槽数推算 == 真机明文槽数")
|
||
spec = cg.SlotSpec(seq, kinds, v0s)
|
||
print(f" {spec.describe()}")
|
||
check("槽数一致", spec.n_slots == len(sample_cd),
|
||
f"{spec.n_slots} vs {len(sample_cd)}")
|
||
check("fixture 覆盖 kind=1", any(k == 1 for k in kinds))
|
||
check("fixture 覆盖 kind=2", any(k == 2 for k in kinds))
|
||
|
||
print("\n[3] 重排是置换(值集合不变)")
|
||
out = cg.reorder(sample_cd, spec)
|
||
key = lambda v: json.dumps(v, sort_keys=True, ensure_ascii=False)
|
||
check("长度不变", len(out) == len(sample_cd), f"{len(out)} vs {len(sample_cd)}")
|
||
check("值集合相同(排序后逐项相等)",
|
||
sorted(map(key, out)) == sorted(map(key, sample_cd)))
|
||
|
||
print("\n[4] 加密/解密回环")
|
||
plain = cg.build_plain(out)
|
||
b64 = cg.encrypt_plain(plain, KEY)
|
||
back = xtea_decrypt_bytes(base64.b64decode(b64), KEY).decode("latin1").rstrip("\x00").strip()
|
||
check("明文逐字节还原", back == plain)
|
||
back_obj = json.loads(back)
|
||
check("cd 还原", back_obj["cd"] == out)
|
||
check("sd 还原", back_obj["sd"] == {"od": "C", "ft": "qf_7Pf__H"})
|
||
|
||
print("\n[5] 槽数不匹配必须报错")
|
||
raised = False
|
||
try:
|
||
cg.reorder(sample_cd[:-1], spec)
|
||
except ValueError:
|
||
raised = True
|
||
check("少一槽 -> ValueError", raised)
|
||
|
||
print(f"\n{'=' * 62}")
|
||
if _fails:
|
||
print(f"❌ collectgen 测试失败: {_fails}")
|
||
return 1
|
||
print("✅ collectgen 测试全部通过")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|