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,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyObservation,
|
||||
DownloadTransitionError,
|
||||
detectNoProgress,
|
||||
isDownloadComplete,
|
||||
mapQbState,
|
||||
type ProgressSample,
|
||||
} from "../src";
|
||||
|
||||
const ctx = { pausedByThreshold: false };
|
||||
|
||||
describe("qB 状态映射", () => {
|
||||
it.each([
|
||||
["downloading", 0.5, "downloading"],
|
||||
["stalledDL", 0.5, "downloading"],
|
||||
["metaDL", 0, "downloading"],
|
||||
["forcedDL", 0.1, "downloading"],
|
||||
["queuedDL", 0, "queued"],
|
||||
["checkingResumeData", 0.4, "queued"],
|
||||
["uploading", 1, "completed"],
|
||||
["stalledUP", 1, "completed"],
|
||||
["pausedUP", 1, "completed"],
|
||||
["stoppedUP", 1, "completed"],
|
||||
["queuedUP", 1, "completed"],
|
||||
["checkingUP", 1, "downloading"],
|
||||
["uploading", 0.9, "downloading"],
|
||||
["error", 1, "error"],
|
||||
["missingFiles", 1, "missing_files"],
|
||||
["pausedDL", 0.3, "paused_by_user"],
|
||||
["stoppedDL", 0.3, "paused_by_user"],
|
||||
] as const)("%s @%s → %s", (state, progress, want) => {
|
||||
expect(mapQbState({ state, progress }, ctx).status).toBe(want);
|
||||
});
|
||||
it("阈值暂停与用户暂停由业务账本区分", () => {
|
||||
expect(mapQbState({ state: "pausedDL", progress: 0.3 }, { pausedByThreshold: true }).status).toBe(
|
||||
"paused_by_threshold",
|
||||
);
|
||||
});
|
||||
it("任务消失 → removed;未知状态沿用上次并标记不确定", () => {
|
||||
expect(mapQbState(null, ctx).status).toBe("removed");
|
||||
expect(mapQbState({ state: "weird", progress: 0 }, { ...ctx, previous: "downloading" })).toEqual({
|
||||
status: "downloading",
|
||||
uncertain: true,
|
||||
reason: "unknown_state:weird",
|
||||
});
|
||||
expect(mapQbState({ state: "weird", progress: 0 }, ctx).status).toBe("queued");
|
||||
});
|
||||
});
|
||||
|
||||
describe("完成判定", () => {
|
||||
it("error/missingFiles/校验中不算完成", () => {
|
||||
expect(isDownloadComplete({ state: "uploading", progress: 1 })).toBe(true);
|
||||
expect(isDownloadComplete({ state: "error", progress: 1 })).toBe(false);
|
||||
expect(isDownloadComplete({ state: "missingFiles", progress: 1 })).toBe(false);
|
||||
expect(isDownloadComplete({ state: "checkingUP", progress: 1 })).toBe(false);
|
||||
expect(isDownloadComplete({ state: "pausedUP", progress: 1, amountLeft: 10 })).toBe(false);
|
||||
expect(isDownloadComplete({ state: "downloading", progress: 0.99 })).toBe(false);
|
||||
expect(isDownloadComplete(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("状态机应用", () => {
|
||||
it("正常迁移与异常标记", () => {
|
||||
expect(applyObservation("queued", { state: "downloading", progress: 0.1 }, ctx)).toEqual({
|
||||
status: "downloading",
|
||||
changed: true,
|
||||
});
|
||||
expect(applyObservation("downloading", { state: "downloading", progress: 0.2 }, ctx).changed).toBe(false);
|
||||
expect(applyObservation("completed", { state: "downloading", progress: 0.9 }, ctx).anomaly).toBe(
|
||||
"completed_regressed",
|
||||
);
|
||||
expect(applyObservation("downloading", { state: "??", progress: 0.9 }, ctx)).toEqual({
|
||||
status: "downloading",
|
||||
changed: false,
|
||||
anomaly: "unknown_state",
|
||||
});
|
||||
});
|
||||
it("removed 是终态", () => {
|
||||
expect(() => applyObservation("removed", { state: "downloading", progress: 0 }, ctx)).toThrow(
|
||||
DownloadTransitionError,
|
||||
);
|
||||
expect(applyObservation("removed", null, ctx).status).toBe("removed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("无进展判定", () => {
|
||||
const min = 60_000;
|
||||
const s = (t: number, bytes: number, status: ProgressSample["status"] = "downloading"): ProgressSample => ({
|
||||
at: t * min,
|
||||
downloadedBytes: bytes,
|
||||
status,
|
||||
});
|
||||
it("持续下载中无增长 → 只记录证据", () => {
|
||||
const r = detectNoProgress([s(0, 100), s(30, 100), s(61, 100)], { thresholdMs: 60 * min });
|
||||
expect(r).toMatchObject({
|
||||
stalled: true,
|
||||
action: "record_evidence",
|
||||
stalledMs: 61 * min,
|
||||
lastProgressAt: 0,
|
||||
lastBytes: 100,
|
||||
});
|
||||
});
|
||||
it("有增长则清零;暂停/排队时间不计入", () => {
|
||||
expect(detectNoProgress([s(0, 100), s(50, 200), s(100, 200)], { thresholdMs: 60 * min }).stalled).toBe(false);
|
||||
const paused = detectNoProgress(
|
||||
[s(0, 100), s(10, 100, "paused_by_threshold"), s(200, 100, "paused_by_threshold"), s(210, 100)],
|
||||
{ thresholdMs: 60 * min },
|
||||
);
|
||||
expect(paused.stalled).toBe(false);
|
||||
expect(paused.excludedMs).toBe(210 * min);
|
||||
const ending = detectNoProgress([s(0, 1), s(100, 1), s(200, 1, "paused_by_user")], { thresholdMs: 60 * min });
|
||||
expect(ending.stalled).toBe(false);
|
||||
});
|
||||
it("样本不足", () => {
|
||||
expect(detectNoProgress([], { thresholdMs: 1 })).toMatchObject({ stalled: false, sampleCount: 0, action: "none" });
|
||||
expect(detectNoProgress([s(0, 1)], { thresholdMs: 0 }).stalled).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user