// @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>; 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 }); }); });