/** * MoviePilot 规则串 / 规则组链 差分测试(W9-D)。 * * rules-cases.json 由 scripts/mp-sync/gen-rules-differential.py 用 MP 的 Python 实现生成: * - parse:每个规则串按 `>` 切层后 pyparsing RuleParser 的 as_list(); * - cases:FilterModule.filter_torrents(多组串联,pri 取最后一组)的 pri_order 与 debug trace。 * 我们用 parseRuleString → evaluateRuleChain 求同样的 pri。 * 断言:解析结构全部一致;pri 不一致集合 == deviations 登记集合(补迁消除差异后测试会失败,提醒删登记)。 */ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { BUILTIN_ATOMS, catalogOf, customAtomFromMp, evaluateRuleChain, fromMpParseList, normalizeExpr, parseRelease, parseRuleString, type RuleChainResult, type RuleGroupSpec, serializeLayers, } from "../../src/index.ts"; interface Torrent { id: string; title: string; description: string; size: number; seeders: number; labels?: string[]; downloadvolumefactor?: number; } interface Rule { id: string; groups: string[]; mediainfo?: { original_language?: string }; } interface Case { caseId: string; ruleId: string; torrentId: string; mp: { pri: number | null; trace: string[]; rustTrace?: string[] }; } type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "not_expressible"; interface Deviation { kind: DeviationKind; note: string; mp?: number | null; ours?: number | null; } interface Fixture { generatedFrom: { commit: string }; customRules: Record>; torrents: Torrent[]; rules: Rule[]; parse: Record; cases: Case[]; deviations: Record; } const fx = JSON.parse(readFileSync(new URL("./rules-cases.json", import.meta.url), "utf8")) as Fixture; const custom = Object.entries(fx.customRules).map(([id, r]) => customAtomFromMp(id, r)); const catalog = catalogOf(custom); const customIds = custom.map((a) => a.id); const torrents = new Map(fx.torrents.map((t) => [t.id, t])); const rules = new Map(fx.rules.map((r) => [r.id, r])); function groupsOf(rule: Rule): RuleGroupSpec[] { return rule.groups.map((s, i) => { const p = parseRuleString(s, { customAtoms: customIds, allowUnknown: true }); if (p.errors.length) throw new Error(`${rule.id}: ${JSON.stringify(p.errors)}`); return { name: `${rule.id}#${i}`, layers: p.layers }; }); } function ours(c: Case): RuleChainResult { const rule = rules.get(c.ruleId)!; const t = torrents.get(c.torrentId)!; const parsed = { ...parseRelease(t.title, t.description ? { subtitle: t.description } : {}), sizeBytes: t.size }; return evaluateRuleChain( groupsOf(rule), parsed, { ...(t.description ? { subtitle: t.description } : {}), labels: t.labels ?? [], seeders: t.seeders, downloadVolumeFactor: t.downloadvolumefactor ?? 1, ...(rule.mediainfo?.original_language ? { originalLanguage: rule.mediainfo.original_language } : {}), }, catalog, ); } const priOf = (r: RuleChainResult) => (r.passed ? (r.pri ?? 100) : null); describe("MP 规则串解析差分(rules-cases.json#parse)", () => { it("≥40 条规则链,覆盖全部支持的内置关键字", () => { expect(fx.rules.length).toBeGreaterThanOrEqual(40); const used = new Set(Object.keys(fx.parse).flatMap((s) => s.match(/[A-Za-z0-9]+/g) ?? [])); const supported = BUILTIN_ATOMS.filter((a) => a.supported).map((a) => a.id); expect(supported.filter((id) => !used.has(id))).toEqual([]); }); it("每个规则串的分层与层内结构 == MP RuleParser", () => { for (const [s, mpLayers] of Object.entries(fx.parse)) { const p = parseRuleString(s, { customAtoms: customIds, allowUnknown: true }); expect(p.errors, s).toEqual([]); expect( p.layers.map((l) => l.expr), s, ).toEqual(mpLayers.map((l) => normalizeExpr(fromMpParseList(l)))); } }); it("序列化后再解析结构不变(规则串双向映射)", () => { for (const s of Object.keys(fx.parse)) { const a = parseRuleString(s, { customAtoms: customIds, allowUnknown: true }).layers; const text = serializeLayers(a); const b = parseRuleString(text, { customAtoms: customIds, allowUnknown: true }).layers; expect(b, `${s} → ${text}`).toEqual(a); } }); }); describe("MP 规则组过滤差分(rules-cases.json#cases)", () => { it("pri 不一致集合 == deviations 登记集合", () => { const actual: Record = {}; for (const c of fx.cases) { const o = priOf(ours(c)); if (o !== c.mp.pri) actual[c.caseId] = { mp: c.mp.pri, ours: o }; } if (process.env.UMT_RULES_DUMP) console.log(`UMT_RULES_DUMP ${JSON.stringify(actual)}`); expect(Object.keys(actual).sort()).toEqual(Object.keys(fx.deviations).sort()); for (const [k, v] of Object.entries(fx.deviations)) { expect(["intentional", "missing", "our_bug", "mp_bug", "not_expressible"]).toContain(v.kind); expect(actual[k], k).toEqual({ mp: v.mp ?? null, ours: v.ours ?? null }); } // 一致率(报告用):登记外全部一致 expect(fx.cases.length - Object.keys(actual).length).toBeGreaterThan(fx.cases.length * 0.9); }); it("trace 可读:过滤给出未命中层与原子依据,放行给出命中层与 pri,未知条件写明不存在", () => { for (const c of fx.cases) { const r = ours(c); const text = r.trace.join("\n"); if (!r.passed) expect(text, c.caseId).toMatch(/未命中|硬排除|过滤/); const withLayers = r.groups.some((g) => g.pri !== null); if (r.passed && withLayers) expect(text, c.caseId).toMatch(/命中,pri=\d+/); // MP trace 里提到的"不包含任何项"对应我们 ✗ 的原子依据 if (c.mp.trace.some((x) => x.startsWith("不包含任何项")) && !r.passed) expect(text).toMatch(/✗/); if (c.mp.trace.some((x) => /规则 \w+ 不存在/.test(x))) expect(text).toMatch(/条件不存在/); } }); }); describe("规则串错误", () => { it("未知条件给位置与相近名;音乐条件不支持;括号/空层", () => { const a = parseRuleString("4K & CNSUBB > 1080P"); expect(a.errors[0]).toMatchObject({ code: "unknown_atom", position: 5, layer: 0, atom: "CNSUBB" }); expect(a.errors[0]!.suggestions).toContain("CNSUB"); expect(parseRuleString("FLAC").errors[0]).toMatchObject({ code: "unsupported_atom", atom: "FLAC" }); expect(parseRuleString("(4K | 1080P").errors[0]).toMatchObject({ code: "unbalanced_paren", position: 0 }); expect(parseRuleString("4K)").errors[0]).toMatchObject({ code: "unbalanced_paren", position: 2 }); expect(parseRuleString("4K > > 720P").errors[0]).toMatchObject({ code: "empty_layer", layer: 1, position: 4 }); expect(parseRuleString("4K 1080P").errors[0]).toMatchObject({ code: "unexpected_token", position: 3 }); expect(parseRuleString("4K & $").errors[0]).toMatchObject({ code: "invalid_char", position: 5 }); expect(parseRuleString("").errors[0]!.code).toBe("empty"); expect(parseRuleString("4k & !remux").layers.map((l) => l.expr)).toEqual([ { op: "and", args: [ { op: "atom", id: "4K" }, { op: "not", arg: { op: "atom", id: "REMUX" } }, ], }, ]); }); it("序列化只加必要括号", () => { const s = "SPECSUB & CNSUB > (4K | 1080P) & !(BLU | REMUX) > !!3D | 720P & H265"; expect(serializeLayers(parseRuleString(s).layers)).toBe(s); }); });