mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
feat(mcp-server): version cards in the shell from the server's History
The shell's VersionsSource (shell/node-versions-source.ts) reads GET /api/history through the sync and shows one version per distinct content (a restore's copy folds into the entry it copies; the blank page is none), numbered as they first appeared, with what changed since the one before. The entry the server says is on the canvas marks the version; undo and redo of the newest version restore the one before it, or it again, through POST /api/restore, so hand edits stay as versions of their own. The cards sit in a panel beside the canvas (toggle in the header); the strip and Compare come from the shared canvas components. The sync tells listeners once per server version, and when History changed without one, so the list is read again only then.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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) =>
|
||||
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
||||
const doc = (cells: string, pageId = "p1") =>
|
||||
`<mxfile><diagram id="${pageId}" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
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> = {},
|
||||
): HistoryEntryInfo {
|
||||
return {
|
||||
id,
|
||||
index: id,
|
||||
svg: "",
|
||||
xml,
|
||||
source: null,
|
||||
time: 1000 + id,
|
||||
pages: 1,
|
||||
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 = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
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<number, number>()
|
||||
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("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 }) => (
|
||||
<DictionaryProvider dictionary={en as unknown as Dictionary}>
|
||||
{children}
|
||||
</DictionaryProvider>
|
||||
)
|
||||
|
||||
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("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<typeof vi.fn>)
|
||||
.mock.calls.length
|
||||
await act(async () => {
|
||||
result.current.restore("0")
|
||||
})
|
||||
expect(toasts.success).not.toHaveBeenCalled()
|
||||
expect(
|
||||
(server.sync.fetchHistory as ReturnType<typeof vi.fn>).mock.calls
|
||||
.length,
|
||||
).toBe(reads + 1)
|
||||
})
|
||||
})
|
||||
@@ -106,13 +106,16 @@ describe("shell language", () => {
|
||||
expect(Object.keys(dict.shell).sort(), locale).toEqual([
|
||||
"aiChanged",
|
||||
"connected",
|
||||
"hideVersions",
|
||||
"historyChanged",
|
||||
"offline",
|
||||
"restoreFailed",
|
||||
"restoredFromFile",
|
||||
"showVersions",
|
||||
"toggleTheme",
|
||||
"tooLarge",
|
||||
"unreachable",
|
||||
"userEdit",
|
||||
"waiting",
|
||||
])
|
||||
expect(dict.shell.waiting, locale).toBeTruthy()
|
||||
|
||||
@@ -866,7 +866,12 @@ describe("MCP sync export requests", () => {
|
||||
describe("MCP sync History", () => {
|
||||
const historyList = {
|
||||
status: 200,
|
||||
body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 },
|
||||
body: {
|
||||
entries: [{ id: 5, index: 0, svg: "" }],
|
||||
count: 1,
|
||||
stateId: "S1",
|
||||
currentId: null,
|
||||
},
|
||||
}
|
||||
|
||||
it("drops a History list asked for before the session was recreated", async () => {
|
||||
@@ -889,6 +894,7 @@ describe("MCP sync History", () => {
|
||||
expect(list).toEqual({
|
||||
entries: [{ id: 5, index: 0, svg: "" }],
|
||||
stateId: "S1",
|
||||
currentId: null,
|
||||
})
|
||||
const restore = t.sync.restoreEntry(5, list?.stateId ?? null)
|
||||
await t.settle()
|
||||
@@ -938,6 +944,87 @@ describe("MCP sync History", () => {
|
||||
expect(await restore).toBe("failed")
|
||||
expect(t.notices).toEqual(["restoreFailed"])
|
||||
})
|
||||
|
||||
it("passes on the entry the server says is on the canvas", async () => {
|
||||
const t = await inStep()
|
||||
const history = t.sync.fetchHistory()
|
||||
t.next("GET", "/api/history").answer({
|
||||
status: 200,
|
||||
body: { entries: [{ id: 7 }], count: 1, currentId: 7 },
|
||||
})
|
||||
expect((await history)?.currentId).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
// The version cards read History again when the server changed
|
||||
describe("MCP sync server change listener", () => {
|
||||
it("tells once per server version: the first poll, a write, the tab's own edit", async () => {
|
||||
const t = open()
|
||||
let changes = 0
|
||||
const stop = t.sync.onServerChange(() => changes++)
|
||||
t.sync.setReady(true)
|
||||
let poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(1)
|
||||
// The same version again: nothing new
|
||||
poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(1)
|
||||
// The thumbnail of the write is stored: its entry has a picture now
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
t.nextExport().answer({ data: "<svg>A</svg>" })
|
||||
await t.settle()
|
||||
t.next("POST", "/api/history-svg").answer({ status: 200, body: {} })
|
||||
await t.settle()
|
||||
expect(changes).toBe(2)
|
||||
// An AI write
|
||||
poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 3, "<mxfile>B</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(3)
|
||||
// The user's edit is saved: the canvas is at no version now
|
||||
await edit(t, "<mxfile>C</mxfile>")
|
||||
t.next("POST", "/api/state").answer({
|
||||
status: 200,
|
||||
body: { success: true, version: 4 },
|
||||
})
|
||||
await t.settle()
|
||||
expect(changes).toBe(4)
|
||||
// The next poll sees the version the push already told about
|
||||
poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 4, "<mxfile>C</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(4)
|
||||
stop()
|
||||
poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 5, "<mxfile>D</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(4)
|
||||
})
|
||||
|
||||
it("tells when a rejected edit was kept in History, and when the state was recreated", async () => {
|
||||
const t = await inStep()
|
||||
let changes = 0
|
||||
t.sync.onServerChange(() => changes++)
|
||||
await edit(t, "<mxfile>B</mxfile>")
|
||||
t.next("POST", "/api/state").answer({
|
||||
status: 409,
|
||||
body: { error: "changed", version: 3, savedToHistory: true },
|
||||
})
|
||||
await t.settle()
|
||||
expect(changes).toBe(1)
|
||||
// The 409 handler polls and loads the newer write
|
||||
t.next("GET").answer(state("S1", 3, "<mxfile>AI</mxfile>"))
|
||||
await t.settle()
|
||||
expect(changes).toBe(2)
|
||||
// The server restarted: History has new ids
|
||||
const poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S2", 1, "<mxfile>AI</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// Documents as the server writes them (one page per call)
|
||||
|
||||
Reference in New Issue
Block a user