162 lines
7.3 KiB
TypeScript
162 lines
7.3 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act, createElement, useEffect, useState } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { serializeLocalDraft } from "@/lib/editor/local-draft";
|
|
|
|
const mocks = vi.hoisted(() => ({ toast: Object.assign(vi.fn<(message: string, options: { action: { onClick: () => void } }) => void>(), { error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }), refresh: vi.fn() }));
|
|
vi.mock("sonner", () => ({ toast: mocks.toast }));
|
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: mocks.refresh }) }));
|
|
import { useManualSave } from "./use-manual-save";
|
|
|
|
type Value = { title: string };
|
|
let root: Root;
|
|
let container: HTMLDivElement;
|
|
let result: ReturnType<typeof useManualSave<Value>>;
|
|
let setValue: (value: Value) => void;
|
|
let setValid: (valid: boolean) => void;
|
|
let source: EventTarget;
|
|
const persist = vi.fn<(value: Value, version: number, intent?: { overwrite?: boolean }) => Promise<{ status: string; version?: number; currentVersion?: number }>>();
|
|
function Harness({ storage = false }: { storage?: boolean }) {
|
|
const [value, update] = useState({ title: "initial" });
|
|
const [valid, validate] = useState(true);
|
|
const latest = useManualSave({ value, valid, initialVersion: 1, persist, guideId: "guide", scope: "overview",
|
|
...(storage ? { storageKey: "test-draft", localDraft: value, parseLocalDraft: (draft: unknown) => draft as Value, restoreLocalDraft: update } : {}),
|
|
});
|
|
useEffect(() => {
|
|
setValue = update;
|
|
setValid = validate;
|
|
result = latest;
|
|
}, [latest, update, validate]);
|
|
return createElement("button", { disabled: !latest.canSave, onClick: latest.saveNow }, latest.state);
|
|
}
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
source = new EventTarget();
|
|
vi.stubGlobal("EventSource", vi.fn(function () { return Object.assign(source, { close: vi.fn() }); }));
|
|
vi.clearAllMocks();
|
|
persist.mockResolvedValue({ status: "saved", version: 2 });
|
|
window.localStorage.clear();
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
async function render(storage = false) { await act(async () => root.render(createElement(Harness, { storage }))); }
|
|
async function edit(title: string) { await act(async () => setValue({ title })); }
|
|
async function wait() { await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); }
|
|
|
|
describe("useManualSave", () => {
|
|
it("never saves edits until a click", async () => {
|
|
await render();
|
|
expect(result.canSave).toBe(false);
|
|
await edit("edited");
|
|
await wait();
|
|
expect(persist).not.toHaveBeenCalled();
|
|
expect(result.state).toBe("unsaved");
|
|
await act(async () => container.querySelector("button")!.click());
|
|
expect(persist).toHaveBeenCalledExactlyOnceWith({ title: "edited" }, 1, undefined);
|
|
expect(result.state).toBe("saved");
|
|
expect(result.canSave).toBe(false);
|
|
});
|
|
it("captures clicked data, blocks duplicate clicks, and does not save later edits", async () => {
|
|
let finish!: (value: { status: string; version: number }) => void;
|
|
persist.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
|
|
await render();
|
|
await edit("clicked");
|
|
await act(async () => { result.saveNow(); result.saveNow(); });
|
|
await edit("later");
|
|
await act(async () => finish({ status: "saved", version: 2 }));
|
|
await wait();
|
|
expect(persist).toHaveBeenCalledTimes(1);
|
|
expect(result.state).toBe("unsaved");
|
|
await act(async () => result.saveNow());
|
|
expect(persist).toHaveBeenLastCalledWith({ title: "later" }, 2, undefined);
|
|
});
|
|
it("blocks invalid input and requires a click after correcting it", async () => {
|
|
await render();
|
|
await act(async () => { setValue({ title: "invalid" }); setValid(false); });
|
|
await act(async () => result.saveNow());
|
|
expect(result.state).toBe("incomplete");
|
|
await act(async () => setValid(true));
|
|
await wait();
|
|
expect(persist).not.toHaveBeenCalled();
|
|
await act(async () => result.saveNow());
|
|
expect(persist).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("keeps errors for explicit retry", async () => {
|
|
persist.mockResolvedValueOnce({ status: "error" });
|
|
await render();
|
|
await edit("draft");
|
|
await act(async () => result.saveNow());
|
|
await wait();
|
|
expect(result.state).toBe("error");
|
|
expect(persist).toHaveBeenCalledTimes(1);
|
|
await act(async () => result.saveNow());
|
|
expect(result.state).toBe("saved");
|
|
});
|
|
it("blocks ordinary saving after a conflict but allows explicit overwrite", async () => {
|
|
persist.mockResolvedValueOnce({ status: "conflict", currentVersion: 8 });
|
|
await render();
|
|
await edit("draft");
|
|
await act(async () => result.saveNow());
|
|
expect(result.state).toBe("conflict");
|
|
await act(async () => result.saveNow());
|
|
await wait();
|
|
expect(persist).toHaveBeenCalledTimes(1);
|
|
await act(async () => result.overwrite());
|
|
expect(persist).toHaveBeenLastCalledWith({ title: "draft" }, 8, { overwrite: true });
|
|
});
|
|
it("restores local drafts without saving and clears them only after clicking save", async () => {
|
|
window.localStorage.setItem("test-draft", serializeLocalDraft({ title: "restored" }, 1));
|
|
await render(true);
|
|
expect(result.canSave).toBe(false);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
|
const options = mocks.toast.mock.calls[0][1];
|
|
await act(async () => options.action.onClick());
|
|
await wait();
|
|
expect(result.state).toBe("unsaved");
|
|
expect(window.localStorage.getItem("test-draft")).not.toBeNull();
|
|
expect(persist).not.toHaveBeenCalled();
|
|
await act(async () => result.saveNow());
|
|
expect(persist).toHaveBeenCalledWith({ title: "restored" }, 1, undefined);
|
|
expect(window.localStorage.getItem("test-draft")).toBeNull();
|
|
});
|
|
it("warns before leaving until the changes are saved", async () => {
|
|
await render();
|
|
await edit("draft");
|
|
const unsavedEvent = new Event("beforeunload", { cancelable: true });
|
|
window.dispatchEvent(unsavedEvent);
|
|
expect(unsavedEvent.defaultPrevented).toBe(true);
|
|
await act(async () => result.saveNow());
|
|
const savedEvent = new Event("beforeunload", { cancelable: true });
|
|
window.dispatchEvent(savedEvent);
|
|
expect(savedEvent.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("keeps edited data on live conflicts and uses other sections' updated versions", async () => {
|
|
await render();
|
|
await edit("draft");
|
|
await act(async () => source.dispatchEvent(new MessageEvent("invalidation", {
|
|
data: JSON.stringify({ type: "admin.updated", id: "guide", version: 7, scope: "weapon" }),
|
|
})));
|
|
expect(result.state).toBe("unsaved");
|
|
await act(async () => source.dispatchEvent(new MessageEvent("invalidation", {
|
|
data: JSON.stringify({ type: "admin.updated", id: "guide", version: 8, scope: "overview" }),
|
|
})));
|
|
await wait();
|
|
expect(result.state).toBe("conflict");
|
|
expect(mocks.refresh).not.toHaveBeenCalled();
|
|
expect(persist).not.toHaveBeenCalled();
|
|
await act(async () => result.overwrite());
|
|
expect(persist).toHaveBeenCalledWith({ title: "draft" }, 8, { overwrite: true });
|
|
});
|
|
|
|
});
|