125 lines
4.8 KiB
TypeScript
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();
|
|
});
|
|
});
|