Files
UHD_Media_Tool/packages/domain/test/differential/rules.test.ts
T
rootandClaude Opus 5.5 e994a7bcfa feat(domain): rule groups — MP rule-string parser/serializer, builtin keywords, chain evaluation wired into slot
- rules/: RuleExpr tree, '>' layers with ! & | and parens, position-aware errors
  (unknown atom with suggestions, unsupported music atoms, parens, empty layer)
- builtin keyword table aligned with MP BUILTIN_RULE_SET; field-based judgement
  where parsed fields exist, MP regex otherwise; custom atoms (MP 7 fields)
- chain evaluation: hard excludes, ladder pri 100-k, groups AND-ed, pri from last
  laddered group, readable per-atom trace
- slot: SlotSpec.rules, rule.hard_exclude / rule.no_layer_matched violations,
  pri basis switches to priority when a rule ladder is present
- differential: 66 rule chains x 32 torrents vs MP python path (parse 64/64,
  pri 2090/2112, 22 registered mp_bug)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 14:50:43 +08:00

185 lines
7.4 KiB
TypeScript

/**
* 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<string, Record<string, string>>;
torrents: Torrent[];
rules: Rule[];
parse: Record<string, unknown[]>;
cases: Case[];
deviations: Record<string, Deviation>;
}
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<string, { mp: number | null; ours: number | null }> = {};
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);
});
});