mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
## Problem
Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.
## Change
Every item below was either approved one by one or covered by "fix all of the others".
- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.
Source lines: 74,983 before, 67,418 after (excluding lockfiles).
The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).
## Testing
- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
191 lines
7.1 KiB
TypeScript
191 lines
7.1 KiB
TypeScript
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 enDict from "@/lib/i18n/dictionaries/en.json"
|
||
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
||
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
||
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||
import { MAX_VERSIONS, useVersionsStore } 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("describeChangeSummary", () => {
|
||
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(
|
||
describeChangeSummary(
|
||
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
|
||
en,
|
||
),
|
||
).toBe("Added 2 shapes, changed 1 connector")
|
||
})
|
||
|
||
it("says so when nothing changed", () => {
|
||
expect(describeChangeSummary({ ...EMPTY_SUMMARY }, en)).toBe(
|
||
"No visible changes",
|
||
)
|
||
})
|
||
|
||
it("uses the language's list separator", () => {
|
||
const zh = {
|
||
shapesAddedOther: "新增 {count} 个图形",
|
||
edgesChangedOne: "改了 1 条连线",
|
||
listSeparator: ",",
|
||
}
|
||
expect(
|
||
describeChangeSummary(
|
||
{ ...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)
|
||
// The model is told the list is cut short
|
||
expect(text).toContain("- and 30 more selected shapes not listed")
|
||
expect(text).not.toContain('"quoted"')
|
||
expect(lines[0].length).toBeLessThan(130)
|
||
})
|
||
})
|
||
|
||
describe("describeTotals", () => {
|
||
it("uses singular and plural forms from the dictionaries", () => {
|
||
const dicts = [enDict, zhDict, jaDict, zhHantDict].map(
|
||
(d) => d.versions as Record<string, string>,
|
||
)
|
||
const one = { ...EMPTY_SUMMARY, shapesAdded: 1, edgesAdded: 0 }
|
||
const many = { ...EMPTY_SUMMARY, shapesAdded: 3, edgesAdded: 1 }
|
||
expect(dicts.map((t) => describeTotals(one, t))).toEqual([
|
||
"1 shape, 0 connectors",
|
||
"共 1 个图形、0 条连线",
|
||
"図形 1 個、コネクタ 0 本",
|
||
"共 1 個圖形、0 條連線",
|
||
])
|
||
expect(describeTotals(many, dicts[0])).toBe("3 shapes, 1 connector")
|
||
})
|
||
})
|
||
|
||
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, each with its number", () => {
|
||
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)
|
||
// v6 stays v6 when v1 to v5 are dropped
|
||
expect(versions[0].number).toBe(6)
|
||
expect(versions.at(-1)?.number).toBe(MAX_VERSIONS + 5)
|
||
})
|
||
|
||
it("numbers on from the last version kept", () => {
|
||
add(0)
|
||
add(2)
|
||
add(4)
|
||
useVersionsStore.getState().removeFromTurn(4)
|
||
add(4)
|
||
expect(
|
||
useVersionsStore.getState().versions.map((v) => v.number),
|
||
).toEqual([1, 2, 3])
|
||
})
|
||
|
||
it("drops versions from a turn that is re-run, none left on the canvas", () => {
|
||
add(0)
|
||
add(2)
|
||
add(4)
|
||
useVersionsStore.getState().removeFromTurn(2)
|
||
const state = useVersionsStore.getState()
|
||
expect(state.versions.map((v) => v.turnIndex)).toEqual([0])
|
||
expect(state.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()
|
||
})
|
||
})
|