Files
next-ai-draw-io/packages/mcp-server/tests/history.test.ts
T
dayuan.jiang 734a31d4eb fix(mcp-server): review fixes for the version cards and History
The shell's version cards (shell/node-versions-source.ts):
- a version's change and undo target are the state it replaced, the
  History entry right before its first copy, not the card before it: after
  a restore those differ, and undo went to the wrong version (and not
  where restore_version steps_back=1 goes)
- a card restores the newest copy of its content, as restore_version
  does, so page settings the user changed (a "user" copy) are kept
- a blank page after a drawing is a clear of the canvas, a version of its
  own; only the blank page before any drawing is hidden
- numbers and changes are keyed by content, not by the first copy's id,
  so a version keeps them when its first copy drops out of the server's
  20-entry buffer; the caches start over for another server state (the
  process restarted: entry ids name other content)

The server's History (src/history.ts):
- firstCopyIds compares each entry with the first of every group only: a
  bare model matches any page name, so "same content" is not transitive,
  and a card could show one document and restore another
- the time and pages fields had no reader; pages parsed every XML once
  more on every write

Reading History (src/http-server.ts, shell/mcp-sync-core.ts):
- GET /api/state and a push's answer carry a History key (entry count,
  newest id, the entry on the canvas); the shell reads History again only
  when it changes, so a hand edit no longer downloads every entry's XML and
  thumbnail
- a failed History read is told again at the next poll
- a History list from a state the poll has not seen yet is dropped
2026-10-11 20:56:07 +09:00

206 lines
7.4 KiB
TypeScript

