Files
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- 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]>
2026-09-27 18:53:06 +08:00

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);
});
});