Files
hello_js_reverse_skill/tcaptcha/tests/test_collectgen.py
T
yml2213 6aaf6bc290 feat(collect): 按本次构建生成 collect 的工具链 + 回环回归测试
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(纯离线生成尚未打通, 诚实标注)。
2026-10-12 00:45:23 +08:00

106 lines
3.9 KiB
Python
Raw 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.
# -*- 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())