import { describe, expect, it } from "vitest"; import { canTransition, checkDistinctAssets, compareRank, evaluateCandidate, movieCompletion, type ParsedRelease, parseRelease, qualityTier, rankForSlot, type SlotSpec, type SlotState, SlotTransitionError, transition, unitCompletion, } from "../src/index.ts"; const GiB = 1024 ** 3; const rel = (t: string, over: Partial = {}): ParsedRelease => ({ ...parseRelease(t), ...over }); const archival4k: SlotSpec = { id: "s-4k", name: "4K 蓝光压制", hard: { resolutions: ["2160p"], sources: ["BluRay", "WEB-DL"], maxSizeBytes: 40 * GiB }, soft: { hdr: ["DV", "HDR10"], groups: ["CHD", "FraMeSToR"], preferredSizeBytes: { max: 30 * GiB } }, upgrade: { enabled: true }, }; describe("evaluateCandidate 硬条件", () => { it("满足 → accept 并给软偏好排序键", () => { const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265.DV-CHD 20GB")); expect(r.decision).toBe("accept"); if (r.decision !== "accept") return; expect(r.softRank).toEqual([2, 2, 0, 0, 1]); expect(r.tier).toEqual({ source: 3, resolution: 6 }); }); it("已知不满足 → reject,列出全部违规", () => { const r = evaluateCandidate(archival4k, rel("Dune.2021.1080p.HDTV.x264-GRP 50GB")); expect(r.decision).toBe("reject"); if (r.decision !== "reject") return; expect(r.violations.map((v) => `${v.field}:${v.code}`)).toEqual([ "resolution:not_allowed", "source:not_allowed", "size:too_large", ]); }); it("缺字段 → unknown_field(与不满足区分)", () => { const r = evaluateCandidate(archival4k, rel("Dune 2021 BluRay")); expect(r).toMatchObject({ decision: "unknown_field", missing: ["resolution", "size"] }); }); it("unknownPolicy 可按字段配置 reject / accept", () => { const spec: SlotSpec = { ...archival4k, unknownPolicy: { size: "accept", resolution: "reject" } }; const r = evaluateCandidate(spec, rel("Dune 2021 BluRay")); expect(r.decision).toBe("reject"); const ok = evaluateCandidate({ ...archival4k, unknownPolicy: "accept" }, rel("Dune 2021 BluRay")); expect(ok.decision).toBe("accept"); if (ok.decision === "accept") expect(ok.reasons).toContain("unknown_size_accepted_by_policy"); }); it("必需字幕:未知 hold,缺语言 reject,zh 可被 zh-Hans 满足", () => { const spec: SlotSpec = { name: "zh", hard: { requiredSubtitles: ["zh"] } }; expect(evaluateCandidate(spec, rel("Movie 2020 1080p")).decision).toBe("unknown_field"); expect(evaluateCandidate(spec, rel("Movie 2020 1080p 简体字幕")).decision).toBe("accept"); const r = evaluateCandidate({ name: "en", hard: { requiredSubtitles: ["en"] } }, rel("Movie 2020 简体字幕")); expect(r.decision).toBe("reject"); }); it("内容版本 / 仅二压 / 排除组 / 内容类型 / 最小体积", () => { const cutSpec: SlotSpec = { name: "c", hard: { cuts: ["Extended"] } }; expect(evaluateCandidate(cutSpec, rel("LOTR 2001 Extended 1080p")).decision).toBe("accept"); expect(evaluateCandidate(cutSpec, rel("LOTR 2001 1080p")).decision).toBe("reject"); const enc: SlotSpec = { name: "e", hard: { requireEncode: true } }; expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay REMUX")).decision).toBe("reject"); expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay x264")).decision).toBe("accept"); expect(evaluateCandidate(enc, rel("Movie 2020 1080p")).decision).toBe("unknown_field"); const ex: SlotSpec = { name: "x", hard: { excludeGroups: ["badgrp"] } }; expect(evaluateCandidate(ex, rel("Movie.2020.1080p.WEB-DL-BADGRP")).decision).toBe("reject"); const ct: SlotSpec = { name: "m", hard: { contentTypes: ["main"] } }; expect(evaluateCandidate(ct, rel("某综艺 第1期 加更")).decision).toBe("reject"); expect(evaluateCandidate(ct, rel("某综艺 第1期")).decision).toBe("unknown_field"); expect(evaluateCandidate(ct, rel("某综艺 第1期 正片")).decision).toBe("accept"); const min: SlotSpec = { name: "min", hard: { minSizeBytes: 2 * GiB } }; expect(evaluateCandidate(min, rel("Movie 2020 1080p 1GB")).decision).toBe("reject"); }); }); describe("升级规则", () => { const cur4kWeb = { resolution: "2160p" as const, source: "WEB-DL" as const }; it("WEB-DL → 蓝光二压:upgrade", () => { const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb); expect(r.decision).toBe("upgrade"); if (r.decision === "upgrade") expect(r.fromTier.source).toBe(2); }); it("同等级不反复更换", () => { const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"), cur4kWeb); expect(r).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] }); const remuxToEncode = evaluateCandidate( { ...archival4k, hard: { resolutions: ["2160p"] } }, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), { resolution: "2160p", source: "REMUX" }, ); expect(remuxToEncode).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] }); }); it("更低等级 / 未开启升级 / 当前资产字段未知 / 达到上限", () => { const hi = { resolution: "2160p" as const, source: "BluRay" as const }; expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL 20GB"), hi)).toMatchObject({ violations: [{ code: "lower_tier" }], }); expect( evaluateCandidate({ ...archival4k, upgrade: { enabled: false } }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb), ).toMatchObject({ violations: [{ code: "upgrade_disabled" }] }); expect( evaluateCandidate({ ...archival4k, upgrade: undefined }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb), ).toMatchObject({ decision: "reject" }); expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay 2GB"), {})).toMatchObject({ decision: "unknown_field", missing: ["current_asset"], }); expect( evaluateCandidate( { ...archival4k, upgrade: { enabled: true, ceilingTier: 2 } }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb, ), ).toMatchObject({ violations: [{ code: "at_ceiling" }] }); }); it("最小改进阈值 2 级:WEBRip → WEB-DL 不够", () => { const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, minTierSteps: 2 } }; const r = evaluateCandidate(spec, rel("Movie 2020 1080p WEB-DL"), { resolution: "1080p", source: "WEBRip" }); expect(r).toMatchObject({ violations: [{ code: "below_min_improvement" }] }); }); it("分辨率升级需显式允许", () => { const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, allowResolutionUpgrade: true } }; const cur = { resolution: "1080p" as const, source: "WEB-DL" as const }; expect(evaluateCandidate(spec, rel("M 2020 2160p WEB-DL"), cur).decision).toBe("upgrade"); expect(evaluateCandidate({ ...spec, upgrade: { enabled: true } }, rel("M 2020 2160p WEB-DL"), cur).decision).toBe( "reject", ); }); it("合格压制组名单:名单外压制不自动升级", () => { const spec: SlotSpec = { ...archival4k, upgrade: { enabled: true, encodeGroupAllowlist: ["CHD"] } }; expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb).decision).toBe("upgrade"); expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-LOWQ 20GB"), cur4kWeb)).toMatchObject({ violations: [{ code: "encode_not_qualified" }], }); }); it("自定义来源等级表", () => { expect( qualityTier( { source: "REMUX", resolution: "1080p" }, { name: "x", hard: {}, upgrade: { enabled: true, sourceTiers: { REMUX: 5 } } }, ), ).toEqual({ source: 5, resolution: 4, }); expect(qualityTier({})).toEqual({ source: -1, resolution: -1 }); }); }); describe("rankForSlot 排序", () => { it("可用在前;按等级再按软偏好", () => { const cands = [ rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"), rel("Dune.2021.1080p.BluRay-CHD 10GB"), rel("Dune.2021.2160p.BluRay.x265.HDR-SPARKS 35GB"), rel("Dune.2021.2160p.BluRay.x265.DV-CHD 25GB"), ]; const ranked = rankForSlot(archival4k, cands); expect(ranked.map((r) => r.candidate.releaseGroup?.name)).toEqual(["CHD", "SPARKS", "FLUX", "CHD"]); expect(ranked[3]!.evaluation.decision).toBe("reject"); }); it("compareRank 字典序", () => { expect(compareRank([1, 0], [0, 5])).toBeGreaterThan(0); expect(compareRank([1], [1, 0])).toBe(0); }); }); describe("Slot 状态机", () => { const s = (status: SlotState["status"], hasAsset = false): SlotState => ({ status, hasAsset }); it("主路径 empty→reserved→downloading→verifying→satisfied", () => { let st = s("empty"); st = transition(st, { type: "reserve" }); st = transition(st, { type: "download_started" }); st = transition(st, { type: "download_completed" }); st = transition(st, { type: "verify_passed", complete: true }); expect(st).toEqual({ status: "satisfied", hasAsset: true }); }); it("升级路径保留旧资产,取消回到 satisfied", () => { let st = transition(s("satisfied", true), { type: "upgrade_found" }); expect(st.status).toBe("upgrade_pending"); expect(transition(st, { type: "upgrade_cancelled" }).status).toBe("satisfied"); st = transition(st, { type: "reserve" }); expect(transition(st, { type: "release" })).toEqual({ status: "satisfied", hasAsset: true }); expect(transition(s("reserved"), { type: "release" }).status).toBe("empty"); }); it("失败/校验失败进 attention,解决后按是否有资产回退", () => { expect(transition(s("downloading"), { type: "download_failed" }).status).toBe("attention"); expect(transition(s("reserved"), { type: "download_failed" }).status).toBe("attention"); expect(transition(s("verifying"), { type: "verify_failed" }).status).toBe("attention"); expect(transition(s("attention"), { type: "attention_resolved", hasAsset: true }).status).toBe("satisfied"); expect(transition(s("attention"), { type: "attention_resolved", hasAsset: false }).status).toBe("empty"); expect(transition(s("attention"), { type: "reserve" }).status).toBe("reserved"); expect(transition(s("satisfied", true), { type: "asset_missing_confirmed" })).toEqual({ status: "attention", hasAsset: false, }); }); it("需处理经人工整理回到核验,再按实际资产落终态(manual_verify)", () => { const st = transition(s("attention"), { type: "manual_verify", reason: "manual_organize" }); expect(st).toEqual({ status: "verifying", hasAsset: false }); expect(transition(st, { type: "verify_passed", complete: true })).toEqual({ status: "satisfied", hasAsset: true }); for (const from of ["empty", "reserved", "downloading", "verifying", "satisfied", "upgrade_pending"] as const) expect(canTransition(s(from), { type: "manual_verify", reason: "x" }), from).toBe(false); }); it("部分覆盖回到 empty 但记住已有资产", () => { expect(transition(s("verifying"), { type: "verify_passed", complete: false })).toEqual({ status: "empty", hasAsset: true, }); }); it("非法迁移抛错", () => { const illegal: [SlotState["status"], Parameters[1]][] = [ ["empty", { type: "download_started" }], ["satisfied", { type: "reserve" }], ["empty", { type: "release" }], ["reserved", { type: "download_completed" }], ["empty", { type: "verify_passed", complete: true }], ["downloading", { type: "verify_failed" }], ["empty", { type: "upgrade_found" }], ["satisfied", { type: "upgrade_cancelled" }], ["empty", { type: "attention_resolved", hasAsset: false }], ["empty", { type: "asset_missing_confirmed" }], ["satisfied", { type: "download_failed" }], ]; for (const [from, ev] of illegal) { expect(() => transition(s(from), ev), `${from} ${ev.type}`).toThrow(SlotTransitionError); expect(canTransition(s(from), ev)).toBe(false); } expect(canTransition(s("empty"), { type: "reserve" })).toBe(true); }); }); describe("完成条件", () => { it("电影按内容版本", () => { expect(movieCompletion(undefined, [{ assetId: "a", verified: true }]).complete).toBe(true); expect(movieCompletion(undefined, [{ assetId: "a", verified: false }]).complete).toBe(false); const r = movieCompletion(["default", "Extended"], [{ assetId: "a", verified: true }]); expect(r).toEqual({ complete: false, missingCuts: ["Extended"], satisfiedBy: { default: "a" } }); }); it("剧集按 content_unit;season pack 需实际文件清单覆盖", () => { const req = ["e1", "e2", "e3"]; const pack = { assetId: "p", verified: true, fileUnits: ["e1", "e2"], claimedUnits: ["e1", "e2", "e3"] }; const r = unitCompletion(req, [pack], { totalKnown: true }); expect(r).toEqual({ complete: false, covered: ["e1", "e2"], missing: ["e3"], unverifiedClaims: ["e3"], keepTracking: true, }); const full = unitCompletion(req, [pack, { assetId: "x", verified: true, fileUnits: ["e3"] }], { totalKnown: true }); expect(full.complete).toBe(true); expect(full.keepTracking).toBe(false); expect( unitCompletion(req, [{ assetId: "x", verified: false, fileUnits: req }], { totalKnown: true }).complete, ).toBe(false); expect( unitCompletion(["e1"], [{ assetId: "x", verified: true, fileUnits: ["e1"] }], { totalKnown: false }), ).toMatchObject({ complete: true, keepTracking: true, }); expect(unitCompletion([], [], { totalKnown: false }).complete).toBe(false); }); it("require_distinct_asset 校验", () => { const g = { slots: [], requireDistinctAsset: true }; expect( checkDistinctAssets(g, [ { slotId: "a", assetId: "1" }, { slotId: "b", assetId: "1" }, ]), ).toEqual({ ok: false, sharedAssets: ["1"], }); expect( checkDistinctAssets(g, [ { slotId: "a", assetId: "1" }, { slotId: "b", assetId: "2" }, ]).ok, ).toBe(true); expect( checkDistinctAssets({ slots: [] }, [ { slotId: "a", assetId: "1" }, { slotId: "b", assetId: "1" }, ]).ok, ).toBe(true); }); });