/**
* Tests for the diagram history: the versions restore_version can go to.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import {
addHistory,
clearHistory,
currentEntryId,
firstCopyIds,
getHistory,
isInHistory,
otherVersions,
} from "../src/history.ts"
beforeAll(() => {
// XML parsing for the content fingerprints
installDomPolyfill()
})
const page = (cellId: string) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const A = page("a")
const B = page("b")
const C = page("c")
// draw.io's copy of B: other attribute order, whitespace and viewport
// attributes, same diagram
const B_COPY = `<mxfile host="app.diagrams.net">
<diagram name="P" id="p">
<mxGraphModel dx="100" dy="200" pageWidth="850">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="b" parent="1" vertex="1" />
</root>
</mxGraphModel>
</diagram>
</mxfile>`
const fill = (id: string, versions: string[]) => {
clearHistory(id)
for (const xml of versions) addHistory(id, xml)
}
describe("otherVersions", () => {
it("lists the versions before the canvas, newest first", () => {
const id = "history-abc"
fill(id, [A, B, C])
expect(otherVersions(id, C).map((e) => e.xml)).toEqual([B, A])
})
it("lists each version once after an undo, the left one first", () => {
const id = "history-abcb"
fill(id, [A, B, C, B])
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([C, A])
})
it("lists every version when the canvas is in none of them", () => {
const id = "history-manual"
fill(id, [A, B, C])
expect(otherVersions(id, page("manual")).map((e) => e.xml)).toEqual([
C,
B,
A,
])
})
it("counts a re-serialised copy as the same version", () => {
const id = "history-copy"
fill(id, [A, B, B_COPY])
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([A])
expect(otherVersions(id, B_COPY).map((e) => e.xml)).toEqual([A])
})
it("keeps the newest copy of a repeated version", () => {
const id = "history-newest"
fill(id, [A, B, A])
const [newestA] = otherVersions(id, B)
expect(newestA.id).toBe(getHistory(id)[2].id)
expect(otherVersions(id, B)).toHaveLength(1)
})
it("matches a bare model from the browser with its named page", () => {
const id = "history-bare"
const named = (xml: string) => xml.replace('name="P"', 'name="Arch"')
fill(id, [named(A), named(B)])
// The browser's sync sent page B without its <mxfile> wrapper
const bareB = B.replace(/^.*?(<mxGraphModel>)/, "$1").replace(
"</diagram></mxfile>",
"",
)
expect(otherVersions(id, bareB).map((e) => e.xml)).toEqual([named(A)])
expect(isInHistory(id, bareB)).toBe(true)
expect(isInHistory(id, C)).toBe(false)
})
it("still counts a rename when an unrelated bare model is in History", () => {
const id = "history-mixed"
const bareX = page("x")
.replace(/^.*?(<mxGraphModel>)/, "$1")
.replace("</diagram></mxfile>", "")
const arch = B.replace('name="P"', 'name="Arch"')
const overview = B.replace('name="P"', 'name="Overview"')
fill(id, [bareX, arch])
expect(otherVersions(id, overview).map((e) => e.xml)).toEqual([
arch,
bareX,
])
expect(isInHistory(id, overview)).toBe(false)
})
it("returns nothing for an empty history", () => {
const id = "history-empty"
clearHistory(id)
expect(otherVersions(id, A)).toEqual([])
})
})
describe("entries", () => {
it("record who made them", () => {
const id = "history-fields"
clearHistory(id)
addHistory(id, A)
addHistory(id, B, "<svg/>", "user")
addHistory(id, C, "", "restore")
const [first, second, third] = getHistory(id)
expect(first.source).toBeUndefined()
expect(second.source).toBe("user")
expect(second.svg).toBe("<svg/>")
expect(third.source).toBe("restore")
})
})
// The shell's version cards: which entry the canvas shows, and which
// entries are copies of an earlier one (a restore adds one)
describe("currentEntryId and firstCopyIds", () => {
it("name the newest entry with the canvas's content, by the rule of otherVersions", () => {
const id = "history-current"
fill(id, [A, B, C, A])
const [a, b, c, aCopy] = getHistory(id)
expect(currentEntryId(id, A)).toBe(aCopy.id)
expect(currentEntryId(id, B_COPY)).toBe(b.id)
expect(currentEntryId(id, page("manual"))).toBeNull()
// A restore's copy belongs with its first entry; the others are
// their own
expect([...firstCopyIds(id).entries()]).toEqual([
[a.id, a.id],
[b.id, b.id],
[c.id, c.id],
[aCopy.id, a.id],
])
})
it("group a bare model with the first named page it matches, never a later one", () => {
// A bare model (the browser's sync can send one) has no page name:
// it is the same as "Arch" and as "Overview", which differ from each
// other. Each entry names a first copy with its own content, so a
// card never shows one document and restores another
const id = "history-bare-groups"
const named = (name: string) => A.replace('name="P"', `name="${name}"`)
const bare = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel>`
fill(id, [named("Arch"), bare, named("Overview")])
const [arch, model, overview] = getHistory(id)
const firsts = firstCopyIds(id)
expect([...firsts.entries()]).toEqual([
[arch.id, arch.id],
[model.id, arch.id],
[overview.id, overview.id],
])
// The entry named as first has the content of every entry in its
// group (otherVersions of that content never lists it)
for (const entry of getHistory(id)) {
expect(otherVersions(id, entry.xml).map((e) => e.id)).not.toContain(
firsts.get(entry.id),
)
}
})
it("agree with otherVersions on a page setting that only the canvas shows", () => {
// The same cells with another background: one version to the MCP
// core (the model's edits stay valid when the user changes a page
// setting), so the canvas still shows that entry and restore_version
// offers no other copy of it
const id = "history-background"
const withBackground = A.replace(
"<mxGraphModel>",
'<mxGraphModel background="#ffcc00" shadow="1">',
)
fill(id, [A, B])
const [a] = getHistory(id)
expect(currentEntryId(id, withBackground)).toBe(a.id)
expect(otherVersions(id, withBackground).map((e) => e.xml)).toEqual([B])
expect(isInHistory(id, withBackground)).toBe(true)
})
it("return nothing for an empty history", () => {
const id = "history-current-empty"
clearHistory(id)
expect(currentEntryId(id, A)).toBeNull()
expect(firstCopyIds(id).size).toBe(0)
})
})