Files
next-ai-draw-io/tests/unit/redesign-helpers.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

174 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from "vitest"
import { formatSelectionContext } from "@/lib/chat-helpers"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import { describeChanges } from "@/lib/version-text"
import {
MAX_VERSIONS,
useVersionsStore,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
describe("withPageDefaults", () => {
it("adds page and grid when the model has none", () => {
expect(withPageDefaults("<mxGraphModel><root/></mxGraphModel>")).toBe(
'<mxGraphModel page="1" grid="1" gridSize="10"><root/></mxGraphModel>',
)
})
it("keeps values the diagram already sets", () => {
const xml = '<mxGraphModel dx="10" page="0" grid="0" gridSize="5">'
expect(withPageDefaults(xml)).toBe(xml)
})
it("reads attributes written with spaces around =", () => {
const xml = '<mxGraphModel page = "0" grid ="0" gridSize= "5">'
expect(withPageDefaults(xml)).toBe(xml)
})
it("handles self-closing models and several pages", () => {
const xml =
'<mxfile><diagram><mxGraphModel/></diagram><diagram><mxGraphModel page="0"></mxGraphModel></diagram></mxfile>'
expect(withPageDefaults(xml)).toBe(
'<mxfile><diagram><mxGraphModel page="1" grid="1" gridSize="10"/></diagram><diagram><mxGraphModel page="0" grid="1" gridSize="10"></mxGraphModel></diagram></mxfile>',
)
})
})
describe("describeChanges", () => {
const en = {
shapesAddedOne: "added 1 shape",
shapesAddedOther: "added {count} shapes",
edgesChangedOne: "changed 1 connector",
edgesChangedOther: "changed {count} connectors",
noChanges: "No visible changes",
listSeparator: ", ",
}
it("uses singular and plural forms and sentence case", () => {
expect(
describeChanges(
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
en,
),
).toBe("Added 2 shapes, changed 1 connector")
})
it("says so when nothing changed", () => {
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
"No visible changes",
)
})
it("uses the language's list separator", () => {
const zh = {
shapesAddedOther: "新增 {count} 个图形",
edgesChangedOne: "改了 1 条连线",
listSeparator: ",",
}
expect(
describeChanges(
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
zh,
),
).toBe("新增 3 个图形,改了 1 条连线")
})
})
describe("formatSelectionContext", () => {
it("lists selected ids with their labels", () => {
const text = formatSelectionContext([
{ id: "4", label: "EC2" },
{ id: "cf", label: "" },
])
expect(text).toContain('- id="4" (EC2)')
expect(text).toContain('- id="cf"')
expect(text).not.toContain('id="cf" (')
})
it("returns nothing for missing or malformed input", () => {
expect(formatSelectionContext(undefined)).toBe("")
expect(formatSelectionContext("4")).toBe("")
expect(formatSelectionContext([{ label: "no id" }])).toBe("")
})
it("strips quotes and newlines and limits length and count", () => {
const many = Array.from({ length: 80 }, (_, i) => ({
id: `c${i}`,
label: `line\n"quoted"${"x".repeat(200)}`,
}))
const text = formatSelectionContext(many)
const lines = text.split("\n").filter((l) => l.startsWith("- id="))
expect(lines).toHaveLength(50)
expect(text).not.toContain('"quoted"')
expect(lines[0].length).toBeLessThan(130)
})
})
describe("versions store", () => {
beforeEach(() => useVersionsStore.getState().clear())
const add = (n: number) =>
useVersionsStore.getState().addVersion({
xml: `<x n="${n}"/>`,
beforeXml: `<before n="${n}"/>`,
turnIndex: n,
summary: { ...EMPTY_SUMMARY },
})
it("keeps the before-XML only on the newest version", () => {
add(0)
add(2)
const [first, second] = useVersionsStore.getState().versions
expect(first.beforeXml).toBeUndefined()
expect(second.beforeXml).toBe('<before n="2"/>')
})
it("keeps at most MAX_VERSIONS versions", () => {
for (let i = 0; i < MAX_VERSIONS + 5; i++) add(i)
const { versions } = useVersionsStore.getState()
expect(versions).toHaveLength(MAX_VERSIONS)
expect(versions[0].turnIndex).toBe(5)
})
it("drops versions from a turn that is re-run", () => {
add(0)
add(2)
add(4)
useVersionsStore.getState().removeFromTurn(2)
expect(
useVersionsStore.getState().versions.map((v) => v.turnIndex),
).toEqual([0])
})
it("marks no version as on the canvas after a turn is re-run", () => {
add(0)
add(2)
useVersionsStore.getState().removeFromTurn(2)
expect(useVersionsStore.getState().onCanvasVersionId).toBeNull()
})
it("hand edits clear both version flags", () => {
const id = add(0)
expect(useVersionsStore.getState().onCanvasVersionId).toBe(id)
useVersionsStore.getState().clearCanvasFlags()
let state = useVersionsStore.getState()
expect(state.onCanvasVersionId).toBeNull()
expect(state.versions[0].onCanvas).toBeUndefined()
useVersionsStore.getState().setUndone(id)
useVersionsStore.getState().clearCanvasFlags()
state = useVersionsStore.getState()
expect(state.undoneVersionId).toBeNull()
expect(state.versions[0].undone).toBeUndefined()
})
it("converts the old history list", () => {
const versions = versionsFromLegacyHistory([
{ svg: "data:image/svg+xml;base64,AA", xml: "<a/>" },
])
expect(versions).toHaveLength(1)
expect(versions[0]).toMatchObject({ xml: "<a/>", turnIndex: -1 })
expect(versionsFromLegacyHistory(undefined)).toEqual([])
})
})