feat(rules): rule_group/rule_atom (0033), /rules endpoints, evaluation via referenced rule groups
- migration 0033: rule_group (layers, hard excludes, scope, source, revision,
fingerprint, default_order) and rule_atom; acquisition_slot.rule_group_ids;
preference tags migrated into rule groups (same id/fingerprint), suppressions
linked; seeded default groups for acquisition / upgrade / search
- contracts: RuleGroup/RuleAtom/RuleExpr/RuleDefaults, parse/serialize/preview
schemas; Api.rules, Api.ruleAtoms, Api.rulePolicies, Api.ruleSuppressions
- server: GET/POST/PATCH/DELETE /rules, /rules/{id}/revisions, /rules/{id}/preview,
/rules/parse, /rules/serialize, /rules/keywords, /rules/defaults, /rules/atoms;
disable/delete writes suppression, agent re-add -> 409
- /preference-tags now aliases rule groups; /policy-revisions and
/preference-suppressions kept as aliases of /rules/policies, /rules/suppressions
- evaluateAcquisition resolves slot -> subscription -> default chain (+ upgrade
defaults) and records pri / rule_group@revision / per-layer trace
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a0946bf480
commit
0105e5c6ac
11 files changed
+2296
-340
No files matched your search
@@ -0,0 +1,282 @@
|
||||
import { makeTorrent } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHarness,
|
||||
fakeDisk,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
seedSlot,
|
||||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
|
||||
// W9-D 规则组:关键字表、规则串解析/序列化、CRUD + 修订、抑制记录、试算、默认链、评估接入、旧端点别名。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("规则组 API(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
const disk = fakeDisk(1_000_000_000);
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
disk.setPct(10);
|
||||
h = await createHarness("rulegroups", { diskUsage: disk.fn });
|
||||
base = await seedBase(h.db, {
|
||||
libraryPath: "/tmp/nonexistent-lib",
|
||||
cachePath: "/tmp",
|
||||
qbUrl: "http://127.0.0.1:9",
|
||||
});
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("关键字表对齐 MP;规则串解析 / 序列化 / 错误位置", async () => {
|
||||
const kw = await h.api("GET", "/rules/keywords");
|
||||
expect(kw.status).toBe(200);
|
||||
const byId = new Map(kw.body.data.items.map((k: { id: string }) => [k.id, k]));
|
||||
expect(byId.get("4K")).toMatchObject({ builtin: true, supported: true, basis: "field" });
|
||||
expect(byId.get("CNSUB")).toMatchObject({ mp: { tmdb: { original_language: "zh,cn" } } });
|
||||
expect(byId.get("FLAC")).toMatchObject({ supported: false });
|
||||
|
||||
const p = await h.api("POST", "/rules/parse", { ruleString: "specsub & cnsub > CNSUB & 4K > 1080P & !BLU" });
|
||||
expect(p.status).toBe(200);
|
||||
expect(p.body.data.ruleString).toBe("SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU");
|
||||
expect(p.body.data.layers).toHaveLength(3);
|
||||
const bad = await h.api("POST", "/rules/parse", { ruleString: "4K & CNSUBB > (1080P" });
|
||||
expect(bad.body.data.ruleString).toBeNull();
|
||||
expect(bad.body.data.errors[0]).toMatchObject({ code: "unknown_atom", position: 5, suggestions: ["CNSUB"] });
|
||||
const ser = await h.api("POST", "/rules/serialize", { layers: p.body.data.layers });
|
||||
expect(ser.body.data.ruleString).toBe("SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU");
|
||||
});
|
||||
|
||||
it("默认三套组;CRUD + revision 乐观锁 + 修订历史;名称唯一", async () => {
|
||||
const defs = await h.api("GET", "/rules/defaults");
|
||||
expect(defs.body.data.acquisition).toHaveLength(1);
|
||||
expect(defs.body.data.upgrade).toHaveLength(1);
|
||||
expect(defs.body.data.search).toHaveLength(1);
|
||||
|
||||
const c = await h.api("POST", "/rules", { name: "4K 优先", ruleString: "4K > 1080P", hardExcludes: ["BLU"] });
|
||||
expect(c.status).toBe(201);
|
||||
expect(c.body.data).toMatchObject({
|
||||
kind: "acquisition",
|
||||
source: "manual",
|
||||
ruleString: "4K > 1080P",
|
||||
ladderRequired: true,
|
||||
revision: 1,
|
||||
hardExcludes: [{ op: "atom", id: "BLU" }],
|
||||
});
|
||||
expect((await h.api("POST", "/rules", { name: "4K 优先" })).status).toBe(409);
|
||||
expect((await h.api("POST", "/rules", { name: "坏", ruleString: "4K & NOPE" })).status).toBe(422);
|
||||
const id = c.body.data.id;
|
||||
expect((await h.api("PATCH", `/rules/${id}`, { expectedRevision: 5, ruleString: "4K" })).status).toBe(409);
|
||||
const u = await h.api("PATCH", `/rules/${id}`, { expectedRevision: 1, ruleString: "4K & !REMUX > 1080P" });
|
||||
expect(u.body.data).toMatchObject({ revision: 2, ruleString: "4K & !REMUX > 1080P" });
|
||||
const rev = await h.api("GET", `/rules/${id}/revisions`);
|
||||
expect(rev.body.data.items.map((r: { action: string }) => r.action)).toEqual([
|
||||
"rule_group.updated",
|
||||
"rule_group.created",
|
||||
]);
|
||||
expect(rev.body.data.items[0].changedFields).toEqual(["layers"]);
|
||||
const list = await h.api("GET", "/rules?kind=acquisition");
|
||||
expect(list.body.data.items.some((g: { id: string }) => g.id === id)).toBe(true);
|
||||
});
|
||||
|
||||
it("自定义条件:不能覆盖内置;被引用不能删", async () => {
|
||||
expect((await h.api("POST", "/rules/atoms", { key: "4K" })).status).toBe(409);
|
||||
expect((await h.api("POST", "/rules/atoms", { key: "BADRE", include: ["(a+)+"] })).status).toBe(422);
|
||||
const a = await h.api("POST", "/rules/atoms", { key: "sizemovie", unitSizeMb: { min: 2000, max: 30000 } });
|
||||
expect(a.status).toBe(201);
|
||||
expect(a.body.data.key).toBe("SIZEMOVIE");
|
||||
const g = await h.api("POST", "/rules", { name: "体积", ruleString: "SIZEMOVIE" });
|
||||
expect(g.status).toBe(201);
|
||||
expect((await h.api("DELETE", `/rules/atoms/${a.body.data.id}`)).status).toBe(409);
|
||||
const kw = await h.api("GET", "/rules/keywords");
|
||||
expect(kw.body.data.items.find((k: { id: string }) => k.id === "SIZEMOVIE")).toMatchObject({ builtin: false });
|
||||
});
|
||||
|
||||
it("抑制:停用/删除写抑制记录;Agent 不能加回(409);手动新建解除", async () => {
|
||||
const body = { name: "不要 HDR", source: "agent", hardExcludes: ["HDR"] };
|
||||
const c = await h.api("POST", "/rules", body);
|
||||
expect(c.status).toBe(201);
|
||||
expect((await h.api("POST", "/rules", { ...body, name: "不要 HDR 2" })).status).toBe(409);
|
||||
const off = await h.api("PATCH", `/rules/${c.body.data.id}`, { enabled: false });
|
||||
expect(off.body.data.suppressedAt).not.toBeNull();
|
||||
const sup = await h.api("GET", "/rules/suppressions?active=true");
|
||||
expect(sup.body.data.items.map((s: { fingerprint: string }) => s.fingerprint)).toContain(c.body.data.fingerprint);
|
||||
expect((await h.api("DELETE", `/rules/${c.body.data.id}`)).status).toBe(200);
|
||||
expect((await h.api("POST", "/rules", { ...body, name: "又来" })).status).toBe(409);
|
||||
const again = await h.api("POST", "/rules", { ...body, name: "用户自己加", source: "manual" });
|
||||
expect(again.status).toBe(201);
|
||||
const still = await h.api("GET", "/preference-suppressions?active=true");
|
||||
expect(still.body.data.items.map((s: { fingerprint: string }) => s.fingerprint)).not.toContain(
|
||||
c.body.data.fingerprint,
|
||||
);
|
||||
});
|
||||
|
||||
it("试算:最近资源 + 粘贴标题,给 pri / 命中层 / 拒绝原因", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "试算片", year: 2024 });
|
||||
for (const [i, t] of [
|
||||
"Try.2024.2160p.WEB-DL.H265-A",
|
||||
"Try.2024.1080p.BluRay.x264-B",
|
||||
"Try.2024.720p.WEB-DL.x264-C",
|
||||
].entries())
|
||||
await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: e,
|
||||
title: t,
|
||||
size: 1000,
|
||||
hash: `${"ab".repeat(19)}${i}${i}`,
|
||||
});
|
||||
const g = await h.api("POST", "/rules", { name: "试算组", ruleString: "4K > 1080P", hardExcludes: ["BLU"] });
|
||||
const pv = await h.api("POST", `/rules/${g.body.data.id}/preview`, {
|
||||
limit: 20,
|
||||
titles: [{ title: "X.2023.1080p.Blu-ray.AVC.DTS-HD.MA-GRP" }],
|
||||
sourceReleaseIds: [],
|
||||
});
|
||||
expect(pv.status).toBe(200);
|
||||
const byTitle = new Map(pv.body.data.items.map((i: { title: string }) => [i.title, i]));
|
||||
expect(byTitle.get("Try.2024.2160p.WEB-DL.H265-A")).toMatchObject({ passed: true, pri: 100, level: 0 });
|
||||
expect(byTitle.get("Try.2024.1080p.BluRay.x264-B")).toMatchObject({ passed: true, pri: 99 });
|
||||
expect(byTitle.get("Try.2024.720p.WEB-DL.x264-C")).toMatchObject({ passed: false, reasons: ["no_layer_matched"] });
|
||||
expect(byTitle.get("X.2023.1080p.Blu-ray.AVC.DTS-HD.MA-GRP")).toMatchObject({
|
||||
passed: false,
|
||||
excludedBy: ["BLU"],
|
||||
reasons: ["hard_exclude"],
|
||||
});
|
||||
const draft = await h.api("POST", `/rules/${g.body.data.id}/preview`, {
|
||||
titles: [{ title: "Try.2024.720p.WEB-DL.x264-C" }],
|
||||
draft: { ruleString: "720P" },
|
||||
});
|
||||
expect(draft.body.data.items[0]).toMatchObject({ passed: true, pri: 100 });
|
||||
});
|
||||
|
||||
it("评估接入:版本目标引用规则组 → 硬排除拒绝、pri 进候选评估 trace;被引用的组不能删", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "规则片", year: 2024 });
|
||||
const slot = await seedSlot(h.db, { entityId: e });
|
||||
const g = await h.api("POST", "/rules", { name: "片源", ruleString: "4K > 1080P", hardExcludes: ["REMUX"] });
|
||||
const up = await h.api("PATCH", `/acquisition-slots/${slot}`, {
|
||||
expectedRevision: 1,
|
||||
ruleGroupIds: [g.body.data.id],
|
||||
});
|
||||
expect(up.body.data.ruleGroupIds).toEqual([g.body.data.id]);
|
||||
expect(
|
||||
(
|
||||
await h.api("PATCH", `/acquisition-slots/${slot}`, {
|
||||
expectedRevision: 2,
|
||||
ruleGroupIds: ["7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60"],
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
|
||||
const t1 = makeTorrent({ name: "Rule.2024.1080p.BluRay.REMUX.mkv", length: 1000, pieceSeed: 41 });
|
||||
const r1 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: e,
|
||||
title: "Rule.2024.1080p.BluRay.REMUX.AVC-EPSiLON",
|
||||
size: 1000,
|
||||
hash: t1.hash,
|
||||
});
|
||||
const ev1 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 });
|
||||
expect(ev1.plans).toHaveLength(0);
|
||||
const cand = async (rid: string) =>
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("artifact")
|
||||
.select("content")
|
||||
.where("subject_id", "=", slot)
|
||||
.where("title", "=", "candidate_evaluation")
|
||||
.execute()
|
||||
)
|
||||
.map(
|
||||
(a) =>
|
||||
a.content as {
|
||||
sourceReleaseId: string;
|
||||
decision: string;
|
||||
pri: number | null;
|
||||
trace: string[];
|
||||
rejected: { code: string }[];
|
||||
reasons: string[];
|
||||
},
|
||||
)
|
||||
.find((x) => x.sourceReleaseId === rid)!;
|
||||
const c1 = await cand(r1);
|
||||
expect(c1.decision).toBe("reject");
|
||||
expect(c1.rejected.map((x) => x.code)).toContain("rule.hard_exclude");
|
||||
expect(c1.trace.join("\n")).toContain("规则组「片源」@r1 硬排除 REMUX 命中");
|
||||
|
||||
const t2 = makeTorrent({ name: "Rule.2024.1080p.WEB-DL.mkv", length: 1000, pieceSeed: 42 });
|
||||
const r2 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: e,
|
||||
title: "Rule.2024.1080p.WEB-DL.H264-GRP",
|
||||
size: 1000,
|
||||
hash: t2.hash,
|
||||
});
|
||||
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: r2 });
|
||||
expect(ev2.plans).toHaveLength(1);
|
||||
const c2 = await cand(r2);
|
||||
expect(c2).toMatchObject({ decision: "accept", pri: 99 });
|
||||
expect(c2.reasons).toContain(`rule_group:${g.body.data.id}@1`);
|
||||
|
||||
expect((await h.api("DELETE", `/rules/${g.body.data.id}`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it("默认链:订阅默认组有层时对未引用规则组的版本目标生效;PUT /rules/defaults 校验类型", async () => {
|
||||
const defs = (await h.api("GET", "/rules/defaults")).body.data;
|
||||
const dflt = defs.acquisition[0];
|
||||
expect((await h.api("PUT", "/rules/defaults", { ...defs, acquisition: defs.upgrade })).status).toBe(400);
|
||||
await h.api("PATCH", `/rules/${dflt}`, { ruleString: "WEBDL" });
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "默认片", year: 2024 });
|
||||
const slot = await seedSlot(h.db, { entityId: e });
|
||||
const t = makeTorrent({ name: "Dflt.2024.1080p.BluRay.mkv", length: 1000, pieceSeed: 51 });
|
||||
const r = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: e,
|
||||
title: "Dflt.2024.1080p.BluRay.x264-GRP",
|
||||
size: 1000,
|
||||
hash: t.hash,
|
||||
});
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
||||
expect(ev.plans).toHaveLength(0);
|
||||
const a = await h.db
|
||||
.selectFrom("artifact")
|
||||
.select("content")
|
||||
.where("subject_id", "=", slot)
|
||||
.where("title", "=", "candidate_evaluation")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((a.content as { rejected: { code: string }[] }).rejected.map((x) => x.code)).toContain(
|
||||
"rule.no_layer_matched",
|
||||
);
|
||||
// 复原,避免影响其它用例
|
||||
await h.api("PATCH", `/rules/${dflt}`, { ruleString: "" });
|
||||
});
|
||||
|
||||
it("旧端点别名:偏好标签落在规则组;/rules/policies 与 /policy-revisions 同一数据", async () => {
|
||||
const tag = await h.api("POST", "/preference-tags", {
|
||||
label: "不要国配",
|
||||
kind: "hard",
|
||||
rule: { ruleString: "CNVOI" },
|
||||
origin: "conversation",
|
||||
});
|
||||
expect(tag.status).toBe(201);
|
||||
const g = await h.api("GET", `/rules/${tag.body.data.id}`);
|
||||
expect(g.body.data).toMatchObject({
|
||||
legacyKind: "hard",
|
||||
source: "chat",
|
||||
hardExcludes: [{ op: "atom", id: "CNVOI" }],
|
||||
});
|
||||
const pol = await h.api("POST", "/rules/policies", {
|
||||
policyKey: "w9d",
|
||||
kind: "acquisition",
|
||||
title: "t",
|
||||
rules: {},
|
||||
});
|
||||
expect(pol.status).toBe(201);
|
||||
const old = await h.api("GET", `/policy-revisions/${pol.body.data.id}`);
|
||||
expect(old.body.data.policyKey).toBe("w9d");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user