- 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]>
185 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|