Files
next-ai-draw-io/tests/unit/diagram-context.test.tsx
T
dayuan.jiang c0fa997186 fix: older defects (batch C) and the second batch's review
Chats:
- New Chat right after an answer saves that chat once. Saves run one at
  a time and read the chat on screen when their turn comes; a save
  scheduled for a chat that is no longer on screen is dropped. A chat
  whose id was still on its way to the URL no longer comes back after
  New Chat (the next answer went into it).
- Crossing the 768 px breakpoint keeps the chat panel: a streaming answer,
  unsaved messages and attachments stay. The panel gets the sizes of each
  side, and a panel collapsed on desktop opens on mobile.
- The chat's export waits for its own reply: an edit's history export
  still on its way no longer answers it with the older diagram, and two
  file saves at once no longer swap results.
- A second edit in one answer is previewed on the first edit's result.
- Stop also ends a running screenshot check; a chat that cannot be saved
  (storage full) can be left with "Continue without saving".
- Small diagrams with shapes count as diagrams; the tool card no longer
  crashes on malformed operations.

Quota and providers:
- Requests that reach the server's own endpoints count toward the quota:
  EdgeOne (always its own endpoint now), a private base URL whatever key
  header is sent, keyless Ollama without a URL. With the quota on, a
  redirect is followed only to a public address. The output cap applies
  to these requests too.
- Stop records the tokens of the steps that finished; the screenshot check
  counts its tokens without counting a request.
- EdgeOne configured only by AI_PROVIDER works, also in the admin Test,
  which forwards the access code. Azure set up only in the admin panel
  works in chat. The Test sends a Bedrock session token.
- The admin panel's Test of an entry without a URL uses the server's URL
  as the server does (no private address check for it); the admin panel
  no longer writes an Ollama URL.

MCP server:
- Write tools and start_session run one at a time, so two at once never
  drop each other's change; a cancelled call waiting its turn is skipped.
  get_diagram and export_diagram keep the session they started with.
- Export to .drawio first gets the user's latest edits from the browser.
- History thumbnails: one that arrives after the next AI write is
  dropped; a sync reply keeps the image; a version that changed only page
  settings is its own entry.
- A diagram over the 10 MB limit is saved without its image, or the user
  is told to download it (the server now answers 413 instead of cutting
  the connection).
- Labels holding text like id='1' or parent='1' are no longer read as
  attributes (a layer or a parent was deleted). A broken bare
  <mxGraphModel> file is refused.
- After a sync reply the tab no longer sends its autosave copy again.

Desktop and files:
- A newer switch of the same preset is not rolled back by an older one
  that failed. .env values with escaped quotes are read whole.
- MCP saved files: a file that could not be read stays protected while a
  folder without permission hides it, and is saved again once deleted.
- The desktop app reports "no chats" only when the count was read and no
  model settings are stored.
2026-10-05 18:57:02 +09:00

120 lines
4.6 KiB
TypeScript

import { deflateRawSync } from "node:zlib"
import { act, renderHook } from "@testing-library/react"
import type React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { DiagramProvider, useDiagram } from "@/contexts/diagram-context"
vi.mock("sonner", () => ({ toast: { success: vi.fn() } }))
// The provider with a stand-in draw.io that records each export request
function setup() {
const { result } = renderHook(() => useDiagram(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<DiagramProvider>{children}</DiagramProvider>
),
})
const requests: { format: string; message: string }[] = []
result.current.drawioRef.current = {
exportDiagram: (r: any) => requests.push(r),
load: vi.fn(),
} as any
// draw.io's reply to a request: it echoes the request in `message`
const reply = (request: { message: string }, data: string, xml = "") =>
act(() =>
result.current.handleDiagramExport({
event: "export",
data,
xml,
format: "xmlsvg",
message: request,
} as any),
)
return { result, requests, reply }
}
// An editable SVG as draw.io exports it: the diagram, compressed, in its
// content attribute
const svgOf = (label: string) => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="${label}" vertex="1" parent="1"/></root></mxGraphModel>`
const packed = deflateRawSync(
Buffer.from(encodeURIComponent(model)),
).toString("base64")
const content = `<mxfile><diagram id="p">${packed}</diagram></mxfile>`
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${content}"></svg>`
return `data:image/svg+xml;base64,${btoa(svg)}`
}
afterEach(() => {
vi.restoreAllMocks()
})
describe("exports in flight at the same time", () => {
it("give the chat's export only its own reply", () => {
const { result, requests, reply } = setup()
// An edit's history export is still on its way when the chat exports
act(() => {
result.current.handleExport()
})
let tag = ""
const got: string[] = []
act(() => {
tag = result.current.handleExportWithoutHistory()
result.current.exportResolversRef.current[tag] = (xml) =>
got.push(xml)
})
reply(requests[0], svgOf("older"))
expect(got).toEqual([])
reply(requests[1], svgOf("current"))
expect(got).toHaveLength(1)
expect(got[0]).toContain('value="current"')
expect(result.current.exportResolversRef.current[tag]).toBeUndefined()
})
it("save each file with its own result", async () => {
const { result, requests, reply } = setup()
const saved: { name: string; href: string }[] = []
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
function (this: HTMLAnchorElement) {
saved.push({ name: this.download, href: this.href })
},
)
const blobs = new Map<string, Blob>()
URL.createObjectURL = vi.fn((blob: Blob) => {
const url = `blob:test-${blobs.size}`
blobs.set(url, blob)
return url
})
URL.revokeObjectURL = vi.fn()
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}")),
)
const twoPages =
'<mxfile><diagram id="a" name="A"><mxGraphModel><root/></mxGraphModel></diagram><diagram id="b" name="B"><mxGraphModel><root/></mxGraphModel></diagram></mxfile>'
act(() => {
result.current.saveDiagramToFile("doc", "drawio")
result.current.saveDiagramToFile("pic", "png")
})
// The PNG answers first
reply(requests[1], "data:image/png;base64,iVBORw0KGgo=")
reply(requests[0], svgOf("doc"), twoPages)
expect(saved.map((s) => s.name)).toEqual(["pic.png", "doc.drawio"])
expect(saved[0].href).toMatch(/^data:image\/png/)
const file = blobs.get(saved[1].href)
const text = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.readAsText(file as Blob)
})
expect(text).toContain('name="A"')
expect(text).toContain('name="B"')
vi.unstubAllGlobals()
})
})