Files
next-ai-draw-io/tests/unit/diagram-diff.test.ts
T
dayuan.jiang 23d5c83b0d feat(ui): canvas-first redesign with a self-hosted draw.io
The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- the app's own canvas toolbar, shape library and page tabs replace
  draw.io's chrome
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- phones switch between canvas and chat
- dark mode keeps the diagram on a white sheet and no longer flashes

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to
draw.io's own toolbar and the features above that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
2026-10-06 16:17:11 +09:00

145 lines
6.4 KiB
TypeScript

import pako from "pako"
import { describe, expect, it } from "vitest"
import {
countCells,
diffDiagrams,
inflatePages,
isEmptySummary,
isSameDocument,
} from "@/lib/diagram-diff"
const doc = (cells: string) =>
`<mxfile><diagram name="Page-1" id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
const box = (id: string, value = id) =>
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
const edge = (id: string, source: string, target: string) =>
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
describe("diffDiagrams", () => {
it("counts added shapes and connectors on an empty canvas", () => {
const { summary, touchedIds } = diffDiagrams(
"",
doc(box("a") + box("b") + edge("e", "a", "b")),
)
expect(summary.shapesAdded).toBe(2)
expect(summary.edgesAdded).toBe(1)
expect(touchedIds.sort()).toEqual(["a", "b", "e"])
})
it("separates added, changed and removed cells", () => {
const before = doc(box("a") + box("b") + edge("e", "a", "b"))
const after = doc(box("a", "renamed") + box("c") + edge("e", "a", "c"))
const { summary, touchedIds } = diffDiagrams(before, after)
expect(summary).toEqual({
shapesAdded: 1,
shapesRemoved: 1,
shapesChanged: 1,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 1,
})
expect(touchedIds.sort()).toEqual(["a", "c", "e"])
})
it("ignores how the same cell is written", () => {
// As the model writes it, and as draw.io saves it
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" style="rounded=1;html=1;" vertex="1" parent="1"><mxGeometry x="10" y="20" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
const saved = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" parent="1" style="html=1;rounded=1" value="A" vertex="1"><mxGeometry as="geometry" height="40" width="80" x="10" y="20"/></mxCell></root></mxGraphModel>`
expect(isEmptySummary(diffDiagrams(model, saved).summary)).toBe(true)
})
it("reports nothing for identical diagrams", () => {
const xml = doc(box("a"))
const { summary, touchedIds } = diffDiagrams(xml, xml)
expect(isEmptySummary(summary)).toBe(true)
expect(touchedIds).toEqual([])
})
it("reads the id from <UserObject> wrappers", () => {
const wrapped = `<UserObject label="Linked" link="https://example.com" id="u1"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`
const { summary, touchedIds } = diffDiagrams(doc(""), doc(wrapped))
expect(summary.shapesAdded).toBe(1)
expect(touchedIds).toEqual(["u1"])
})
it("only looks at the first page", () => {
const twoPages = `<mxfile><diagram id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("a")}</root></mxGraphModel></diagram><diagram id="p2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("z")}</root></mxGraphModel></diagram></mxfile>`
expect(countCells(twoPages)).toEqual({ shapes: 1, edges: 0 })
})
it("reads compressed pages", () => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("a")}${box("b")}</root></mxGraphModel>`
const compressed = btoa(
String.fromCharCode(...pako.deflateRaw(encodeURIComponent(model))),
)
const xml = `<mxfile><diagram id="p1">${compressed}</diagram></mxfile>`
expect(countCells(xml)).toEqual({ shapes: 2, edges: 0 })
})
it("treats unparsable XML as empty", () => {
expect(countCells("<mxfile><diagram>")).toEqual({ shapes: 0, edges: 0 })
})
})
const pageOf = (name: string, cells: string, attrs = "") =>
`<diagram name="${name}" id="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
const file = (...pages: string[]) => `<mxfile>${pages.join("")}</mxfile>`
const packedPage = (name: string, cells: string) => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
const packed = Buffer.from(
pako.deflateRaw(encodeURIComponent(model)),
).toString("base64")
return `<diagram name="${name}" id="${name}">${packed}</diagram>`
}
describe("isSameDocument", () => {
const a = file(pageOf("One", box("a")), pageOf("Two", box("b")))
it("is true for the same pages written differently", () => {
const b = file(
pageOf("One", box("a"), ' dx="300" dy="120" grid="1"'),
packedPage("Two", box("b")),
)
expect(isSameDocument(a, b)).toBe(true)
})
it("sees a change on the second page", () => {
const b = file(pageOf("One", box("a")), pageOf("Two", box("b", "x")))
expect(diffDiagrams(a, b).summary).toEqual(diffDiagrams(a, a).summary)
expect(isSameDocument(a, b)).toBe(false)
})
it("sees added pages, renamed pages and changed page settings", () => {
expect(isSameDocument(a, file(pageOf("One", box("a"))))).toBe(false)
expect(
isSameDocument(
a,
file(pageOf("One", box("a")), pageOf("Renamed", box("b"))),
),
).toBe(false)
expect(
isSameDocument(
file(pageOf("One", box("a"), ' background="#ffffff"')),
file(pageOf("One", box("a"), ' background="#000000"')),
),
).toBe(false)
})
})
describe("inflatePages", () => {
it("inflates compressed pages in place", () => {
const xml = file(pageOf("One", box("a")), packedPage("Two", box("b")))
const inflated = inflatePages(xml)
expect(inflated).toContain('<mxCell id="b"')
expect(inflated.match(/<mxGraphModel/g)).toHaveLength(2)
expect(isSameDocument(xml, inflated)).toBe(true)
})
it("leaves plain documents as they are", () => {
const xml = file(pageOf("One", box("a")))
expect(inflatePages(xml)).toBe(xml)
expect(inflatePages("not xml <")).toBe("not xml <")
})
})