- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row 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/index.ts";
|
|
|
|
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);
|
|
});
|
|
});
|