import type { DB, Kysely } from "@umt/db"; import { parseRelease } from "@umt/domain"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type BaseRelease, decorate } from "../../src/modules/search/releases.ts"; import { compareWithLibrary, specHasRules } from "../../src/modules/search/rule-verdict.ts"; import type { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts"; import { memoryLogger } from "../helpers.ts"; import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts"; // 搜索结果的规则判定:关联到本地作品的资源按收录版本评估口径给出「命中第 N 档 / 不满足 / 比库里版本高几档」; // 没关联作品、作品没有收录版本、收录版本没配置条件时为 null。 const up = await pgAvailable(); describe("规则判定口径", () => { it("种子默认规则组(空层)不算规则;硬条件或带层规则组才算", () => { expect(specHasRules({ name: "x", hard: {} })).toBe(false); expect( specHasRules({ name: "x", hard: {}, rules: { groups: [{ name: "订阅默认", layers: [], hardExcludes: [] }], atoms: [] }, }), ).toBe(false); expect(specHasRules({ name: "x", hard: { resolutions: ["2160p"] } })).toBe(true); expect(specHasRules({ name: "x", hard: {}, soft: { tiers: [{ resolutions: ["2160p"] }] } })).toBe(true); }); it("不分档时按来源等级与分辨率比较,两项不同向不下结论", () => { const spec = { name: "x", hard: {} }; expect( compareWithLibrary( spec, { resolution: "2160p", source: "BluRay" }, 100, { resolution: "1080p", source: "WEB-DL" }, undefined, ), ).toEqual({ cmp: "higher", steps: null }); expect( compareWithLibrary( spec, { resolution: "1080p", source: "WEB-DL" }, 100, { resolution: "1080p", source: "WEB-DL" }, undefined, ), ).toEqual({ cmp: "same", steps: null }); expect( compareWithLibrary( spec, { resolution: "720p", source: "HDTV" }, 100, { resolution: "1080p", source: "WEB-DL" }, undefined, ), ).toEqual({ cmp: "lower", steps: null }); expect( compareWithLibrary( spec, { resolution: "2160p", source: "WEB-DL" }, 100, { resolution: "1080p", source: "BluRay" }, undefined, ), ).toBeNull(); }); }); describe.skipIf(!up)("搜索结果规则判定", () => { let db: Kysely; let ts: TestSchema; let rt: SourcesRuntime; let siteId = ""; let entity = ""; let bare = ""; const rel: Record = {}; const TITLES = { uhd: "Dune.Part.Two.2024.2160p.BluRay.REMUX.HEVC.DTS-HD.MA-FRDS", fhd: "Dune.Part.Two.2024.1080p.WEB-DL.H264-GRP", sd: "Dune.Part.Two.2024.720p.HDTV.x264-GRP", other: "Some.Other.Movie.2023.1080p.WEB-DL.H264-GRP", loose: "Unrelated.Thing.2020.1080p.WEB-DL-X", } as const; const base = (k: keyof typeof TITLES): BaseRelease => { const p = parseRelease(TITLES[k]); return { key: `${siteId}:${k}`, sourceReleaseId: rel[k]!, siteId, siteName: "站", siteReleaseId: k, title: TITLES[k], subtitle: null, category: { id: null, name: null }, sizeBytes: null, seeders: 10, leechers: 0, snatched: null, promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null }, free: false, hr: false, uploadedAt: null, detailUrl: "http://x/", observedAt: "2026-10-01T00:00:00.000Z", parsed: { titleCn: null, titleEn: p.titleEn ?? null, year: p.year ?? null, season: null, episodes: null, resolution: p.resolution ?? null, source: p.source ?? null, videoCodec: p.videoCodec ?? null, hdr: [], releaseGroup: null, }, }; }; beforeAll(async () => { ts = await createTestSchema(uniqueSchema("rule_verdict"), { max: 4 }); db = ts.db as unknown as Kysely; rt = { ctx: { db, logger: memoryLogger().logger } } as unknown as SourcesRuntime; siteId = ( await db .insertInto("site") .values({ key: "s1", name: "站", adapter: "nexusphp", base_url: "http://s1.invalid/" }) .returning("id") .executeTakeFirstOrThrow() ).id; const addEntity = async (title: string) => ( await db .insertInto("media_entity") .values({ kind: "movie", title, year: 2024, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; entity = await addEntity("沙丘2"); bare = await addEntity("没有收录版本的作品"); for (const k of Object.keys(TITLES) as (keyof typeof TITLES)[]) { const linked = k === "other" ? bare : k === "loose" ? null : entity; rel[k] = ( await db .insertInto("source_release") .values({ site_id: siteId, site_release_id: k, title: TITLES[k], seeders: 10, media_entity_id: linked, resolution_status: linked ? "resolved" : "unresolved", }) .returning("id") .executeTakeFirstOrThrow() ).id; } // 规则组:第 1 档 4K,第 2 档 1080P const group = ( await db .insertInto("rule_group") .values({ name: "画质", kind: "acquisition", fingerprint: "test:quality", layers: JSON.stringify([{ expr: { op: "atom", id: "4K" } }, { expr: { op: "atom", id: "1080P" } }]), }) .returning("id") .executeTakeFirstOrThrow() ).id; // 库里现有版本来自 1080p 那条资源(第 2 档) const asset = ( await db .insertInto("media_asset") .values({ media_entity_id: entity, kind: "video", source_release_id: rel.fhd!, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; await db .insertInto("acquisition_slot") .values({ media_entity_id: entity, name: "主版本", status: "satisfied", hard_conditions: JSON.stringify({ resolutions: ["2160p", "1080p"] }), rule_group_ids: JSON.stringify([group]), current_asset_id: asset, }) .execute(); }, 60_000); afterAll(async () => { await ts?.drop(); }); it("命中档位 / 不满足原因 / 与库里版本比较;无作品或作品没有收录版本时为 null", async () => { const items = await decorate( rt, (["uhd", "fhd", "sd", "other", "loose"] as const).map(base), async () => new Map(), ); const by = Object.fromEntries(items.map((i) => [i.siteReleaseId, i])); expect(by.uhd!.ruleVerdict).toMatchObject({ slotName: "主版本", slotCount: 1, outcome: "hit", tier: 1, tierName: "画质·第1层", failed: [], vsLibrary: { cmp: "higher", steps: 1 }, }); expect(by.fhd!.ruleVerdict).toMatchObject({ outcome: "hit", tier: 2, vsLibrary: { cmp: "same", steps: 0 } }); const sd = by.sd!.ruleVerdict!; expect(sd).toMatchObject({ outcome: "miss", tier: null, vsLibrary: null }); expect(sd.failed).toContain("分辨率"); expect(sd.failed).toContain("规则组「画质」"); expect(sd.details.length).toBeGreaterThan(0); // 关联到没有收录版本的作品 expect(by.other!.entity?.id).toBe(bare); expect(by.other!.ruleVerdict).toBeNull(); // 没关联作品 expect(by.loose!.entity).toBeNull(); expect(by.loose!.ruleVerdict).toBeNull(); }); it("收录版本只有种子默认规则组(没有条件)时不给判定", async () => { const e = ( await db .insertInto("media_entity") .values({ kind: "movie", title: "空规则作品", year: 2024, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; await db.insertInto("acquisition_slot").values({ media_entity_id: e, name: "默认版本" }).execute(); await db.updateTable("source_release").set({ media_entity_id: e }).where("id", "=", rel.loose!).execute(); const [item] = await decorate(rt, [base("loose")], async () => new Map()); expect(item!.entity?.id).toBe(e); expect(item!.ruleVerdict).toBeNull(); }); });