# -*- 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 —— 合成用例(已知正确答案) * har/online/<时间戳>/tdc_*.js —— **在线抓取的全新构建**(真机数据) 运行: .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) # src=None: 契约是"同序" except ValueError: raised = True check("少一槽 -> ValueError", raised) # ---------------------------------------------------------------- # # [6] 强断言:重排**必须真的重排**(旧实现的 bug 就是被"是置换"放过的) # ---------------------------------------------------------------- # print("\n[6] reorder 不是恒等映射(抓旧 bug)") print(" 旧实现的循环是 sample[slot:slot+n],其中 slot 是**目标**槽号,") print(" 两份构建顺序不同时全盘错位;而'值集合不变'这个弱断言抓不住它。") # 把清单顺序打乱,构造"另一份构建" import random idx = list(range(len(seq))) random.Random(11).shuffle(idx) seq2 = [seq[i] for i in idx] kinds2 = [kinds[i] for i in idx] v0s2 = [v0s[i] for i in idx] # 给每项一个唯一语义名(真实场景由 tdc_modules 的指纹提供) names1 = ["n%d" % i for i in range(len(seq))] names2 = ["n%d" % i for i in idx] src = cg.spec_from_seq(seq, kinds, names1, sample_cd=sample_cd, v0s=v0s) dst = cg.spec_from_seq(seq2, kinds2, names2, v0s=v0s2) # 正确答案:按 src 的 assign 把每项的值搬到目标顺序 truth: list = [] for i in idx: _i, _m, sl, nn = src.assign[i] truth.extend(sample_cd[sl:sl + nn]) try: out2 = cg.reorder(sample_cd, dst, src) check("重排结果 == 已知正确答案", out2 == truth) check("重排结果 != 原样(证明真的重排了)", out2 != list(sample_cd)) except cg.UnmappedSlotsError as exc: check("重排结果 == 已知正确答案", False, f"抛错: {str(exc)[:80]}") # ---------------------------------------------------------------- # # [7] 用在线抓取的**全新构建**做端到端重排(真机数据,非合成) # ---------------------------------------------------------------- # online = sorted((ROOT / "har" / "online").glob("*/tdc_*.js")) if ( ROOT / "har" / "online").exists() else [] print("\n[7] 在线新构建 + 语义名锚点的端到端重排") if len(online) < 2: print(" (跳过: 需要 ≥2 份 har/online/*/tdc_*.js;" "用 scripts/fetch_online_samples.py --n 4 抓取)") else: try: import tdc_modules except Exception as exc: check("导入 tdc_modules", False, str(exc)[:80]) online = [] if len(online) >= 2: def names_of(f): return [r["name"] for r in tdc_modules.analyze(f)] na = names_of(online[0]) n = len(na) check("新构建清单 37 项", n == 37, "实际 %d" % n) check("语义名全部唯一(37/37)", len(set(na)) == n, "唯一 %d/%d" % (len(set(na)), n)) check("无 unknown", not any(x == "unknown" for x in na)) # 给每个语义名一个假值,按 A 顺序排成 cd_A,重排到 B 顺序 fake = {nm: "V<%s>" % nm for nm in na} cd_a = [fake[nm] for nm in na] seqa = [r["module"] for r in tdc_modules.analyze(online[0])] srca = cg.spec_from_seq(seqa, [None] * n, na, sample_cd=cd_a) all_ok = True for f in online[1:]: nb = names_of(f) seqb = [r["module"] for r in tdc_modules.analyze(f)] dstb = cg.spec_from_seq(seqb, [None] * len(nb), nb) truthb = [fake[nm] for nm in nb] try: got = cg.reorder(cd_a, dstb, srca) good = got == truthb and got != cd_a except Exception: good = False all_ok &= good print(" %-28s 重排正确=%s" % (f.parent.name + "/" + f.name[:12], good)) check("全部新构建重排正确且非恒等", all_ok) print(f"\n{'=' * 62}") if _fails: print(f"❌ collectgen 测试失败: {_fails}") return 1 print("✅ collectgen 测试全部通过") return 0 if __name__ == "__main__": raise SystemExit(main())