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

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