- 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]>
231 lines
8.4 KiB
TypeScript
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"]);
|
|
});
|
|
});
|