Files
buzz-sheet/lib/editor/manual-save.test.ts
T
2026-10-10 02:56:59 +07:00

125 lines
4.8 KiB
TypeScript

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<SaveResponse> => {
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<SaveResponse>([
{ 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();
});
});