Files
UHD_Media_Tool/packages/domain/test/acquisition.test.ts
T
rootandClaude Fable 5.1 7ba712ea55 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]>
2026-09-27 18:11:29 +08:00

231 lines
8.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
checkBudget,
DEFAULT_PRIORITY_ORDER,
determineMode,
evaluateCacheVolume,
type InflightTask,
type ModeInput,
priorityOf,
sortByPriority,
validatePriorityOrder,
} from "../src";
const GiB = 1024 ** 3;
const base: ModeInput = {
entity: null,
releaseUnits: [],
satisfiedUnits: [],
slotSatisfied: false,
now: "2026-09-27T00:00:00Z",
newTitleWindowDays: 90,
};
describe("三种模式判定", () => {
it("新作品按发行日期,不按站点上传日期", () => {
expect(determineMode({ ...base, firstReleaseDate: "2026-08-01" })).toEqual({
mode: "new_title",
reason: "released_within_window",
});
expect(determineMode({ ...base, firstReleaseDate: "1999-03-31" }).reason).toBe("not_new_release");
expect(determineMode(base).reason).toBe("release_date_unknown");
expect(determineMode({ ...base, firstReleaseDate: "bad" }).reason).toBe("release_date_unknown");
expect(determineMode({ ...base, firstReleaseDate: "2027-01-01" }).reason).toBe("not_yet_released");
});
it("追更补缺:覆盖未满足的单元", () => {
const tv = { ...base, entity: { tracked: true, kind: "tv" as const } };
expect(determineMode({ ...tv, releaseUnits: ["e1", "e2"], satisfiedUnits: ["e1"] }).mode).toBe("catch_up");
expect(determineMode({ ...tv, releaseUnits: ["e1"], satisfiedUnits: ["e1"] }).reason).toBe("already_satisfied");
expect(determineMode({ ...tv, releaseUnits: ["e1"], satisfiedUnits: ["e1"], upgradeCandidate: true }).mode).toBe(
"upgrade",
);
expect(determineMode({ ...tv }).reason).toBe("no_units_resolved");
});
it("已有电影:Slot 缺口/更优版本 → 升级;只登记的作品不自动下载", () => {
const mv = { ...base, entity: { tracked: true, kind: "movie" as const } };
expect(determineMode(mv)).toEqual({ mode: "upgrade", reason: "slot_gap" });
expect(determineMode({ ...mv, slotSatisfied: true }).mode).toBeNull();
expect(determineMode({ ...mv, slotSatisfied: true, upgradeCandidate: true }).reason).toBe("better_version");
expect(determineMode({ ...mv, entity: { tracked: false, kind: "movie" } }).reason).toBe("not_tracked");
});
});
describe("优先级", () => {
const items = [
{ id: "u", priorityClass: priorityOf("upgrade"), enqueuedAt: "2026-01-01T00:00:00Z" },
{ id: "n", priorityClass: priorityOf("new_title"), enqueuedAt: "2026-01-01T00:00:00Z" },
{ id: "c2", priorityClass: priorityOf("catch_up"), enqueuedAt: "2026-01-02T00:00:00Z" },
{ id: "c1", priorityClass: priorityOf("catch_up"), enqueuedAt: "2026-01-01T00:00:00Z" },
{ id: "i", priorityClass: priorityOf("upgrade", true), enqueuedAt: "2026-01-03T00:00:00Z" },
];
it("默认 即时 > 追更 > 新作品 > 升级,同级先进先出", () => {
expect(sortByPriority(items).map((x) => x.id)).toEqual(["i", "c1", "c2", "n", "u"]);
expect(DEFAULT_PRIORITY_ORDER[0]).toBe("immediate");
});
it("可配置", () => {
expect(sortByPriority(items, ["immediate", "new_title", "catch_up", "upgrade"]).map((x) => x.id)).toEqual([
"i",
"n",
"c1",
"c2",
"u",
]);
});
it("配置必须完整", () => {
expect(() => validatePriorityOrder(["immediate", "catch_up"])).toThrow();
expect(() => validatePriorityOrder(["immediate", "catch_up", "catch_up", "upgrade"])).toThrow();
});
});
describe("预算", () => {
const budget = { maxNewTitlesPerDay: 3, maxBytesPerDay: 100 * GiB, maxConcurrent: 2 };
it("放行", () => {
const r = checkBudget(
budget,
{ newTitlesToday: 2, bytesToday: 10 * GiB, activeDownloads: 1 },
{ countsAsNewTitle: true, bytes: GiB },
);
expect(r).toEqual({ decision: "allow", violations: [], unknown: [] });
});
it("超限列出全部违规,预留字节计入", () => {
const r = checkBudget(
budget,
{ newTitlesToday: 3, bytesToday: 60 * GiB, reservedBytesToday: 30 * GiB, activeDownloads: 2 },
{ countsAsNewTitle: true, bytes: 20 * GiB },
);
expect(r.decision).toBe("deny");
expect(r.violations.map((v) => v.kind)).toEqual(["new_titles", "bytes", "concurrency"]);
expect(r.violations[1]!.current).toBe(90 * GiB);
});
it("体积未知 → unknown,不放行;追更不占新作品额度", () => {
const r = checkBudget(
budget,
{ newTitlesToday: 3, bytesToday: 0, activeDownloads: 0 },
{ countsAsNewTitle: false },
);
expect(r).toEqual({ decision: "unknown", violations: [], unknown: ["bytes"] });
expect(
checkBudget({}, { newTitlesToday: 99, bytesToday: 0, activeDownloads: 99 }, { countsAsNewTitle: true }).decision,
).toBe("allow");
});
});
describe("缓存卷硬阈值", () => {
const cfg = { hardPct: 90, resumePct: 85 };
const vol = (usedPct: number) => ({ volumeId: "v1", totalBytes: 1000 * GiB, usedBytes: usedPct * 10 * GiB });
const task = (over: Partial<InflightTask>): InflightTask => ({
taskId: "t",
volumeId: "v1",
sizeBytes: 50 * GiB,
onDiskBytes: 0,
managed: true,
status: "downloading",
...over,
});
it("读不到磁盘状态 → 保守阻止", () => {
const r = evaluateCacheVolume({
volume: null,
config: cfg,
inflight: [],
reservations: [],
pausedByThreshold: false,
});
expect(r.decision).toBe("block");
expect(r.reasons).toEqual(["volume_state_unknown"]);
});
it("配置校验", () => {
expect(() =>
evaluateCacheVolume({
volume: vol(1),
config: { hardPct: 80, resumePct: 85 },
inflight: [],
reservations: [],
pausedByThreshold: false,
}),
).toThrow();
});
it("达到硬阈值 → 暂停纳管的下载中任务", () => {
const r = evaluateCacheVolume({
volume: vol(91),
config: cfg,
inflight: [
task({ taskId: "a" }),
task({ taskId: "b", managed: false }),
task({ taskId: "c", status: "paused" }),
task({ taskId: "d", volumeId: "v2" }),
],
reservations: [],
pausedByThreshold: false,
});
expect(r.decision).toBe("pause_managed");
expect(r.pauseTaskIds).toEqual(["a"]);
expect(r.unmanagedGrowthRisk).toBe(true);
});
it("在途剩余需求 + 预留:已预分配不重复扣,已提交计划不重复算预留", () => {
const r = evaluateCacheVolume({
volume: vol(70),
config: cfg,
inflight: [
task({ taskId: "a", planId: "p1", sizeBytes: 100 * GiB, onDiskBytes: 100 * GiB }),
task({ taskId: "b", planId: "p2", sizeBytes: 100 * GiB, onDiskBytes: 40 * GiB }),
task({ taskId: "c", status: "completed", sizeBytes: 500 * GiB }),
],
reservations: [
{ planId: "p2", volumeId: "v1", bytes: 100 * GiB },
{ planId: "p3", volumeId: "v1", bytes: 50 * GiB },
{ planId: "p4", volumeId: "v2", bytes: 999 * GiB },
],
request: { bytes: 60 * GiB },
pausedByThreshold: false,
});
expect(r.pendingBytes).toBe(60 * GiB);
expect(r.reservedBytes).toBe(50 * GiB);
expect(r.projectedPct).toBeCloseTo(87);
expect(r.decision).toBe("allow");
const over = evaluateCacheVolume({
volume: vol(70),
config: cfg,
inflight: [task({ sizeBytes: 150 * GiB })],
reservations: [],
request: { bytes: 60 * GiB },
pausedByThreshold: false,
});
expect(over.decision).toBe("block");
expect(over.reasons).toContain("projected_over_threshold");
});
it("体积未知 → 阻止新提交", () => {
const r = evaluateCacheVolume({
volume: vol(10),
config: cfg,
inflight: [task({ sizeBytes: undefined, taskId: "m" })],
reservations: [],
request: {},
pausedByThreshold: false,
});
expect(r.decision).toBe("block");
expect(r.reasons).toEqual(["request_size_unknown", "inflight_size_unknown"]);
expect(r.unknownSizeTaskIds).toEqual(["m"]);
});
it("恢复水位滞回:85%~90% 之间保持暂停,≤85% 恢复阈值暂停的任务", () => {
const mid = evaluateCacheVolume({
volume: vol(87),
config: cfg,
inflight: [],
reservations: [],
pausedByThreshold: true,
});
expect(mid.decision).toBe("block");
expect(mid.resumeThresholdPaused).toBe(false);
const low = evaluateCacheVolume({
volume: vol(80),
config: cfg,
inflight: [],
reservations: [],
pausedByThreshold: true,
});
expect(low.decision).toBe("allow");
expect(low.resumeThresholdPaused).toBe(true);
expect(low.reasons).toEqual(["below_resume_watermark"]);
});
});