import { afterEach, describe, expect, it, vi } from "vitest"; import { ManualSaveCoordinator, type SaveResponse, type SaveStatus } from "./manual-save"; afterEach(() => vi.useRealTimers()); function harness(responses: SaveResponse[] = [{ status: "saved", version: 2 }]) { const statuses: SaveStatus[] = []; const save = vi.fn(async (payload: { value: string; expectedVersion: number }) => responses.shift() ?? { status: "saved" as const, version: payload.expectedVersion + 1 }, ); const coordinator = new ManualSaveCoordinator({ initialVersion: 1, createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), save, onStatus: (status) => statuses.push(status), }); return { coordinator, save, statuses }; } describe("ManualSaveCoordinator", () => { it("does not save staged edits until explicitly requested, then saves the latest", async () => { vi.useFakeTimers(); const { coordinator, save } = harness(); coordinator.stage("first"); await vi.advanceTimersByTimeAsync(10_000); coordinator.stage("latest"); await vi.advanceTimersByTimeAsync(10_000); expect(save).not.toHaveBeenCalled(); await coordinator.flush(); expect(save).toHaveBeenCalledExactlyOnceWith({ value: "latest", expectedVersion: 1 }); await coordinator.flush(); expect(save).toHaveBeenCalledTimes(1); }); it("blocks duplicate clicks and leaves edits during a save for the next click", async () => { vi.useFakeTimers(); let resolveFirst!: (result: SaveResponse) => void; const save = vi.fn(async (payload: { value: string; expectedVersion: number }): Promise => { if (save.mock.calls.length === 1) return new Promise((resolve) => { resolveFirst = resolve; }); return { status: "saved", version: payload.expectedVersion + 1 }; }); const coordinator = new ManualSaveCoordinator({ initialVersion: 4, createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }), save, }); coordinator.stage("first"); const first = coordinator.flush(); await coordinator.flush(); coordinator.stage("latest"); resolveFirst({ status: "saved", version: 5 }); await first; await vi.advanceTimersByTimeAsync(10_000); expect(save).toHaveBeenCalledTimes(1); await coordinator.flush(); expect(save).toHaveBeenLastCalledWith({ value: "latest", expectedVersion: 5 }); }); it("uses the latest version and cancels invalid pending drafts", async () => { const { coordinator, save } = harness(); coordinator.stage("invalid"); coordinator.cancelPending(); await coordinator.flush(); expect(save).not.toHaveBeenCalled(); coordinator.updateVersion(8); coordinator.updateVersion(3); coordinator.stage("valid"); await coordinator.flush(); expect(save).toHaveBeenCalledWith({ value: "valid", expectedVersion: 8 }); }); it("requires another click to retry a failed save", async () => { vi.useFakeTimers(); const { coordinator, save, statuses } = harness([ { status: "transient-error", message: "network" }, { status: "saved", version: 2 }, ]); coordinator.stage("draft"); await coordinator.flush(); await vi.advanceTimersByTimeAsync(10_000); expect(save).toHaveBeenCalledTimes(1); expect(statuses.at(-1)).toEqual({ state: "error", message: "network" }); await coordinator.flush(); expect(save).toHaveBeenCalledTimes(2); expect(statuses.at(-1)?.state).toBe("saved"); }); it.each([ { status: "conflict", currentVersion: 8 }, { status: "validation-error", issues: ["title"] }, ])("never automatically retries $status", async (response) => { vi.useFakeTimers(); const { coordinator, save, statuses } = harness([response]); coordinator.stage("draft"); await coordinator.flush(); await vi.advanceTimersByTimeAsync(10_000); expect(save).toHaveBeenCalledTimes(1); expect(statuses.at(-1)?.state).toBe(response.status === "conflict" ? "conflict" : "error"); }); it("preserves the latest draft when an in-flight save fails", async () => { let rejectFirst!: (error: Error) => void; const { coordinator, save } = harness(); save.mockImplementationOnce(() => new Promise((_, reject) => { rejectFirst = reject; })); coordinator.stage("old"); const first = coordinator.flush(); coordinator.stage("new"); rejectFirst(new Error("network")); await first; await coordinator.flush(); expect(save).toHaveBeenLastCalledWith({ value: "new", expectedVersion: 1 }); }); it("does not save after disposal", async () => { const { coordinator, save } = harness(); coordinator.stage("draft"); coordinator.dispose(); await coordinator.flush(); coordinator.stage("another"); await coordinator.flush(); expect(save).not.toHaveBeenCalled(); }); });