117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
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("serializes a new edit behind an in-flight save", async () => {
|
|
vi.useFakeTimers();
|
|
let resolveFirst: ((value: AutosaveResponse) => void) | undefined;
|
|
const saves: string[] = [];
|
|
const coordinator = new AutosaveCoordinator({
|
|
initialVersion: 4,
|
|
debounceMs: 10,
|
|
createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }),
|
|
save: async ({ value, expectedVersion }) => {
|
|
saves.push(`${value}:${expectedVersion}`);
|
|
if (saves.length === 1) {
|
|
return new Promise<AutosaveResponse>((resolve) => {
|
|
resolveFirst = resolve;
|
|
});
|
|
}
|
|
return { status: "saved", version: expectedVersion + 1 };
|
|
},
|
|
});
|
|
coordinator.schedule("แรก");
|
|
await advance(10);
|
|
coordinator.schedule("ล่าสุด");
|
|
await advance(10);
|
|
expect(saves).toEqual(["แรก:4"]);
|
|
resolveFirst?.({ status: "saved", version: 5 });
|
|
await advance(0);
|
|
expect(saves).toEqual(["แรก:4", "ล่าสุด:5"]);
|
|
});
|
|
|
|
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"] });
|
|
});
|
|
});
|