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,230 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user