- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
343 lines
15 KiB
TypeScript
343 lines
15 KiB
TypeScript
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("洗版:现有资产按规则组重算 pri,只接受 pri 严格更高的候选", async () => {
|
|
const e = await seedEntity(h.db, { kind: "movie", title: "洗版片", year: 2024 });
|
|
const slot = await seedSlot(h.db, { entityId: e, upgrade: { enabled: true } });
|
|
const g = await h.api("POST", "/rules", { name: "洗版阶梯", ruleString: "REMUX > WEBDL & CNSUB > WEBDL" });
|
|
await h.api("PATCH", `/acquisition-slots/${slot}`, { expectedRevision: 1, ruleGroupIds: [g.body.data.id] });
|
|
const tOld = makeTorrent({ name: "Up.2024.1080p.WEB-DL.mkv", length: 1000, pieceSeed: 61 });
|
|
const oldRel = await seedRelease(h.db, {
|
|
siteId: base.siteId,
|
|
entityId: e,
|
|
title: "Up.2024.1080p.WEB-DL.H264-OLD",
|
|
size: 1000,
|
|
hash: tOld.hash,
|
|
});
|
|
await h.db.updateTable("source_release").set({ subtitle: "中字" }).where("id", "=", oldRel).execute();
|
|
const asset = await h.db
|
|
.insertInto("media_asset")
|
|
.values({
|
|
media_entity_id: e,
|
|
kind: "video",
|
|
size_bytes: 1000,
|
|
source_release_id: oldRel,
|
|
media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.updateTable("acquisition_slot")
|
|
.set({ status: "satisfied", current_asset_id: asset.id })
|
|
.where("id", "=", slot)
|
|
.execute();
|
|
// 现有资产 = WEB-DL + 中字 → pri 99;同档 WEB-DL 中字不换,REMUX(pri 100)升级
|
|
const tSame = makeTorrent({ name: "Up.2024.1080p.WEB-DL.2.mkv", length: 1000, pieceSeed: 62 });
|
|
const same = await seedRelease(h.db, {
|
|
siteId: base.siteId,
|
|
entityId: e,
|
|
title: "Up.2024.1080p.WEB-DL.H264-NEW",
|
|
size: 1000,
|
|
hash: tSame.hash,
|
|
});
|
|
await h.db.updateTable("source_release").set({ subtitle: "中字" }).where("id", "=", same).execute();
|
|
expect((await evaluateAcquisition(h.d, { sourceReleaseId: same })).plans).toHaveLength(0);
|
|
const tBetter = makeTorrent({ name: "Up.2024.1080p.REMUX.mkv", length: 1000, pieceSeed: 63 });
|
|
const better = await seedRelease(h.db, {
|
|
siteId: base.siteId,
|
|
entityId: e,
|
|
title: "Up.2024.1080p.BluRay.REMUX.AVC-GRP",
|
|
size: 1000,
|
|
hash: tBetter.hash,
|
|
});
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: better });
|
|
expect(ev.plans).toHaveLength(1);
|
|
const plan = await h.db
|
|
.selectFrom("acquisition_plan")
|
|
.select(["purpose", "evaluation"])
|
|
.where("id", "=", ev.plans[0]!)
|
|
.executeTakeFirstOrThrow();
|
|
expect(plan.purpose).toBe("upgrade");
|
|
expect(plan.evaluation).toMatchObject({ pri: 100 });
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|