import { act, cleanup, renderHook } from "@testing-library/react" import type React from "react" import { afterEach, describe, expect, it, vi } from "vitest" import { DictionaryProvider } from "@/hooks/use-dictionary" import type { Dictionary } from "@/lib/i18n/dictionaries" import en from "@/lib/i18n/dictionaries/en.json" import type { HistoryEntryInfo, HistoryList, McpSync, } from "@/packages/mcp-server/shell/mcp-sync-core" import { buildVersions, useNodeVersions, } from "@/packages/mcp-server/shell/node-versions-source" import { versionLabel } from "@/packages/mcp-server/shell/versions-panel" import { useCanvasStore } from "@/stores/canvas-store" // The shell's version cards come from the MCP server's History (GET // /api/history), read through the sync; restores go back through it afterEach(cleanup) const toasts = vi.hoisted(() => ({ success: vi.fn() })) vi.mock("sonner", () => ({ toast: toasts })) const cell = (id: string, label: string) => `` const doc = (cells: string, pageId = "p1") => `${cells}` const A = doc(cell("a", "A")) const AB = doc(cell("a", "A") + cell("b", "B")) const ABC = doc(cell("a", "A") + cell("b", "B") + cell("c", "C")) function entry( id: number, xml: string, extra: Partial = {}, ): HistoryEntryInfo { return { id, index: id, svg: "", xml, source: null, firstId: id, ...extra, } } const list = ( entries: HistoryEntryInfo[], currentId: number | null, ): HistoryList => ({ entries, stateId: "S1", currentId }) describe("buildVersions", () => { it("makes one numbered version per History entry, with what changed since the one before", () => { const { versions, onCanvasId } = buildVersions( list([entry(0, A), entry(1, AB, { svg: "data:AB" })], 1), new Map(), new Map(), ) expect(versions.map((v) => [v.id, v.number])).toEqual([ ["0", 1], ["1", 2], ]) expect(versions[0].fromScratch).toBe(true) expect(versions[0].summary.shapesAdded).toBe(1) expect(versions[1].fromScratch).toBe(false) expect(versions[1].summary.shapesAdded).toBe(1) expect(versions[1].pageId).toBe("p1") expect(versions[1].beforeXml).toBe(A) expect(versions[1].svg).toBe("data:AB") expect(onCanvasId).toBe("1") }) it("leaves the blank page out, and counts the first drawing from it", () => { // The server keeps the blank page the tab showed when the first // write replaces it; a page the user named is a version const blank = `` const named = blank.replace('name="Page-1"', 'name="Plan"') const { versions, onCanvasId } = buildVersions( list([entry(0, blank), entry(1, A), entry(2, named)], 2), new Map(), new Map(), ) expect(versions.map((v) => [v.id, v.number])).toEqual([ ["1", 1], ["2", 2], ]) expect(versions[0].fromScratch).toBe(true) expect(onCanvasId).toBe("2") // Blank on the canvas: no version is expect( buildVersions(list([entry(0, blank)], 0), new Map(), new Map()), ).toEqual({ versions: [], onCanvasId: "0" }) }) it("folds a restore's copy into the version it copies, where that one is", () => { // History: A, AB, A again (restored); the canvas shows A const { versions, onCanvasId } = buildVersions( list( [ entry(0, A), entry(1, AB), entry(2, A, { firstId: 0, svg: "data:A", source: "restore", }), ], 2, ), new Map(), new Map(), ) expect(versions.map((v) => v.id)).toEqual(["0", "1"]) // The copy's picture stands in for the first copy's missing one expect(versions[0].svg).toBe("data:A") expect(onCanvasId).toBe("0") }) it("keeps the numbers once given, and gives new versions the next", () => { const numbers = new Map() const changes = new Map() buildVersions(list([entry(0, A), entry(1, AB)], 1), numbers, changes) // The oldest entry dropped out of the server's buffer const { versions } = buildVersions( list([entry(1, AB), entry(2, ABC)], 2), numbers, changes, ) expect(versions.map((v) => v.number)).toEqual([2, 3]) }) it("counts a write from the state it replaced, not from the card before, after a restore", () => { // History: A, AB, ABC, A again (restored), AD: the AI drew D on A const AD = doc(cell("a", "A") + cell("d", "D")) const { versions } = buildVersions( list( [ entry(0, A), entry(1, AB), entry(2, ABC), entry(3, A, { firstId: 0, source: "restore" }), entry(4, AD), ], 4, ), new Map(), new Map(), ) expect(versions.map((v) => v.id)).toEqual(["0", "1", "2", "4"]) const latest = versions[3] expect(latest.beforeXml).toBe(A) expect(latest.summary.shapesAdded).toBe(1) expect(latest.summary.shapesRemoved).toBe(0) expect(latest.beforeId).toBe("0") // The first drawing replaced nothing a card shows expect(versions[0].beforeId).toBeNull() expect(versions[1].beforeId).toBe("0") }) it("restores the newest copy of a version: a later copy may carry the user's page settings", () => { // The user set a background on A (kept as a "user" entry, the same // content to the server), then the AI added B const yellow = A.replace( "", '', ) const { versions } = buildVersions( list( [ entry(0, A), entry(1, yellow, { firstId: 0, source: "user" }), entry(2, AB), ], 2, ), new Map(), new Map(), ) expect(versions).toHaveLength(2) expect(versions[0].id).toBe("0") expect(versions[0].entryId).toBe(1) expect(versions[0].xml).toBe(yellow) expect(versions[0].source).toBeNull() }) it("shows a clear of the canvas as a version, hiding only the blank page before any drawing", () => { const blank = `` // The server folds the cleared page into the first blank (same // content); then AB is restored const entries = [ entry(0, blank), entry(1, A), entry(2, AB), entry(3, blank, { firstId: 0 }), entry(4, AB, { firstId: 2, source: "restore" }), ] const cleared = buildVersions( list(entries.slice(0, 4), 3), new Map(), new Map(), ) expect(cleared.versions.map((v) => v.id)).toEqual(["1", "2", "3"]) expect(cleared.onCanvasId).toBe("3") expect(cleared.versions[2].beforeId).toBe("2") expect(cleared.versions[2].summary.shapesRemoved).toBe(2) const back = buildVersions(list(entries, 4), new Map(), new Map()) expect(back.versions.map((v) => v.id)).toEqual(["1", "2", "3"]) expect(back.onCanvasId).toBe("2") // A second clear is a version of its own; a restore of a clear is // a copy of the newest one const twice = buildVersions( list( [ ...entries, entry(5, blank, { firstId: 0 }), entry(6, blank, { firstId: 0, source: "restore" }), ], 6, ), new Map(), new Map(), ) expect(twice.versions.map((v) => [v.id, v.number])).toEqual([ ["1", 1], ["2", 2], ["3", 3], ["5", 4], ]) expect(twice.onCanvasId).toBe("5") }) it("keeps a version's number and change when its first copy drops out of the server's buffer", () => { const numbers = new Map() const changes = new Map() const docs = Array.from({ length: 20 }, (_, i) => doc( Array.from({ length: i + 1 }, (_, j) => cell(`c${j}`, `C${j}`), ).join(""), ), ) const full = docs.map((xml, i) => entry(i, xml)) buildVersions(list(full, 19), numbers, changes) // v1 restored: its copy is added and the original, the oldest // entry, drops out; the copy is now the first with that content const after = [ ...full.slice(1), entry(20, docs[0], { firstId: 20, source: "restore" }), ] const { versions, onCanvasId } = buildVersions( list(after, 20), numbers, changes, ) const v1 = versions.find((v) => v.id === "20") expect(v1?.number).toBe(1) expect(v1?.fromScratch).toBe(true) expect(v1?.summary.shapesAdded).toBe(1) expect(v1?.beforeXml).toBe("") expect(versions.map((v) => v.number)).not.toContain(21) expect(onCanvasId).toBe("20") }) it("lets the newest version on the canvas wait for its picture, older ones show none", () => { const { versions } = buildVersions( list([entry(0, A), entry(1, AB)], 1), new Map(), new Map(), ) expect(versions[0].svg).toBe("") expect(versions[1].svg).toBeUndefined() const edited = buildVersions( list([entry(0, A), entry(1, AB)], null), new Map(), new Map(), ) expect(edited.versions[1].svg).toBe("") }) it("labels a version by who made it and what it did", () => { const dict = en as unknown as Dictionary const { versions } = buildVersions( list( [entry(0, A), entry(1, AB, { source: "user" }), entry(2, ABC)], 2, ), new Map(), new Map(), ) expect(versions.map((v) => versionLabel(v, dict))).toEqual([ "Drew the diagram", "Changed by hand", "Changed the diagram", ]) }) }) /** A stand-in for the sync: History as the server would answer, restores * recorded */ function fakeSync(initial: HistoryList) { let current = initial const listeners = new Set<() => void>() const restores: { id: number; stateId: string | null }[] = [] let restoreResult: "ok" | "stale" | "failed" = "ok" const sync = { fetchHistory: vi.fn(async () => current), restoreEntry: vi.fn(async (id: number, stateId: string | null) => { restores.push({ id, stateId }) return restoreResult }), onServerChange: (listener: () => void) => { listeners.add(listener) return () => listeners.delete(listener) }, } as unknown as McpSync return { sync, restores, /** The server changed: the list it would answer now */ serve(next: HistoryList) { current = next for (const listener of listeners) listener() }, failRestores(result: "stale" | "failed") { restoreResult = result }, } } const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ) const flush = () => act(() => Promise.resolve()) describe("useNodeVersions", () => { it("reads History when the sync starts and whenever the server changed", async () => { const server = fakeSync(list([entry(0, A)], 0)) const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() expect(result.current.versions.map((v) => v.number)).toEqual([1]) expect(result.current.onCanvasId).toBe("0") expect(result.current.canUndo).toBe(false) await act(async () => server.serve(list([entry(0, A), entry(1, AB)], 1)), ) expect(result.current.versions.map((v) => v.number)).toEqual([1, 2]) expect(result.current.onCanvasId).toBe("1") expect(result.current.canUndo).toBe(true) expect(result.current.canRedo).toBe(false) }) it("numbers the History of another server state anew", async () => { // The process restarted: entry ids start over and name other content const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() expect(result.current.versions.map((v) => v.number)).toEqual([1, 2]) const AB2 = doc(cell("a", "A") + cell("b", "B"), "p2") const ABC2 = doc(cell("a", "A") + cell("b", "B") + cell("c", "C"), "p2") await act(async () => server.serve({ entries: [entry(0, AB2), entry(1, ABC2)], stateId: "S2", currentId: 1, }), ) expect(result.current.versions.map((v) => v.number)).toEqual([1, 2]) const [first, second] = result.current.versions expect(first.fromScratch).toBe(true) expect(first.summary.shapesAdded).toBe(2) expect(second.pageId).toBe("p2") }) it("undoes to the version the newest one replaced, after a restore in between", async () => { // History: A, AB, ABC, A again (restored), AD; undo from AD goes to // A (its newest copy), as restore_version does, not to ABC const AD = doc(cell("a", "A") + cell("d", "D")) const server = fakeSync( list( [ entry(0, A), entry(1, AB), entry(2, ABC), entry(3, A, { firstId: 0, source: "restore" }), entry(4, AD), ], 4, ), ) const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() expect(result.current.canUndo).toBe(true) await act(async () => { result.current.undo() }) expect(server.restores).toEqual([{ id: 3, stateId: "S1" }]) // Back at A: AD is the undone one await act(async () => server.serve( list( [ entry(0, A), entry(1, AB), entry(2, ABC), entry(3, A, { firstId: 0, source: "restore" }), entry(4, AD), entry(5, A, { firstId: 0, source: "restore" }), ], 5, ), ), ) expect(result.current.onCanvasId).toBe("0") expect(result.current.undoneId).toBe("4") expect(result.current.canRedo).toBe(true) }) it("has no versions without a sync", () => { const { result } = renderHook(() => useNodeVersions(null), { wrapper }) expect(result.current.versions).toEqual([]) expect(result.current.onCanvasId).toBeNull() }) it("restores through the sync with the list's state, and says so", async () => { const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() await act(async () => { result.current.restore("0") }) expect(server.restores).toEqual([{ id: 0, stateId: "S1" }]) expect(toasts.success).toHaveBeenCalledWith("Restored v1", { duration: 4000, }) // The canvas was busy for the restore, and is free again expect(useCanvasStore.getState().isBusy).toBe(false) }) it("undoes the newest version by restoring the one before, and redoes it", async () => { // The server keeps the state it left as a copy of the entry const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() await act(async () => { result.current.undo() }) expect(server.restores).toEqual([{ id: 0, stateId: "S1" }]) await act(async () => server.serve( list( [entry(0, A), entry(1, AB), entry(2, A, { firstId: 0 })], 2, ), ), ) expect(result.current.versions).toHaveLength(2) expect(result.current.onCanvasId).toBe("0") expect(result.current.undoneId).toBe("1") expect(result.current.canUndo).toBe(false) expect(result.current.canRedo).toBe(true) await act(async () => { result.current.redo() }) expect(server.restores.at(-1)).toEqual({ id: 1, stateId: "S1" }) await act(async () => server.serve( list( [ entry(0, A), entry(1, AB), entry(2, A, { firstId: 0 }), entry(3, AB, { firstId: 1 }), ], 3, ), ), ) expect(result.current.onCanvasId).toBe("1") expect(result.current.undoneId).toBeNull() expect(result.current.canUndo).toBe(true) }) it("offers neither undo nor redo after a hand edit, and keeps that edit as a version once a write follows", async () => { const server = fakeSync(list([entry(0, A), entry(1, AB)], null)) const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() expect(result.current.canUndo).toBe(false) expect(result.current.canRedo).toBe(false) expect(result.current.undoneId).toBeNull() // The AI writes: the server kept the hand edit first const manual = doc(cell("a", "A") + cell("b", "B") + cell("m", "M")) await act(async () => server.serve( list( [ entry(0, A), entry(1, AB), entry(2, manual, { source: "user" }), entry(3, ABC), ], 3, ), ), ) expect(result.current.versions.map((v) => v.source)).toEqual([ null, null, "user", null, ]) }) it("does not restore twice at once, nor what is not a version", async () => { const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) let resolveRestore: (r: "ok") => void = () => {} server.sync.restoreEntry = vi.fn( () => new Promise<"ok">((resolve) => { resolveRestore = resolve }), ) as unknown as McpSync["restoreEntry"] const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() act(() => { result.current.restore("0") }) await flush() expect(result.current.isBusy).toBe(true) act(() => { result.current.restore("0") result.current.restore("missing") }) expect(server.sync.restoreEntry).toHaveBeenCalledTimes(1) await act(async () => resolveRestore("ok")) expect(result.current.isBusy).toBe(false) }) it("stays quiet when a restore was refused, and reads the list again", async () => { const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) server.failRestores("stale") const { result } = renderHook(() => useNodeVersions(server.sync), { wrapper, }) await flush() toasts.success.mockClear() const reads = (server.sync.fetchHistory as ReturnType) .mock.calls.length await act(async () => { result.current.restore("0") }) expect(toasts.success).not.toHaveBeenCalled() expect( (server.sync.fetchHistory as ReturnType).mock.calls .length, ).toBe(reads + 1) }) })