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]>
This commit is contained in:
1 parent
22856d0447
commit
e994a7bcfa
13 files changed
+23919
-10
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentPri,
|
||||
evaluateCandidate,
|
||||
parseRelease,
|
||||
parseRuleString,
|
||||
type RuleGroupSpec,
|
||||
rankForSlot,
|
||||
type SlotSpec,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const group = (name: string, s: string, extra: Partial<RuleGroupSpec> = {}): RuleGroupSpec => ({
|
||||
name,
|
||||
layers: s ? parseRuleString(s).layers : [],
|
||||
...extra,
|
||||
});
|
||||
const base = (groups: RuleGroupSpec[], extra: Partial<SlotSpec> = {}): SlotSpec => ({
|
||||
name: "main",
|
||||
hard: {},
|
||||
rules: { groups },
|
||||
...extra,
|
||||
});
|
||||
const rel = (t: string, subtitle?: string) => parseRelease(t, subtitle ? { subtitle } : {});
|
||||
|
||||
describe("版本目标接入规则组", () => {
|
||||
it("硬排除命中 → reject(rule.hard_exclude),trace 带组名与修订号", () => {
|
||||
const g = group("不要原盘", "", {
|
||||
id: "g1",
|
||||
revision: 3,
|
||||
hardExcludes: parseRuleString("BLU | REMUX").layers.map((l) => l.expr),
|
||||
});
|
||||
const ev = evaluateCandidate(base([g]), rel("Movie.2023.1080p.BluRay.REMUX.AVC-EPSiLON"));
|
||||
expect(ev.decision).toBe("reject");
|
||||
if (ev.decision !== "reject") return;
|
||||
expect(ev.violations).toEqual([
|
||||
{ field: "rule", code: "hard_exclude", expected: "不要原盘", actual: ["BLU | REMUX"] },
|
||||
]);
|
||||
expect(ev.trace?.join("\n")).toContain("规则组「不要原盘」@r3 硬排除 BLU | REMUX 命中");
|
||||
});
|
||||
|
||||
it("多组串联取交集,pri 取最后一个带层的组", () => {
|
||||
const spec = base([group("分辨率", "4K > 1080P"), group("字幕", "CNSUB > !CNSUB"), group("空组", "")]);
|
||||
const a = evaluateCandidate(spec, rel("Dune.2024.1080p.WEB-DL.H264-HHWEB", "中字"));
|
||||
expect(a).toMatchObject({ decision: "accept", pri: 100 });
|
||||
const b = evaluateCandidate(spec, rel("Dune.2024.2160p.WEB-DL.H265-FLUX"));
|
||||
expect(b).toMatchObject({ decision: "accept", pri: 99 });
|
||||
const c = evaluateCandidate(spec, rel("Dune.2024.720p.WEBRip.x264-GRP"));
|
||||
expect(c.decision).toBe("reject");
|
||||
if (c.decision === "reject")
|
||||
expect(c.violations[0]).toMatchObject({ code: "no_layer_matched", expected: "分辨率" });
|
||||
});
|
||||
|
||||
it("ladderRequired=false 的软偏好组不中只降 pri,不拒绝", () => {
|
||||
const spec = base([group("偏好", "SPECSUB", { ladderRequired: false })]);
|
||||
const r = rel("X.2024.1080p.WEB-DL.H264-G", "特效中字");
|
||||
expect(evaluateCandidate(spec, r, null, { subtitle: "特效中字" })).toMatchObject({ pri: 100 });
|
||||
expect(evaluateCandidate(spec, rel("X.2024.1080p.WEB-DL.H264-G"))).toMatchObject({ decision: "accept", pri: 0 });
|
||||
});
|
||||
|
||||
it("有规则组阶梯时升级按 pri 严格提升(洗版)", () => {
|
||||
const spec = base([group("洗版", "REMUX > BLURAY > WEBDL")], { upgrade: { enabled: true } });
|
||||
const up = evaluateCandidate(spec, rel("M.2023.1080p.BluRay.REMUX.AVC-E"), {
|
||||
source: "WEB-DL",
|
||||
resolution: "1080p",
|
||||
pri: 98,
|
||||
});
|
||||
expect(up).toMatchObject({ decision: "upgrade", pri: 100, fromPri: 98 });
|
||||
const same = evaluateCandidate(spec, rel("M.2023.1080p.WEB-DL.H264-G"), { source: "WEB-DL", pri: 98 });
|
||||
expect(same.decision).toBe("reject");
|
||||
expect(currentPri(spec, { source: "REMUX", resolution: "1080p" })).toBe(100);
|
||||
});
|
||||
|
||||
it("作品原语言中文时 CNSUB 放行(MP tmdb 条件)", () => {
|
||||
const spec = base([group("中字", "CNSUB")]);
|
||||
const r = rel("Movie.2023.1080p.WEB-DL.H264-G");
|
||||
expect(evaluateCandidate(spec, r).decision).toBe("reject");
|
||||
expect(evaluateCandidate(spec, r, null, { originalLanguage: "zh" })).toMatchObject({ decision: "accept" });
|
||||
});
|
||||
|
||||
it("rankForSlot 按规则组 pri 排序", () => {
|
||||
const spec = base([group("阶梯", "4K > 1080P > 720P")]);
|
||||
const ranked = rankForSlot(spec, [
|
||||
rel("A.720p.WEB-DL.x264-G"),
|
||||
rel("A.2160p.WEB-DL.x265-G"),
|
||||
rel("A.1080p.WEB-DL.x264-G"),
|
||||
]);
|
||||
expect(ranked.map((x) => x.evaluation.decision === "accept" && x.evaluation.pri)).toEqual([100, 99, 98]);
|
||||
});
|
||||
|
||||
it("没有规则组时行为不变", () => {
|
||||
const r = rel("Movie.2023.1080p.WEB-DL.H264-G");
|
||||
expect(evaluateCandidate(base([]), r)).toEqual(evaluateCandidate({ name: "main", hard: {} }, r));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user