- 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]>
120 lines
4.6 KiB
TypeScript
120 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|