import { afterEach, describe, expect, it, vi } from "vitest"; import { AutosaveCoordinator, type AutosaveResponse, type AutosaveStatus, } from "./autosave"; afterEach(() => { vi.useRealTimers(); }); async function advance(milliseconds: number) { vi.advanceTimersByTime(milliseconds); await Promise.resolve(); await Promise.resolve(); } function harness( responses: AutosaveResponse[] = [ { status: "saved", version: 2 }, ], ) { const saves: Array<{ value: string; expectedVersion: number }> = []; const statuses: AutosaveStatus[] = []; const save = vi.fn(async (payload: { value: string; expectedVersion: number }) => { saves.push(payload); return responses.shift() ?? { status: "saved" as const, version: payload.expectedVersion + 1 }; }); const coordinator = new AutosaveCoordinator({ initialVersion: 1, createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), save, onStatus: (status) => statuses.push(status), debounceMs: 750, retryBaseMs: 100, retryCapMs: 400, maxRetries: 2, }); return { coordinator, save, saves, statuses }; } describe("AutosaveCoordinator", () => { it("debounces edits and sends only the latest draft", async () => { vi.useFakeTimers(); const { coordinator, save, saves } = harness(); coordinator.schedule("หนึ่ง"); await advance(500); coordinator.schedule("สอง"); await advance(749); expect(save).not.toHaveBeenCalled(); await advance(1); expect(saves).toEqual([{ value: "สอง", expectedVersion: 1 }]); }); it("waits for the idle delay before saving an edit made during a request", async () => { vi.useFakeTimers(); let resolveFirst: ((value: AutosaveResponse) => void) | undefined; const saves: string[] = []; const coordinator = new AutosaveCoordinator({ initialVersion: 4, debounceMs: 100, createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), save: async ({ value, expectedVersion }) => { saves.push(`${value}:${expectedVersion}`); if (saves.length === 1) { return new Promise((resolve) => { resolveFirst = resolve; }); } return { status: "saved", version: expectedVersion + 1 }; }, }); coordinator.schedule("แรก"); await advance(100); coordinator.schedule("ล่าสุด"); await advance(50); expect(saves).toEqual(["แรก:4"]); resolveFirst?.({ status: "saved", version: 5 }); await advance(0); expect(saves).toEqual(["แรก:4"]); await advance(49); expect(saves).toEqual(["แรก:4"]); await advance(1); expect(saves).toEqual(["แรก:4", "ล่าสุด:5"]); }); it("uses an externally updated version and drops invalid pending drafts", async () => { vi.useFakeTimers(); const { coordinator, saves } = harness(); coordinator.updateVersion(8); coordinator.schedule("invalid"); coordinator.cancelPending(); await advance(750); expect(saves).toEqual([]); coordinator.schedule("valid"); await advance(750); expect(saves).toEqual([{ value: "valid", expectedVersion: 8 }]); }); it("does not save the same draft twice when it is queued during a request", async () => { vi.useFakeTimers(); let resolveFirst: ((value: AutosaveResponse) => void) | undefined; const save = vi.fn(() => new Promise((resolve) => { resolveFirst = resolve; })); const coordinator = new AutosaveCoordinator({ initialVersion: 1, debounceMs: 100, createPayload: (draft: string) => draft, save, }); coordinator.schedule("same"); await advance(100); coordinator.schedule("same"); resolveFirst?.({ status: "saved", version: 2 }); await advance(100); expect(save).toHaveBeenCalledTimes(1); }); it("retries only transient failures with capped exponential delays", async () => { vi.useFakeTimers(); const { coordinator, save } = harness([ { status: "transient-error", message: "network" }, { status: "transient-error", message: "network" }, { status: "saved", version: 2 }, ]); coordinator.schedule("ร่าง"); await advance(750); expect(save).toHaveBeenCalledTimes(1); await advance(99); expect(save).toHaveBeenCalledTimes(1); await advance(1); expect(save).toHaveBeenCalledTimes(2); await advance(200); expect(save).toHaveBeenCalledTimes(3); }); it("stops on conflicts and validation errors", async () => { vi.useFakeTimers(); const conflict = harness([{ status: "conflict", currentVersion: 8 }]); conflict.coordinator.schedule("ชนกัน"); await advance(750); expect(conflict.save).toHaveBeenCalledTimes(1); expect(conflict.statuses.at(-1)).toEqual({ state: "conflict", currentVersion: 8 }); const invalid = harness([{ status: "validation-error", issues: ["title"] }]); invalid.coordinator.schedule("ผิด"); await advance(750); expect(invalid.save).toHaveBeenCalledTimes(1); expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] }); }); });