feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)
- 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]>
This commit is contained in:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -0,0 +1,292 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user