- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue, cut, release group suffix fallback, dir+file merge, safe episode offset) - identity: title normalization (NFKC, trad->simp map, season suffix) and evidence-level candidate ranking (year tolerance ±1, no probabilities) - slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine, movie/unit completion (season pack must be covered by actual file list) - acquisition: new_title/catch_up/upgrade modes, priority order, budget, cache volume hard threshold with resume watermark (no double counting) - path: immutable path bindings, restricted naming templates, sanitize, conflict detection, downloader<->host path mapping - organize: planOrganize with sidecar pairing, sample exclusion, existing target policy, crossDevice marking, H&R -> conflict for move - download-state: qB state mapping, completion, no-progress evidence 230 tests, ~97% statement coverage. Co-Authored-By: Claude Fable 5.1 <[email protected]>
293 lines
14 KiB
TypeScript
293 lines
14 KiB
TypeScript
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";
|
|
|
|
const GiB = 1024 ** 3;
|
|
const rel = (t: string, over: Partial<ParsedRelease> = {}): 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).toEqual({ 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"), {})).toEqual({
|
|
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("部分覆盖回到 empty 但记住已有资产", () => {
|
|
expect(transition(s("verifying"), { type: "verify_passed", complete: false })).toEqual({
|
|
status: "empty",
|
|
hasAsset: true,
|
|
});
|
|
});
|
|
it("非法迁移抛错", () => {
|
|
const illegal: [SlotState["status"], Parameters<typeof transition>[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);
|
|
});
|
|
});
|