mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
4 Commits
feat/decla
...
renovate/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5156abfe3 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -79,5 +79,3 @@ ai-models.json
|
|||||||
|
|
||||||
# admin panel settings (contains secrets)
|
# admin panel settings (contains secrets)
|
||||||
data/
|
data/
|
||||||
# ...but the diagram engine's stencil catalog is generated source, not settings
|
|
||||||
!lib/diagram-engine/data/
|
|
||||||
|
|||||||
52
NOTICE
52
NOTICE
@@ -1,52 +0,0 @@
|
|||||||
next-ai-draw-io
|
|
||||||
Copyright the next-ai-draw-io contributors
|
|
||||||
|
|
||||||
This product includes software developed by third parties, as set out below.
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
lib/diagram-engine/ — layout and rendering
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
The declarative layout algorithm (bottom-up measure, top-down place, sibling
|
|
||||||
size equalisation), the swimlane-pool geometry, and the mxCell/style emission in
|
|
||||||
`lib/diagram-engine/layout.ts` and `lib/diagram-engine/render.ts` are derived
|
|
||||||
from drawio-ai-kit:
|
|
||||||
|
|
||||||
https://github.com/sparklabx/drawio-ai-kit
|
|
||||||
Copyright (c) sparklabx
|
|
||||||
Licensed under the MIT License
|
|
||||||
|
|
||||||
Original to this repository:
|
|
||||||
|
|
||||||
- the XML→tree reverse parser (`parse.ts`), which that project does not have
|
|
||||||
- the style-marker scheme (`markers.ts`) that lets structure survive a
|
|
||||||
round-trip through the draw.io editor
|
|
||||||
- the structural-operations layer (`operations.ts`)
|
|
||||||
- the invisible-container approach that replaces that project's "phantom" nodes
|
|
||||||
- the edge router (`route.ts`)
|
|
||||||
- the graph→layers pass (`graph.ts`): layer assignment, cycle breaking and
|
|
||||||
barycentre crossing reduction, which turn a flat node/arrow list into a
|
|
||||||
flowchart
|
|
||||||
- the sequence-diagram and radial (mind map / org chart) layouts
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
lib/diagram-engine/data/aws-stencils.json — stencil catalog
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
A name→style map for the mxgraph.aws4 stencil family, generated from
|
|
||||||
drawio-ai-kit's `catalog/aws.json`, which in turn was generated from the draw.io
|
|
||||||
shape index published by jgraph:
|
|
||||||
|
|
||||||
https://github.com/jgraph/drawio-mcp
|
|
||||||
Copyright (c) JGraph Ltd
|
|
||||||
Licensed under the Apache License, Version 2.0
|
|
||||||
|
|
||||||
The style strings are reproduced verbatim from that index. They reference the
|
|
||||||
official AWS Architecture Icons, which are trademarks of Amazon Web Services and
|
|
||||||
are NOT covered by this repository's licence. Their use is governed by the AWS
|
|
||||||
Architecture Icons terms:
|
|
||||||
|
|
||||||
https://aws.amazon.com/architecture/icons/
|
|
||||||
|
|
||||||
The catalog contains stencil *names and style strings* only — it does not embed
|
|
||||||
any AWS icon artwork. draw.io supplies the artwork at render time.
|
|
||||||
15
README.md
15
README.md
@@ -21,6 +21,17 @@ A Next.js web application that integrates AI capabilities with draw.io diagrams.
|
|||||||
|
|
||||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||||
|
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||||
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
|
|
||||||
@@ -99,7 +110,6 @@ Here are some example prompts and their generated diagrams:
|
|||||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||||
- **Computed Layout**: For architecture diagrams, flowcharts, swimlane/BPMN diagrams, sequence diagrams, mind maps and org charts, the AI declares only the structure — what contains what, or what points at what — and the app computes every coordinate, size and arrow route. Containers always fit their contents, siblings never overlap, and arrows are routed around the shapes they would otherwise cross. Anything you then move or recolour by hand is read back as part of the diagram, so a later edit does not undo it.
|
|
||||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server
|
||||||
@@ -218,6 +228,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
- ModelScope
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||||
|
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||||
@@ -254,6 +265,8 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
|
|
||||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||||
|
|
||||||
|
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||||
|
|
||||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||||
|
|
||||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||||
|
|||||||
@@ -160,11 +160,6 @@ export default function AboutCN() {
|
|||||||
<strong>AWS架构图支持</strong>
|
<strong>AWS架构图支持</strong>
|
||||||
:专门支持生成AWS架构图
|
:专门支持生成AWS架构图
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<strong>自动计算布局</strong>
|
|
||||||
:画架构图、流程图、泳道图、时序图、思维导图和组织架构图时,AI
|
|
||||||
只描述结构,所有坐标、尺寸和连线路径都由程序计算——容器一定装得下里面的内容,图形不会重叠,连线会绕开本来会穿过的图形
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<strong>动画连接器</strong>
|
<strong>动画连接器</strong>
|
||||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|||||||
@@ -153,11 +153,6 @@ export default function AboutJA() {
|
|||||||
</strong>
|
</strong>
|
||||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<strong>レイアウトの自動計算</strong>
|
|
||||||
:アーキテクチャ図、フローチャート、スイムレーン図、シーケンス図、マインドマップ、組織図では、AI
|
|
||||||
は構造だけを指定し、座標・サイズ・矢印の経路はすべてアプリが計算します——コンテナは必ず中身が収まり、図形は重ならず、矢印は本来通り抜けてしまう図形を避けて引かれます
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<strong>アニメーションコネクタ</strong>
|
<strong>アニメーションコネクタ</strong>
|
||||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|||||||
@@ -165,15 +165,6 @@ export default function About() {
|
|||||||
Specialized support for generating AWS architecture
|
Specialized support for generating AWS architecture
|
||||||
diagrams
|
diagrams
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<strong>Computed Layout</strong>: For architecture
|
|
||||||
diagrams, flowcharts, swimlane diagrams, sequence
|
|
||||||
diagrams, mind maps and org charts, the AI declares
|
|
||||||
only the structure and the app computes every
|
|
||||||
coordinate, size and arrow route — so containers fit
|
|
||||||
their contents, shapes never overlap, and arrows are
|
|
||||||
routed around what they would otherwise cross
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<strong>Animated Connectors</strong>: Create dynamic
|
<strong>Animated Connectors</strong>: Create dynamic
|
||||||
and animated connectors between diagram elements for
|
and animated connectors between diagram elements for
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
|
import fs from "fs/promises"
|
||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import {
|
import {
|
||||||
getAIModel,
|
getAIModel,
|
||||||
@@ -21,7 +23,6 @@ import {
|
|||||||
replaceHistoricalToolInputs,
|
replaceHistoricalToolInputs,
|
||||||
validateFileParts,
|
validateFileParts,
|
||||||
} from "@/lib/chat-helpers"
|
} from "@/lib/chat-helpers"
|
||||||
import { OperationSchema, searchStencils } from "@/lib/diagram-engine"
|
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
@@ -33,17 +34,19 @@ import {
|
|||||||
setTraceOutput,
|
setTraceOutput,
|
||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
|
import {
|
||||||
|
resolveMaxOutputTokens,
|
||||||
|
withOutputTokenLimitFallback,
|
||||||
|
} from "@/lib/output-token-limit"
|
||||||
import { findServerModelById } from "@/lib/server-model-config"
|
import { findServerModelById } from "@/lib/server-model-config"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const maxDuration = 120
|
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||||
|
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||||
|
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||||
|
|
||||||
// Helper function to create cached stream response.
|
// Helper function to create cached stream response
|
||||||
//
|
|
||||||
// This replays a stored XML answer straight to the canvas, so it still speaks the
|
|
||||||
// `display_diagram` wire format even though the model can no longer call that tool: the client
|
|
||||||
// handler for it is what puts XML on the canvas. Nothing here goes through the model.
|
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
const toolCallId = `cached-${Date.now()}`
|
const toolCallId = `cached-${Date.now()}`
|
||||||
|
|
||||||
@@ -244,13 +247,22 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
// Get AI model with optional client overrides
|
// Get AI model with optional client overrides
|
||||||
const {
|
const {
|
||||||
model,
|
model: baseModel,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
headers,
|
headers,
|
||||||
modelId,
|
modelId,
|
||||||
provider: resolvedProvider,
|
provider: resolvedProvider,
|
||||||
} = getAIModel(clientOverrides)
|
} = getAIModel(clientOverrides)
|
||||||
|
|
||||||
|
// Retry with a smaller budget if the provider rejects the requested one
|
||||||
|
const model = withOutputTokenLimitFallback(baseModel)
|
||||||
|
|
||||||
|
// User setting wins over server env, so desktop users can raise it themselves
|
||||||
|
const maxOutputTokens = resolveMaxOutputTokens(
|
||||||
|
req.headers.get("x-max-output-tokens"),
|
||||||
|
)
|
||||||
|
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||||
|
|
||||||
// Check if model supports prompt caching
|
// Check if model supports prompt caching
|
||||||
const shouldCache = supportsPromptCaching(modelId)
|
const shouldCache = supportsPromptCaching(modelId)
|
||||||
console.log(
|
console.log(
|
||||||
@@ -496,9 +508,9 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
abortSignal: req.signal,
|
abortSignal: req.signal,
|
||||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||||
}),
|
maxOutputTokens,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||||
@@ -553,6 +565,15 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (toolCall.toolName === "display_diagram") {
|
||||||
|
return {
|
||||||
|
...toolCall,
|
||||||
|
input: {
|
||||||
|
xml: "",
|
||||||
|
_error: "JSON repair failed - empty diagram",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,6 +613,44 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
|
display_diagram: {
|
||||||
|
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
|
||||||
|
|
||||||
|
VALIDATION RULES (XML will be rejected if violated):
|
||||||
|
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||||
|
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||||
|
3. All mxCell elements must be siblings - never nested
|
||||||
|
4. Every mxCell needs a unique id (start from "2")
|
||||||
|
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
|
||||||
|
6. Escape special chars in values: < > & "
|
||||||
|
|
||||||
|
Example (generate ONLY this - no wrapper tags):
|
||||||
|
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||||
|
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||||
|
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- For AWS diagrams, use **AWS 2025 icons**.
|
||||||
|
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||||
|
`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
xml: z
|
||||||
|
.string()
|
||||||
|
.describe("XML string to be displayed on draw.io"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
edit_diagram: {
|
edit_diagram: {
|
||||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||||
|
|
||||||
@@ -634,169 +693,87 @@ Example - Delete container (children & edges auto-deleted):
|
|||||||
.describe("Array of operations to apply"),
|
.describe("Array of operations to apply"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
restructure_diagram: {
|
append_diagram: {
|
||||||
description: `Build or edit a diagram by declaring STRUCTURE. The engine computes every coordinate.
|
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||||
|
|
||||||
PREFER THIS over edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||||
|
|
||||||
The layout model is FLEXBOX. row/col containers nest freely; a box with internal structure is an invisible col container (pad 10-14) holding smaller boxes. Three knobs: grow (columns split leftover WIDTH by weight — grow 3 / grow 2 gives a 3:2 page; for containers in a row, not for leaf boxes), align "stretch" (child fills its column's width; content keeps natural height and packs to the top — the engine leaves leftover vertical space at the bottom, never inflates boxes to fill it, so balance columns by moving content between them), pad (8-14 tight card, default 24 roomy section). Labels take inline HTML — <b>, <i>, <font color="#...">, <br> — so one box carries a bold keyword, a second paragraph, a coloured verdict line. Paragraphs set themselves flush-left automatically; short labels centre. Emoji in headings (💡 Core Idea) read instantly.
|
CRITICAL INSTRUCTIONS:
|
||||||
|
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||||
|
2. Continue from EXACTLY where your previous output stopped
|
||||||
|
3. Complete the remaining mxCell elements
|
||||||
|
4. If still truncated, call append_diagram again with the next fragment
|
||||||
|
|
||||||
DECLARE THE PAGE SHAPE FIRST, with set_page. aspect is width:height — 1 square, 1.4 a landscape slide, 0.75 a portrait poster, 1.6 a wide architecture diagram. This is the one thing that has to come before everything else: it gives the top level a definite width, and until there is one there is no spare room to share out, so grow weights and column fractions have no effect at all. A row that then cannot fit wraps onto a second line rather than running off to the right.
|
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
|
||||||
|
|
||||||
LAYOUT, TYPE AND SURFACE — Tailwind classes. Every add_container and add_box takes class, and it is the preferred way to say these things. Colour is the one thing a class never carries: that comes from role and group.
|
|
||||||
proportion grow-3 / flex-3 / w-2/3 — a column's share of the row. Add min-w-0 to BOTH columns when you want the ratio exactly: without it a column will not shrink below the width of its own text, so a declared 3:1 lands wherever the text allows (this is how flexbox behaves in a browser too).
|
|
||||||
direction flex-row, flex-col (or the dir field, which a class cannot override)
|
|
||||||
cross axis items-stretch on the container (cards all span the same width — this is what makes a column line up), or self-start / self-center / self-end / self-stretch on one child
|
|
||||||
main axis justify-start (default: packed, spare room at the far end) / justify-center / justify-end / justify-between / justify-around / justify-evenly. Reach for justify-between when a short column would otherwise leave a hole at the bottom.
|
|
||||||
spacing gap-4 between children, p-6 inside. Tailwind's 4px scale, so gap-4 is 16px and p-6 is 24px. Use the scale; there is no gap-7.5.
|
|
||||||
width cap max-w-md (448) or max-w-96, up to max-w-4xl. A capped box rewraps its text instead of stretching, which is what stops one long sentence flattening the page. A cap beats grow.
|
|
||||||
type font-bold / font-normal, italic, underline, line-through, text-xs..text-4xl (12/14/16/18/20/24/30/36px), text-left|center|right, align-top|middle|bottom, whitespace-nowrap. An explicit alignment beats the engine's own "this looks like a paragraph" rule, so use text-center when you want a long label centred. line-through is for a superseded or cancelled step.
|
|
||||||
border border or border-N for thickness, border-dashed / border-dotted / border-solid. A dashed frame is the conventional way to draw something planned, optional or purely logical. border-none removes the outline entirely, which is how you draw a plain colour field.
|
|
||||||
corners rounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-3xl, rounded-4xl (4/4/6/8/12/16/24/32px), rounded-full for a capsule, rounded-none for square. Real pixels, so the same class is the same corner on every box. Overrides the corner of a shape that has one, which is what you want on round and terminator.
|
|
||||||
elevation shadow-sm / shadow-md / shadow-lg / shadow-xl, shadow-none. Use it to lift a card off a panel; one level on one group of cards, not on everything.
|
|
||||||
NOT supported, and dropped with a note telling you which: EVERY colour class (bg-*, text-red-*, border-blue-*) and gradients — colour comes from role and group; the seven font weights between font-thin and font-black, because draw.io has one bold bit rather than a weight ladder; opacity-* (Tailwind's is any number, not a scale); truncate (draw.io cannot draw the ellipsis, so text would just be cut); PER-SIDE borders (border-l, border-t-4) — draw.io draws these with a shape called partialRectangle, which would take the place of the node's own shape, and what a node IS matters more than which of its edges show; PER-SIDE padding (pt-4, px-2) — the engine has one padding value, and draw.io's per-side keys pad the LABEL rather than making room for children; per-corner radius (rounded-tl-lg); text-shadow-*; tracking-* and uppercase/lowercase/capitalize and leading-* (draw.io has no letter-spacing, no text-transform and no per-node line height); outline-*, hover:*, responsive prefixes, and all transforms.
|
|
||||||
|
|
||||||
PLAN THE COLUMNS BEFORE THE FIRST OPERATION. The engine places exactly what you declare; a column that runs out of content early leaves a hole at the bottom of the page and nothing later can fill it. So: list each section with a rough character count (heading ~20, paragraph ~its length, comparison card ~the sum of its parts, add_graph ~400); a column twice as wide runs about half as tall, so a column's SHARE OF THE TOTAL CONTENT must match its grow weight — grow 3 beside grow 1 holds about three times the characters, never fewer; add the columns up and check the ratio before emitting anything (1200 vs 1100 chars is grow 1 / grow 1, and wanting grow 3 / grow 1 for 900 vs 1100 means the plan is wrong — move sections across or equalise the weights); a full-width element (masthead, footnote, wide diagram) is its own row above or below the row of columns, never inside one, because a 900-wide diagram in one column forces that column wide and strands the others. State the numbers in your preamble ("left ~N chars / right ~M, so grow X / Y") — writing them down is what catches the mismatch.
|
|
||||||
|
|
||||||
For a POSTER (paper summary, cheat sheet): set_page with aspect 0.75 (portrait) or 1.4 (landscape); one col container as the page with class "gap-4"; a banner box as the masthead with class "self-stretch" (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container class "gap-4" holding 2-4 col containers as columns, each class "grow-N min-w-0 items-stretch"; each section a heading-role box + content boxes. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print. A comparison card: add_container dir=col class="gap-2 p-3 grow-1 items-stretch" role=bad, then a bold title box, the body text, a role=bad answer bar, and a coloured "<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" verdict with class "self-start".
|
|
||||||
{"operations":[
|
|
||||||
{"op":"set_page","aspect":0.8},
|
|
||||||
{"op":"add_container","id":"page","label":"","dir":"col","class":"gap-4"},
|
|
||||||
{"op":"add_box","id":"mast","parent":"page","label":"Chain-of-Thought Prompting","role":"banner","class":"self-stretch"},
|
|
||||||
{"op":"add_container","id":"cols","parent":"page","label":"","dir":"row","class":"gap-4"},
|
|
||||||
{"op":"add_container","id":"left","parent":"cols","label":"","dir":"col","class":"grow-2 min-w-0 gap-3 items-stretch"},
|
|
||||||
{"op":"add_container","id":"right","parent":"cols","label":"","dir":"col","class":"grow-1 min-w-0 gap-3 items-stretch"},
|
|
||||||
{"op":"add_box","id":"h1","parent":"left","label":"What it is","role":"heading","group":"idea"},
|
|
||||||
{"op":"add_box","id":"p1","parent":"left","label":"Ask the model to show its steps...","group":"idea"}
|
|
||||||
]}
|
|
||||||
(Two thirds of the characters go in the grow-2 column, one third in the grow-1 column.)
|
|
||||||
|
|
||||||
Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.
|
|
||||||
|
|
||||||
Operations are applied in order, so you can add a container and fill it in the same call:
|
|
||||||
{"operations":[
|
|
||||||
{"op":"add_container","id":"vpc","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
|
||||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
|
||||||
{"op":"add_icon","id":"ec2","parent":"vpc","name":"ec2","label":"EC2"},
|
|
||||||
{"op":"link","source":"alb","target":"ec2","label":"route","step":1}
|
|
||||||
]}
|
|
||||||
|
|
||||||
Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram.
|
|
||||||
|
|
||||||
CLOUD ARCHITECTURE (AWS/Azure/GCP/Kubernetes) — every zone is a container, and each one's dir is what makes the diagram readable: dir follows the traffic. Nesting is Region -> VPC -> Availability Zone -> Subnet, and managed/global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS, WAF, CloudWatch) sit OUTSIDE the VPC — a regional service inside a subnet states something false about the network. Use dir "row" wherever things are PEERS (availability zones side by side, replicas, a set of regional services) and dir "col" wherever traffic FLOWS THROUGH (the tiers inside one zone: public -> app -> data, top to bottom). Label every zone with its scope ("Availability Zone A", "Private Subnet (App)", "VPC 10.0.0.0/16") — an unlabelled frame makes the reader guess what the boundary means. Put the actor (Users / Internet) OUTSIDE the region as a plain box with shape "person" or "cloud" and link it inwards; it is not infrastructure. Two availability zones is the right default for "a sample architecture" — one reads as a single point of failure, three repeats the same information a third time. Number the request path on the links ("1. HTTPS", "2. forward", "3. route", "4. query") so the reader has an entry point, and make cross-cutting links (replication, telemetry) dashed and unnumbered. Keep each zone to 1-4 icons: one is fine when the boundary itself is the point (a subnet holding one NAT gateway), ten is a wall of icons — split it or use add_grid.
|
|
||||||
{"operations":[
|
|
||||||
{"op":"add_box","id":"users","label":"Users / Internet","shape":"person"},
|
|
||||||
{"op":"add_container","id":"region","label":"Region (ap-southeast-1)","dir":"row","gname":"group_region"},
|
|
||||||
{"op":"add_container","id":"vpc","parent":"region","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
|
||||||
{"op":"add_icon","id":"igw","parent":"vpc","name":"internet_gateway","label":"Internet Gateway"},
|
|
||||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
|
||||||
{"op":"add_container","id":"azs","parent":"vpc","dir":"row"},
|
|
||||||
{"op":"add_container","id":"az_a","parent":"azs","label":"Availability Zone A","dir":"col","gname":"group_availability_zone"},
|
|
||||||
{"op":"add_container","id":"pub_a","parent":"az_a","label":"Public Subnet","dir":"col","gname":"group_subnet"},
|
|
||||||
{"op":"add_icon","id":"nat_a","parent":"pub_a","name":"nat_gateway","label":"NAT Gateway"},
|
|
||||||
{"op":"add_container","id":"app_a","parent":"az_a","label":"Private Subnet (App)","dir":"col","gname":"group_subnet"},
|
|
||||||
{"op":"add_icon","id":"ec2_a","parent":"app_a","name":"ec2","label":"EC2 / ECS"},
|
|
||||||
{"op":"add_container","id":"db_a","parent":"az_a","label":"Private Subnet (Data)","dir":"col","gname":"group_subnet"},
|
|
||||||
{"op":"add_icon","id":"rds_a","parent":"db_a","name":"rds","label":"RDS (Primary)"},
|
|
||||||
{"op":"add_container","id":"reg_svc","parent":"region","label":"Regional / Edge services","dir":"col"},
|
|
||||||
{"op":"add_icon","id":"waf","parent":"reg_svc","name":"waf","label":"AWS WAF"},
|
|
||||||
{"op":"link","source":"users","target":"igw","label":"1. HTTPS"},
|
|
||||||
{"op":"link","source":"igw","target":"alb","label":"2. forward"},
|
|
||||||
{"op":"link","source":"alb","target":"ec2_a","label":"3. route"},
|
|
||||||
{"op":"link","source":"ec2_a","target":"rds_a","label":"4. query"},
|
|
||||||
{"op":"link","source":"rds_a","target":"rds_b","label":"Multi-AZ replication","dashed":true}
|
|
||||||
]}
|
|
||||||
(az_b mirrors az_a, with RDS labelled "(Standby)".)
|
|
||||||
|
|
||||||
CONTAINERS — pick by what the diagram means:
|
|
||||||
|
|
||||||
add_container: children stacked along one axis. dir "row" side by side, "col" one above the next. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame.
|
|
||||||
|
|
||||||
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
|
|
||||||
|
|
||||||
add_graph: an ARROW-ORDERED block. Give it nodes and edges, NO positions and NO nesting: the engine reads the arrows to work out how many rows the diagram has, which nodes share a row, and who goes left of whom — chosen to keep arrows from crossing each other or running through unrelated boxes. Loops and arrows that skip ahead are fine.
|
|
||||||
THIS IS THE ONLY WAY TO DRAW A FLOWCHART. Use it for flowcharts, decision trees, process and approval flows, CI/CD pipelines, state machines, git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams, and any "illustrate how X works" where X is a sequence of steps or states. Never build one out of add_container/add_box by hand: declaring a flowchart as nesting puts every step in one column, so each branch has to jump over the step beside it.
|
|
||||||
Omit parent for a whole-page flowchart; set parent to put a flow inside one zone of a bigger diagram (a pipeline in an architecture diagram, a small flowchart in a poster column), where the block then joins the outer layout like any node. dir: "col" (default) flows down, "row" flows right.
|
|
||||||
Redrawing a whole-page flowchart: send clear first. One new arrow can change which row several nodes belong in, so a flowchart is rebuilt rather than patched.
|
|
||||||
{"operations":[
|
|
||||||
{"op":"clear"},
|
|
||||||
{"op":"add_graph","id":"flow","nodes":[
|
|
||||||
{"id":"start","label":"Order received","shape":"terminator"},
|
|
||||||
{"id":"check","label":"Amount > $1000?","shape":"decision"},
|
|
||||||
{"id":"mgr","label":"Manager approval"},
|
|
||||||
{"id":"auto","label":"Auto-approve"},
|
|
||||||
{"id":"ship","label":"Ship order"}
|
|
||||||
],"edges":[
|
|
||||||
{"source":"start","target":"check"},
|
|
||||||
{"source":"check","target":"mgr","label":"yes"},
|
|
||||||
{"source":"check","target":"auto","label":"no"},
|
|
||||||
{"source":"mgr","target":"ship"},
|
|
||||||
{"source":"auto","target":"ship"}
|
|
||||||
]},
|
|
||||||
{"op":"set_title","title":"Order Approval"}
|
|
||||||
]}
|
|
||||||
Grouping: when the nodes fall into natural zones (remote vs local, frontend vs backend, roles, phases), set the same group name on each zone's nodes and the engine colours each zone consistently. Set icon instead of shape to draw a node as a catalog icon.
|
|
||||||
|
|
||||||
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
|
|
||||||
{"operations":[
|
|
||||||
{"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]},
|
|
||||||
{"op":"add_box","id":"fill","parent":"p","label":"Fill form","lane":0,"col":0,"shape":"terminator"},
|
|
||||||
{"op":"add_box","id":"rev","parent":"p","label":"Review","lane":1,"col":1},
|
|
||||||
{"op":"add_box","id":"ok","parent":"p","label":"Approved?","lane":1,"col":2,"shape":"decision"},
|
|
||||||
{"op":"add_box","id":"pay","parent":"p","label":"Pay out","lane":2,"col":3},
|
|
||||||
{"op":"link","source":"fill","target":"rev"},{"op":"link","source":"rev","target":"ok"},
|
|
||||||
{"op":"link","source":"ok","target":"pay","label":"yes"}
|
|
||||||
]}
|
|
||||||
|
|
||||||
add_sequence: a SEQUENCE diagram. One add_box per participant, left to right in the order they first act; the engine draws each one's lifeline. Every message is a link with a step number giving its order — number them 1, 2, 3… as they happen, and make a reply its own link back. A participant calling itself is a link from a node to itself.
|
|
||||||
{"operations":[
|
|
||||||
{"op":"add_sequence","id":"s","label":"Login flow"},
|
|
||||||
{"op":"add_box","id":"u","parent":"s","label":"User"},
|
|
||||||
{"op":"add_box","id":"api","parent":"s","label":"API"},
|
|
||||||
{"op":"add_box","id":"db","parent":"s","label":"Database"},
|
|
||||||
{"op":"link","source":"u","target":"api","label":"POST /login","step":1},
|
|
||||||
{"op":"link","source":"api","target":"db","label":"find user","step":2},
|
|
||||||
{"op":"link","source":"db","target":"api","label":"user record","step":3},
|
|
||||||
{"op":"link","source":"api","target":"u","label":"JWT","step":4}
|
|
||||||
]}
|
|
||||||
|
|
||||||
add_radial: a MIND MAP or ORG CHART. Add every node with the radial container as its parent — a FLAT list, never nested inside another box — and let the links carry the hierarchy: link parent to child. The node nothing points at becomes the centre. spread "radial" fans branches out both sides (a mind map); "down" hangs everything below its parent (an org chart, where a reporting line only reads correctly downwards).
|
|
||||||
{"operations":[
|
|
||||||
{"op":"add_radial","id":"o","label":"","spread":"down"},
|
|
||||||
{"op":"add_box","id":"ceo","parent":"o","label":"CEO"},
|
|
||||||
{"op":"add_box","id":"cto","parent":"o","label":"CTO"},
|
|
||||||
{"op":"add_box","id":"lead","parent":"o","label":"Platform Lead"},
|
|
||||||
{"op":"link","source":"ceo","target":"cto"},{"op":"link","source":"cto","target":"lead"}
|
|
||||||
]}
|
|
||||||
|
|
||||||
BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminator" for a start/end point, "data" for input or output, "document" for a report, "round" for a soft-edged step. Use them; a reader takes a diamond to mean a choice.`,
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
operations: z
|
xml: z
|
||||||
.array(OperationSchema)
|
|
||||||
.describe("Structural operations, applied in order"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
search_stencils: {
|
|
||||||
description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`,
|
|
||||||
inputSchema: z.object({
|
|
||||||
query: z
|
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"Service name or keyword, e.g. 's3' or 'nat gateway'",
|
"Continuation XML fragment to append (NO wrapper tags)",
|
||||||
),
|
),
|
||||||
kind: z
|
|
||||||
.enum(["icon", "group"])
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Restrict to service icons or container frames",
|
|
||||||
),
|
|
||||||
limit: z.number().optional(),
|
|
||||||
}),
|
}),
|
||||||
execute: async ({ query, kind, limit }) => {
|
},
|
||||||
const hits = searchStencils(query, { kind, limit })
|
get_shape_library: {
|
||||||
if (hits.length === 0)
|
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||||
return `No stencil matches "${query}". Try a shorter or more general term.`
|
|
||||||
return JSON.stringify(hits)
|
Available libraries:
|
||||||
|
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
||||||
|
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||||
|
- Business: bpmn, lean_mapping
|
||||||
|
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||||
|
- UI/Mockups: android, material_design
|
||||||
|
- Enterprise: citrix, sap, mscae, atlassian
|
||||||
|
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||||
|
- Icons: webicons
|
||||||
|
|
||||||
|
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
library: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ library }) => {
|
||||||
|
// Sanitize input - prevent path traversal attacks
|
||||||
|
const sanitizedLibrary = library
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, "")
|
||||||
|
|
||||||
|
if (sanitizedLibrary !== library.toLowerCase()) {
|
||||||
|
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"docs/shape-libraries",
|
||||||
|
)
|
||||||
|
const filePath = path.join(
|
||||||
|
baseDir,
|
||||||
|
`${sanitizedLibrary}.md`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify path stays within expected directory
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
||||||
|
return `Invalid library path.`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, "utf-8")
|
||||||
|
return content
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||||
|
) {
|
||||||
|
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`[get_shape_library] Error loading "${library}":`,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return `Error loading library "${library}". Please try again.`
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export default function ChatPanel({
|
|||||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||||
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
||||||
const [customSystemMessage, setCustomSystemMessage] = useState("")
|
const [customSystemMessage, setCustomSystemMessage] = useState("")
|
||||||
|
const [maxOutputTokens, setMaxOutputTokens] = useState("")
|
||||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||||
|
|
||||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||||
@@ -204,6 +205,14 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load output token budget from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.maxOutputTokens)
|
||||||
|
if (stored !== null) {
|
||||||
|
setMaxOutputTokens(stored)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Check config on mount
|
// Check config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch(getApiEndpoint("/api/config"))
|
||||||
@@ -320,6 +329,13 @@ export default function ChatPanel({
|
|||||||
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
|
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Handler for output token budget change (empty string = use server default)
|
||||||
|
const handleMaxOutputTokensChange = useCallback((value: string) => {
|
||||||
|
const digitsOnly = value.replace(/\D/g, "")
|
||||||
|
setMaxOutputTokens(digitsOnly)
|
||||||
|
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Ref to store the sendMessage function for use in callbacks
|
// Ref to store the sendMessage function for use in callbacks
|
||||||
const sendMessageRef = useRef<typeof sendMessage | null>(null)
|
const sendMessageRef = useRef<typeof sendMessage | null>(null)
|
||||||
|
|
||||||
@@ -337,7 +353,7 @@ export default function ChatPanel({
|
|||||||
// VLM validation hook using AI SDK's useObject
|
// VLM validation hook using AI SDK's useObject
|
||||||
const { validateWithFallback } = useValidateDiagram()
|
const { validateWithFallback } = useValidateDiagram()
|
||||||
|
|
||||||
// Diagram tool handlers (edit_diagram, restructure_diagram, cached replay)
|
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||||
const { handleToolCall } = useDiagramToolHandlers({
|
const { handleToolCall } = useDiagramToolHandlers({
|
||||||
partialXmlRef,
|
partialXmlRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
@@ -1104,6 +1120,9 @@ export default function ChatPanel({
|
|||||||
...(minimalStyle && {
|
...(minimalStyle && {
|
||||||
"x-minimal-style": "true",
|
"x-minimal-style": "true",
|
||||||
}),
|
}),
|
||||||
|
...(maxOutputTokens && {
|
||||||
|
"x-max-output-tokens": maxOutputTokens,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1448,6 +1467,8 @@ export default function ChatPanel({
|
|||||||
onVlmValidationChange={handleVlmValidationChange}
|
onVlmValidationChange={handleVlmValidationChange}
|
||||||
customSystemMessage={customSystemMessage}
|
customSystemMessage={customSystemMessage}
|
||||||
onCustomSystemMessageChange={handleCustomSystemMessageChange}
|
onCustomSystemMessageChange={handleCustomSystemMessageChange}
|
||||||
|
maxOutputTokens={maxOutputTokens}
|
||||||
|
onMaxOutputTokensChange={handleMaxOutputTokensChange}
|
||||||
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
|||||||
import type { Dispatch, SetStateAction } from "react"
|
import type { Dispatch, SetStateAction } from "react"
|
||||||
import { CodeBlock } from "@/components/code-block"
|
import { CodeBlock } from "@/components/code-block"
|
||||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||||
import type {
|
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||||
DiagramOperation,
|
|
||||||
StructureOperation,
|
|
||||||
ToolPartLike,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
interface ToolCallCardProps {
|
interface ToolCallCardProps {
|
||||||
part: ToolPartLike
|
part: ToolPartLike
|
||||||
@@ -23,137 +19,31 @@ interface ToolCallCardProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Colour an operation by what it does to the diagram: removes, adds, or changes.
|
|
||||||
*
|
|
||||||
* Takes an unknown rather than a string because this renders DURING streaming: the tool
|
|
||||||
* input arrives character by character, so an operation is briefly `{}` or `{"op": "add_c`
|
|
||||||
* before it is whole. A missing name is the normal mid-stream state, not an error.
|
|
||||||
*/
|
|
||||||
function opColour(op: unknown): string {
|
|
||||||
if (typeof op !== "string") return "text-muted-foreground"
|
|
||||||
if (op === "delete" || op === "remove" || op === "unlink" || op === "clear")
|
|
||||||
return "text-red-600"
|
|
||||||
if (op.startsWith("add") || op === "link") return "text-green-600"
|
|
||||||
return "text-blue-600"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The arguments worth showing beside an operation's name.
|
|
||||||
*
|
|
||||||
* A whitelist rather than "everything except op and id", because some operations carry a
|
|
||||||
* whole nested graph (add_graph's nodes and edges) and dumping that turns one line into a
|
|
||||||
* screenful. The excluded keys are summarised instead.
|
|
||||||
*/
|
|
||||||
const SHOWN_KEYS = [
|
|
||||||
"label",
|
|
||||||
"name",
|
|
||||||
"parent",
|
|
||||||
"dir",
|
|
||||||
"class",
|
|
||||||
"role",
|
|
||||||
"group",
|
|
||||||
"shape",
|
|
||||||
"cols",
|
|
||||||
"lanes",
|
|
||||||
"aspect",
|
|
||||||
"source",
|
|
||||||
"target",
|
|
||||||
"title",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function summarise(op: StructureOperation | undefined | null): string {
|
|
||||||
if (!op || typeof op !== "object") return ""
|
|
||||||
const parts: string[] = []
|
|
||||||
for (const key of SHOWN_KEYS) {
|
|
||||||
const v = op[key]
|
|
||||||
if (v === undefined || v === null || v === "") continue
|
|
||||||
parts.push(
|
|
||||||
`${key}=${Array.isArray(v) ? v.join("/") : String(v).slice(0, 60)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// A graph carries its own nodes and edges; report the size, not the contents.
|
|
||||||
const nodes = op.nodes
|
|
||||||
const edges = op.edges
|
|
||||||
if (Array.isArray(nodes))
|
|
||||||
parts.push(
|
|
||||||
`${nodes.length} node${nodes.length === 1 ? "" : "s"}${
|
|
||||||
Array.isArray(edges)
|
|
||||||
? `, ${edges.length} edge${edges.length === 1 ? "" : "s"}`
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
return parts.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `restructure_diagram`'s operations: structural steps, not XML patches.
|
|
||||||
*
|
|
||||||
* Written to survive PARTIAL data. This renders while the tool input is still streaming, so
|
|
||||||
* an entry may be `{}`, or `{op: "add_contai"}`, or — because a JSON array is repaired as it
|
|
||||||
* arrives — `undefined`. Every field is therefore treated as possibly absent rather than
|
|
||||||
* validated up front: dropping incomplete entries would make rows appear and disappear as
|
|
||||||
* the text arrives, and asserting on them crashes the whole message.
|
|
||||||
*/
|
|
||||||
function StructureOperationsDisplay({
|
|
||||||
operations,
|
|
||||||
}: {
|
|
||||||
operations: StructureOperation[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{operations.map((op, index) => (
|
|
||||||
<div
|
|
||||||
key={`${op?.op ?? "pending"}-${op?.id ?? index}-${index}`}
|
|
||||||
className="flex items-baseline gap-2 px-2 py-1 rounded bg-background/50 border border-border/40"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide shrink-0 ${opColour(op?.op)}`}
|
|
||||||
>
|
|
||||||
{op?.op ?? "…"}
|
|
||||||
</span>
|
|
||||||
{op?.id && (
|
|
||||||
<span className="text-xs font-mono text-foreground/80 shrink-0">
|
|
||||||
{op.id}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[11px] text-muted-foreground font-mono break-all">
|
|
||||||
{summarise(op)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `edit_diagram`'s operations. Also streamed, so also written for partial entries. */
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{operations.map((op, index) => (
|
{operations.map((op, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${op?.operation ?? "pending"}-${op?.cell_id ?? index}-${index}`}
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
op?.operation === "delete"
|
op.operation === "delete"
|
||||||
? "text-red-600"
|
? "text-red-600"
|
||||||
: op?.operation === "add"
|
: op.operation === "add"
|
||||||
? "text-green-600"
|
? "text-green-600"
|
||||||
: "text-blue-600"
|
: "text-blue-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{op?.operation ?? "…"}
|
{op.operation}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
cell_id: {op.cell_id}
|
||||||
</span>
|
</span>
|
||||||
{op?.cell_id && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
cell_id: {op.cell_id}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{op?.new_xml && (
|
{op.new_xml && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
{op.new_xml}
|
{op.new_xml}
|
||||||
@@ -191,16 +81,12 @@ export function ToolCallCard({
|
|||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
const getToolDisplayName = (name: string) => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "restructure_diagram":
|
|
||||||
return "Build Diagram"
|
|
||||||
case "edit_diagram":
|
|
||||||
return "Edit Diagram"
|
|
||||||
case "search_stencils":
|
|
||||||
return "Find Icons"
|
|
||||||
// Only ever arrives from the server's cache-hit path now; the model cannot
|
|
||||||
// call it. See createCachedStreamResponse in app/api/chat/route.ts.
|
|
||||||
case "display_diagram":
|
case "display_diagram":
|
||||||
return "Generate Diagram"
|
return "Generate Diagram"
|
||||||
|
case "edit_diagram":
|
||||||
|
return "Edit Diagram"
|
||||||
|
case "get_shape_library":
|
||||||
|
return "Get Shape Library"
|
||||||
default:
|
default:
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@@ -219,6 +105,14 @@ export function ToolCallCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
typeof output === "string"
|
||||||
|
) {
|
||||||
|
textToCopy = output
|
||||||
|
}
|
||||||
|
|
||||||
if (textToCopy) {
|
if (textToCopy) {
|
||||||
onCopy(callId, textToCopy, true)
|
onCopy(callId, textToCopy, true)
|
||||||
}
|
}
|
||||||
@@ -268,11 +162,10 @@ export function ToolCallCard({
|
|||||||
)}
|
)}
|
||||||
{state === "output-error" &&
|
{state === "output-error" &&
|
||||||
(() => {
|
(() => {
|
||||||
// Truncation only applies to a tool that streams raw XML, which
|
// Check if this is a truncation (incomplete XML) vs real error
|
||||||
// is now just the cached-answer replay. The engine tools send
|
|
||||||
// structured operations, so a failure there is a real error.
|
|
||||||
const isTruncated =
|
const isTruncated =
|
||||||
toolName === "display_diagram" &&
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
!isMxCellXmlComplete(input?.xml)
|
!isMxCellXmlComplete(input?.xml)
|
||||||
return isTruncated ? (
|
return isTruncated ? (
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||||
@@ -321,23 +214,7 @@ export function ToolCallCard({
|
|||||||
) : typeof input === "object" &&
|
) : typeof input === "object" &&
|
||||||
input.operations &&
|
input.operations &&
|
||||||
Array.isArray(input.operations) ? (
|
Array.isArray(input.operations) ? (
|
||||||
// Dispatch by TOOL, not by whether an `operations` key exists: both
|
<OperationsDisplay operations={input.operations} />
|
||||||
// tools call their argument that, but the items have different shapes
|
|
||||||
// (op/id versus operation/cell_id), and reading one as the other
|
|
||||||
// printed a row of blank `cell_id:` labels.
|
|
||||||
toolName === "restructure_diagram" ? (
|
|
||||||
<StructureOperationsDisplay
|
|
||||||
operations={
|
|
||||||
input.operations as StructureOperation[]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<OperationsDisplay
|
|
||||||
operations={
|
|
||||||
input.operations as DiagramOperation[]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" &&
|
||||||
Object.keys(input).length > 0 ? (
|
Object.keys(input).length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
@@ -351,7 +228,8 @@ export function ToolCallCard({
|
|||||||
state === "output-error" &&
|
state === "output-error" &&
|
||||||
(() => {
|
(() => {
|
||||||
const isTruncated =
|
const isTruncated =
|
||||||
toolName === "display_diagram" &&
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
!isMxCellXmlComplete(input?.xml)
|
!isMxCellXmlComplete(input?.xml)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -363,21 +241,25 @@ export function ToolCallCard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
{/* What the tool actually returned. Worth showing on success, not only on
|
{/* Show get_shape_library output on success */}
|
||||||
error: restructure_diagram answers with an outline of the structure it
|
{output &&
|
||||||
built plus any notes about classes it could not honour, and that is the
|
toolName === "get_shape_library" &&
|
||||||
same text the model reads to name ids in its next call. */}
|
state === "output-available" &&
|
||||||
{output && state === "output-available" && isExpanded && (
|
isExpanded && (
|
||||||
<div className="px-4 py-3 border-t border-border/40">
|
<div className="px-4 py-3 border-t border-border/40">
|
||||||
<pre className="text-[11px] font-mono text-muted-foreground bg-muted/40 rounded-md p-2 overflow-auto max-h-64 whitespace-pre-wrap break-all">
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
{typeof output === "string"
|
Library loaded (
|
||||||
? output.length > 4000
|
{typeof output === "string" ? output.length : 0}{" "}
|
||||||
? `${output.slice(0, 4000)}\n…`
|
chars)
|
||||||
: output
|
</div>
|
||||||
: String(output)}
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||||
</pre>
|
{typeof output === "string"
|
||||||
</div>
|
? output.substring(0, 800) +
|
||||||
)}
|
(output.length > 800 ? "\n..." : "")
|
||||||
|
: String(output)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
/** An `edit_diagram` operation: a patch against one cell, addressed by its id. */
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
operation: "update" | "add" | "delete"
|
operation: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A `restructure_diagram` operation.
|
|
||||||
*
|
|
||||||
* Deliberately loose. The engine owns the real schema (lib/diagram-engine/operations.ts)
|
|
||||||
* and it has two dozen variants; the card only needs to say WHAT each step did, so it
|
|
||||||
* reads the two fields every variant shares and picks a few recognisable extras out of
|
|
||||||
* the rest. Mirroring the full union here would mean editing this file every time the
|
|
||||||
* engine gains an operation.
|
|
||||||
*
|
|
||||||
* The field names matter: `op`/`id`, where edit_diagram has `operation`/`cell_id`. Both
|
|
||||||
* tools happen to call their argument `operations`, which is what let the card render one
|
|
||||||
* as the other and print six blank `cell_id:` lines.
|
|
||||||
*/
|
|
||||||
export interface StructureOperation {
|
|
||||||
op: string
|
|
||||||
id?: string
|
|
||||||
label?: string
|
|
||||||
parent?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolPartLike {
|
export interface ToolPartLike {
|
||||||
type: string
|
type: string
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
state?: string
|
state?: string
|
||||||
input?: {
|
input?: {
|
||||||
xml?: string
|
xml?: string
|
||||||
operations?: DiagramOperation[] | StructureOperation[]
|
operations?: DiagramOperation[]
|
||||||
} & Record<string, unknown>
|
} & Record<string, unknown>
|
||||||
output?: string
|
output?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ interface SettingsDialogProps {
|
|||||||
onOpenModelConfig?: () => void
|
onOpenModelConfig?: () => void
|
||||||
customSystemMessage?: string
|
customSystemMessage?: string
|
||||||
onCustomSystemMessageChange?: (value: string) => void
|
onCustomSystemMessageChange?: (value: string) => void
|
||||||
|
maxOutputTokens?: string
|
||||||
|
onMaxOutputTokensChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
@@ -101,6 +103,8 @@ function SettingsContent({
|
|||||||
onOpenModelConfig,
|
onOpenModelConfig,
|
||||||
customSystemMessage = "",
|
customSystemMessage = "",
|
||||||
onCustomSystemMessageChange = () => {},
|
onCustomSystemMessageChange = () => {},
|
||||||
|
maxOutputTokens = "",
|
||||||
|
onMaxOutputTokensChange = () => {},
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -591,6 +595,24 @@ function SettingsContent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Max Output Tokens */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.maxOutputTokens}
|
||||||
|
description={dict.settings.maxOutputTokensDescription}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="max-output-tokens"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={maxOutputTokens}
|
||||||
|
onChange={(e) =>
|
||||||
|
onMaxOutputTokensChange(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="64000"
|
||||||
|
className="h-9 w-28 text-sm"
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
{/* Send Shortcut */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.sendShortcut}
|
label={dict.settings.sendShortcut}
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||||
- **自动计算布局**:画架构图、流程图、泳道图/BPMN、时序图、思维导图和组织架构图时,AI 只描述结构——谁包含谁,或者谁指向谁——所有坐标、尺寸和连线路径都由程序计算。容器一定装得下里面的内容,同层元素不会重叠,连线会绕开本来会穿过的图形。之后您手动移动或改色的部分会被当作图表的一部分读回来,所以后续修改不会覆盖掉您的调整。
|
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
## MCP服务器
|
## MCP服务器
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||||
- **レイアウトの自動計算**:アーキテクチャ図、フローチャート、スイムレーン図/BPMN、シーケンス図、マインドマップ、組織図では、AI は構造だけ——何が何を含むか、何が何を指すか——を指定し、座標・サイズ・矢印の経路はすべてアプリが計算します。コンテナは必ず中身が収まるサイズになり、同じ階層の要素が重なることはなく、矢印は本来通り抜けてしまう図形を避けて引かれます。その後に手で動かしたり色を変えた部分は図の一部として読み戻されるため、次の編集で元に戻されることはありません。
|
|
||||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|
||||||
## MCPサーバー
|
## MCPサーバー
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ AI_PROVIDER=bedrock
|
|||||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
|
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
|
||||||
|
# so a thinking model can spend it all before the tool call. Users can override it in Settings.
|
||||||
|
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
|
||||||
|
# MAX_OUTPUT_TOKENS=64000
|
||||||
|
|
||||||
# AWS Bedrock Configuration
|
# AWS Bedrock Configuration
|
||||||
# AWS_REGION=us-east-1
|
# AWS_REGION=us-east-1
|
||||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import type {
|
|||||||
ValidationState,
|
ValidationState,
|
||||||
ValidationStatus,
|
ValidationStatus,
|
||||||
} from "@/components/chat/ValidationCard"
|
} from "@/components/chat/ValidationCard"
|
||||||
import type { Operation } from "@/lib/diagram-engine"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||||
import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
|
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
@@ -66,8 +64,7 @@ interface UseDiagramToolHandlersParams {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that creates the onToolCall handler for diagram-related tools.
|
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||||
* Handles edit_diagram and restructure_diagram, plus the cached-XML replay that arrives
|
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||||
* as display_diagram.
|
|
||||||
*
|
*
|
||||||
* Note: addToolOutput is passed at call time (not hook init) because
|
* Note: addToolOutput is passed at call time (not hook init) because
|
||||||
* it comes from useChat which creates a circular dependency.
|
* it comes from useChat which creates a circular dependency.
|
||||||
@@ -121,36 +118,259 @@ export function useDiagramToolHandlers({
|
|||||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||||
} else if (toolCall.toolName === "edit_diagram") {
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
await handleEditDiagram(toolCall, addToolOutput)
|
await handleEditDiagram(toolCall, addToolOutput)
|
||||||
} else if (toolCall.toolName === "restructure_diagram") {
|
} else if (toolCall.toolName === "append_diagram") {
|
||||||
await handleRestructureDiagram(toolCall, addToolOutput)
|
handleAppendDiagram(toolCall, addToolOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replays a cached XML answer onto the canvas. The model can no longer call this tool —
|
|
||||||
// it only arrives from the server's cache-hit path (see createCachedStreamResponse), which
|
|
||||||
// speaks the same wire format. So there is no truncation to continue and no model to send
|
|
||||||
// errors back to: load it, or report that it did not load.
|
|
||||||
const handleDisplayDiagram = async (
|
const handleDisplayDiagram = async (
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
) => {
|
) => {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
const validationError = onDisplayChart(wrapWithMxFile(xml))
|
|
||||||
if (validationError) {
|
// DEBUG: Log raw input to diagnose false truncation detection
|
||||||
console.warn("[display_diagram] Validation error:", validationError)
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] XML ending (last 100 chars):",
|
||||||
|
xml.slice(-100),
|
||||||
|
)
|
||||||
|
console.log("[display_diagram] XML length:", xml.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||||
|
const isTruncated = !isMxCellXmlComplete(xml)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTruncated) {
|
||||||
|
// Store the partial XML for continuation via append_diagram
|
||||||
|
partialXmlRef.current = xml
|
||||||
|
|
||||||
|
// Tell LLM to use append_diagram to continue
|
||||||
|
const partialEnding = partialXmlRef.current.slice(-500)
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "display_diagram",
|
tool: "display_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
state: "output-error",
|
||||||
errorText: validationError,
|
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||||
|
|
||||||
|
Your output ended with:
|
||||||
|
\`\`\`
|
||||||
|
${partialEnding}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
NEXT STEP: Call append_diagram with the continuation XML.
|
||||||
|
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||||
|
- Start from EXACTLY where you stopped
|
||||||
|
- Complete all remaining mxCell elements`,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
// Complete XML received - use it directly
|
||||||
toolCallId: toolCall.toolCallId,
|
// (continuation is now handled via append_diagram tool)
|
||||||
output: "Successfully displayed the diagram.",
|
const finalXml = xml
|
||||||
})
|
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||||
|
|
||||||
|
// Wrap raw XML with full mxfile structure for draw.io
|
||||||
|
const fullXml = wrapWithMxFile(finalXml)
|
||||||
|
|
||||||
|
// loadDiagram validates and returns error if invalid
|
||||||
|
const validationError = onDisplayChart(fullXml)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
console.warn("[display_diagram] Validation error:", validationError)
|
||||||
|
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Adding tool output with state: output-error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `${validationError}
|
||||||
|
|
||||||
|
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||||
|
|
||||||
|
Your failed XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${finalXml}
|
||||||
|
\`\`\``,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Success - diagram will be rendered by chat-message-display
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VLM validation after successful display
|
||||||
|
if (
|
||||||
|
enableVlmValidation &&
|
||||||
|
captureValidationPng &&
|
||||||
|
validateDiagram
|
||||||
|
) {
|
||||||
|
let capturedPngData: string | null = null
|
||||||
|
try {
|
||||||
|
// Notify UI that we're starting capture
|
||||||
|
updateValidationState(toolCall.toolCallId, "capturing")
|
||||||
|
|
||||||
|
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
||||||
|
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
capturedPngData = await captureValidationPng()
|
||||||
|
if (capturedPngData) {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Captured PNG for validation",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryCount =
|
||||||
|
validationRetryCountRef.current.get(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
) || 0
|
||||||
|
|
||||||
|
// Notify UI that we're validating (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"validating",
|
||||||
|
{
|
||||||
|
attempt: retryCount + 1,
|
||||||
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
|
imageData: capturedPngData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await validateDiagram(
|
||||||
|
capturedPngData,
|
||||||
|
sessionId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||||
|
validationRetryCountRef.current.set(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
retryCount + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const feedback =
|
||||||
|
formatValidationFeedback(result)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||||
|
result.issues,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify UI of validation failure (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"failed",
|
||||||
|
{
|
||||||
|
attempt: retryCount + 1,
|
||||||
|
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||||
|
result,
|
||||||
|
imageData: capturedPngData,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Max retries reached - accept the diagram with warning
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
validationRetryCountRef.current.delete(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notify UI that we're accepting with issues (include the image)
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
"skipped",
|
||||||
|
{ result, imageData: capturedPngData },
|
||||||
|
)
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Diagram displayed (validation issues noted but max retries reached).",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validation passed - clean up retry count
|
||||||
|
validationRetryCountRef.current.delete(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Validation passed!",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify UI of success (include the image)
|
||||||
|
// Use "success_with_warnings" if valid but has issues
|
||||||
|
const hasWarnings = result.issues.length > 0
|
||||||
|
updateValidationState(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
hasWarnings
|
||||||
|
? "success_with_warnings"
|
||||||
|
: "success",
|
||||||
|
{ result, imageData: capturedPngData },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// PNG capture failed - skip validation
|
||||||
|
updateValidationState(toolCall.toolCallId, "skipped")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// VLM validation error - log but don't block the user
|
||||||
|
console.warn(
|
||||||
|
"[display_diagram] VLM validation error:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
updateValidationState(toolCall.toolCallId, "error", {
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Validation failed",
|
||||||
|
imageData: capturedPngData || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Adding tool output with state: output-available",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Successfully displayed the diagram.",
|
||||||
|
})
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditDiagram = async (
|
const handleEditDiagram = async (
|
||||||
@@ -263,77 +483,97 @@ Current diagram XML:
|
|||||||
${currentXml || "No XML available"}
|
${currentXml || "No XML available"}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Please check cell IDs and retry, or rebuild with restructure_diagram.`,
|
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||||
})
|
})
|
||||||
// Clean up the shared original XML ref even on error
|
// Clean up the shared original XML ref even on error
|
||||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const handleAppendDiagram = (
|
||||||
* Structural editing. The model sends operations against the tree; the engine
|
|
||||||
* re-derives that tree from whatever is on the canvas right now — including anything
|
|
||||||
* the user moved or recoloured by hand — applies the operations, recomputes every
|
|
||||||
* coordinate, and returns new XML.
|
|
||||||
*
|
|
||||||
* Nothing about the tree is stored between calls, so there is no second copy of the
|
|
||||||
* state to drift out of sync with the canvas.
|
|
||||||
*/
|
|
||||||
const handleRestructureDiagram = async (
|
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
) => {
|
) => {
|
||||||
const { operations } = toolCall.input as { operations: Operation[] }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
// Read the live canvas, not the last thing we generated: the user may have
|
// Detect if LLM incorrectly started fresh instead of continuing
|
||||||
// edited it since.
|
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||||
let currentXml = ""
|
const trimmed = xml.trim()
|
||||||
try {
|
const isFreshStart =
|
||||||
currentXml = await onFetchChart(false)
|
trimmed.startsWith("<mxGraphModel") ||
|
||||||
} catch {
|
trimmed.startsWith("<root") ||
|
||||||
currentXml = chartXMLRef.current ?? ""
|
trimmed.startsWith("<mxfile") ||
|
||||||
}
|
trimmed.startsWith('<mxCell id="0"') ||
|
||||||
if (!isRealDiagram(currentXml)) currentXml = ""
|
trimmed.startsWith('<mxCell id="1"')
|
||||||
|
|
||||||
const result = restructureDiagram(currentXml, operations)
|
if (isFreshStart) {
|
||||||
|
|
||||||
if (result.errors.length > 0 || !result.xml) {
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "restructure_diagram",
|
tool: "append_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
state: "output-error",
|
||||||
errorText: `Could not apply the operations:
|
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||||
${result.errors.map((e) => `- ${e}`).join("\n")}
|
|
||||||
|
|
||||||
Structure as it stands:
|
Continue from EXACTLY where the partial ended:
|
||||||
${result.outline}
|
\`\`\`
|
||||||
|
${partialXmlRef.current.slice(-500)}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
Fix the operations and call restructure_diagram again.`,
|
Start your continuation with the NEXT character after where it stopped.`,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadError = onDisplayChart(result.xml)
|
// Append to accumulated XML
|
||||||
if (loadError) {
|
partialXmlRef.current += xml
|
||||||
|
|
||||||
|
// Check if XML is now complete (last mxCell is complete)
|
||||||
|
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
// Wrap and display the complete diagram
|
||||||
|
const finalXml = partialXmlRef.current
|
||||||
|
partialXmlRef.current = "" // Reset
|
||||||
|
|
||||||
|
const fullXml = wrapWithMxFile(finalXml)
|
||||||
|
const validationError = onDisplayChart(fullXml)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `Validation error after assembly: ${validationError}
|
||||||
|
|
||||||
|
Assembled XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${finalXml.substring(0, 2000)}...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please use display_diagram with corrected XML.`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Diagram assembly complete and displayed successfully.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Still incomplete - signal to continue
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "restructure_diagram",
|
tool: "append_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
state: "output-error",
|
state: "output-error",
|
||||||
errorText: `The diagram was built but draw.io rejected it: ${loadError}`,
|
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report the outline rather than the XML: it is what the model needs to name ids
|
Current ending:
|
||||||
// in the next call, at a fraction of the tokens.
|
\`\`\`
|
||||||
const notes = result.warnings.length
|
${partialXmlRef.current.slice(-500)}
|
||||||
? `\n\nNotes:\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
\`\`\`
|
||||||
: ""
|
|
||||||
addToolOutput({
|
Continue from EXACTLY where you stopped.`,
|
||||||
tool: "restructure_diagram",
|
})
|
||||||
toolCallId: toolCall.toolCallId,
|
}
|
||||||
output: `Diagram updated.\n\n${result.outline}${notes}`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleToolCall }
|
return { handleToolCall }
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
/**
|
|
||||||
* The stencil catalog: a name → verbatim draw.io style map.
|
|
||||||
*
|
|
||||||
* This is the anti-hallucination layer. The model asks for `icon("s3")`; the engine
|
|
||||||
* looks the name up here and gets the exact style draw.io ships, including the official
|
|
||||||
* category colour, the connection points and `aspect=fixed`. A name that is not in the
|
|
||||||
* catalog fails at build time with a suggestion, rather than becoming an empty square in
|
|
||||||
* the rendered diagram — which is what happens when a model writes
|
|
||||||
* `resIcon=mxgraph.aws4.s3_bucket_thing` by hand and nothing checks it.
|
|
||||||
*
|
|
||||||
* The styles are verbatim from draw.io's own shape index (via drawio-ai-kit, which
|
|
||||||
* generated them from jgraph/drawio-mcp's index, Apache-2.0). Nothing here is
|
|
||||||
* hand-assembled, so there is no chance of a plausible-looking but wrong colour.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import stencils from "./data/aws-stencils.json"
|
|
||||||
|
|
||||||
const ICONS = stencils.icons as Record<string, string>
|
|
||||||
const GROUPS = stencils.groups as Record<string, string>
|
|
||||||
|
|
||||||
export interface CatalogEntry {
|
|
||||||
name: string
|
|
||||||
kind: "icon" | "group"
|
|
||||||
style: string
|
|
||||||
/** Official colour from the style, for showing the model what it is getting. */
|
|
||||||
color: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorOf(style: string): string | null {
|
|
||||||
return style.match(/(?:^|;)fillColor=([^;]+)/)?.[1] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exact lookup. Returns null for an unknown name — never a guess. */
|
|
||||||
export function lookupStencil(
|
|
||||||
name: string,
|
|
||||||
kind?: "icon" | "group",
|
|
||||||
): CatalogEntry | null {
|
|
||||||
if (kind !== "group" && ICONS[name])
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
kind: "icon",
|
|
||||||
style: ICONS[name],
|
|
||||||
color: colorOf(ICONS[name]),
|
|
||||||
}
|
|
||||||
if (kind !== "icon" && GROUPS[name])
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
kind: "group",
|
|
||||||
style: GROUPS[name],
|
|
||||||
color: colorOf(GROUPS[name]),
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The resolver the renderer takes, so the engine itself does not depend on the catalog. */
|
|
||||||
export function resolveStyle(
|
|
||||||
name: string,
|
|
||||||
kind: "icon" | "group",
|
|
||||||
): string | null {
|
|
||||||
return lookupStencil(name, kind)?.style ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalise for matching: lowercase, and non-alphanumerics collapsed to single spaces. */
|
|
||||||
function norm(s: string): string {
|
|
||||||
return s
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, " ")
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorthand people type, mapped to words that actually appear in a catalog name.
|
|
||||||
*
|
|
||||||
* The direction matters: the target has to exist in the catalog. AWS's own stencil names
|
|
||||||
* are already abbreviated — EKS is `eks`, not `elastic_kubernetes_service`, and nothing
|
|
||||||
* in the catalog contains the word "kubernetes" at all — so expanding an abbreviation
|
|
||||||
* into its full product name finds nothing. These entries go the other way, from a
|
|
||||||
* spoken-out name or a nickname to the token the catalog uses.
|
|
||||||
*/
|
|
||||||
const ALIASES: Record<string, string> = {
|
|
||||||
k8s: "eks",
|
|
||||||
kubernetes: "eks",
|
|
||||||
kube: "eks",
|
|
||||||
alb: "application load balancer",
|
|
||||||
nlb: "network load balancer",
|
|
||||||
elb: "elastic load balancing",
|
|
||||||
asg: "auto scaling",
|
|
||||||
apigw: "api gateway",
|
|
||||||
cf: "cloudfront",
|
|
||||||
cw: "cloudwatch",
|
|
||||||
ddb: "dynamodb",
|
|
||||||
tgw: "transit gateway",
|
|
||||||
igw: "internet gateway",
|
|
||||||
r53: "route 53",
|
|
||||||
iam: "identity and access management",
|
|
||||||
kms: "key management service",
|
|
||||||
postgres: "rds",
|
|
||||||
postgresql: "rds",
|
|
||||||
mysql: "rds",
|
|
||||||
aurora: "aurora",
|
|
||||||
bucket: "s3",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Score one entry against the query tokens. Higher is better; 0 means no match.
|
|
||||||
*
|
|
||||||
* The extra-words penalty is what makes "s3" return `s3` rather than
|
|
||||||
* `backup_aws_backup_support_for_amazon_s3` — both contain the token, so without it the
|
|
||||||
* winner comes down to iteration order. It counts only the words the query did NOT ask
|
|
||||||
* for, so a deliberately multi-word query like "nat gateway" is not punished for being
|
|
||||||
* specific.
|
|
||||||
*/
|
|
||||||
function score(name: string, qTokens: string[], qJoined: string): number {
|
|
||||||
const n = norm(name)
|
|
||||||
const words = n.split(" ")
|
|
||||||
let s = 0
|
|
||||||
if (n === qJoined) s += 100
|
|
||||||
if (n.replace(/ /g, "") === qJoined.replace(/ /g, "")) s += 60
|
|
||||||
for (const t of qTokens) {
|
|
||||||
if (words.includes(t)) s += 25
|
|
||||||
else if (n.includes(t)) s += 12
|
|
||||||
}
|
|
||||||
if (s === 0) return 0
|
|
||||||
const extra = words.filter((w) => !qTokens.includes(w)).length
|
|
||||||
return s - Math.min(24, extra * 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchHit {
|
|
||||||
name: string
|
|
||||||
kind: "icon" | "group"
|
|
||||||
color: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find stencils by keyword.
|
|
||||||
*
|
|
||||||
* Returns names and colours only, not styles. The model builds with `icon("<name>")` and
|
|
||||||
* the engine resolves the style itself, so sending the style — around 600 characters per
|
|
||||||
* AWS entry, and 20KB+ for an Azure one with an embedded image — would be pure context
|
|
||||||
* burn.
|
|
||||||
*/
|
|
||||||
export function searchStencils(
|
|
||||||
query: string,
|
|
||||||
opts: { limit?: number; kind?: "icon" | "group" } = {},
|
|
||||||
): SearchHit[] {
|
|
||||||
const limit = opts.limit ?? 8
|
|
||||||
const tokens = norm(query)
|
|
||||||
.split(" ")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((t) => ALIASES[t] ?? t)
|
|
||||||
.flatMap((t) => t.split(" "))
|
|
||||||
if (tokens.length === 0) return []
|
|
||||||
const joined = tokens.join(" ")
|
|
||||||
|
|
||||||
const pool: [string, string, "icon" | "group"][] = []
|
|
||||||
if (opts.kind !== "group")
|
|
||||||
for (const [n, st] of Object.entries(ICONS)) pool.push([n, st, "icon"])
|
|
||||||
if (opts.kind !== "icon")
|
|
||||||
for (const [n, st] of Object.entries(GROUPS))
|
|
||||||
pool.push([n, st, "group"])
|
|
||||||
|
|
||||||
return pool
|
|
||||||
.map(([name, style, kind]) => ({
|
|
||||||
name,
|
|
||||||
kind,
|
|
||||||
color: colorOf(style),
|
|
||||||
s: score(name, tokens, joined),
|
|
||||||
}))
|
|
||||||
.filter((r) => r.s > 0)
|
|
||||||
.sort((a, b) => b.s - a.s || a.name.length - b.name.length)
|
|
||||||
.slice(0, limit)
|
|
||||||
.map(({ name, kind, color }) => ({ name, kind, color }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suggest real names for one that does not exist.
|
|
||||||
*
|
|
||||||
* Plain search is not quite the right tool here. A model that writes
|
|
||||||
* `s3_bucket_storage` most likely meant `s3`, but searching that whole phrase ranks
|
|
||||||
* `s3_storage_lens` first — it matches more of the query. So we also search the
|
|
||||||
* leading token on its own and put those hits first: an invented name is usually a
|
|
||||||
* real service name with extra words stuck on the end.
|
|
||||||
*/
|
|
||||||
function suggestFor(name: string, kind: "icon" | "group"): string[] {
|
|
||||||
const words = norm(name.replace(/_/g, " ")).split(" ").filter(Boolean)
|
|
||||||
const out: string[] = []
|
|
||||||
const add = (hits: SearchHit[]) => {
|
|
||||||
for (const h of hits) if (!out.includes(h.name)) out.push(h.name)
|
|
||||||
}
|
|
||||||
if (words.length > 1) add(searchStencils(words[0], { limit: 2, kind }))
|
|
||||||
add(searchStencils(words.join(" "), { limit: 3, kind }))
|
|
||||||
return out.slice(0, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the icon names in a tree before laying it out, so a bad name is reported as
|
|
||||||
* a correctable error with suggestions instead of rendering as a blank square — which is
|
|
||||||
* what an unchecked invented name becomes in draw.io.
|
|
||||||
*/
|
|
||||||
export function checkNames(
|
|
||||||
names: { id: string; name: string; kind: "icon" | "group" }[],
|
|
||||||
): { id: string; name: string; suggestions: string[] }[] {
|
|
||||||
const bad: { id: string; name: string; suggestions: string[] }[] = []
|
|
||||||
for (const n of names) {
|
|
||||||
if (!n.name || lookupStencil(n.name, n.kind)) continue
|
|
||||||
bad.push({
|
|
||||||
id: n.id,
|
|
||||||
name: n.name,
|
|
||||||
suggestions: suggestFor(n.name, n.kind),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return bad
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total catalog size, for the tool description. */
|
|
||||||
export const CATALOG_SIZE = {
|
|
||||||
icons: Object.keys(ICONS).length,
|
|
||||||
groups: Object.keys(GROUPS).length,
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,393 +0,0 @@
|
|||||||
/**
|
|
||||||
* Graph → layers. What turns a flat list of nodes and arrows into a diagram.
|
|
||||||
*
|
|
||||||
* The engine's layout can only arrange what nesting tells it to: a container stacks its
|
|
||||||
* children in one direction, so six boxes declared in a row become six boxes in a row. For
|
|
||||||
* a flowchart that is the wrong answer, and measurably so — an order-approval flow declared
|
|
||||||
* in its natural order comes out as one column, which forces the arrow from the decision to
|
|
||||||
* its second branch to jump over the first branch, and the arrow to the merge point to jump
|
|
||||||
* back over that. The layout never looked at the arrows.
|
|
||||||
*
|
|
||||||
* This computes what it should have looked at. Three steps, the standard shape of a layered
|
|
||||||
* graph drawing (Sugiyama's algorithm):
|
|
||||||
*
|
|
||||||
* 1. LAYER — how far along the flow each node sits. Longest path from a source, so an
|
|
||||||
* arrow always points forwards and no arrow skips backwards through a layer.
|
|
||||||
* 2. ORDER — who goes left and who goes right within a layer. Chosen to reduce the number
|
|
||||||
* of arrows that cross, which is what makes a flowchart readable.
|
|
||||||
* 3. EMIT — one invisible row container per layer, which the existing layout then places.
|
|
||||||
*
|
|
||||||
* Step 3 is why this file is small: the coordinate work already exists, and it is the same
|
|
||||||
* code that lays out an AWS diagram. What was missing was only the decision of what goes in
|
|
||||||
* which row.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Operation } from "./operations"
|
|
||||||
import type { BoxShape, Role } from "./types"
|
|
||||||
|
|
||||||
/** A node in the graph the caller wants drawn. */
|
|
||||||
export interface GraphNode {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
/** Flowchart outline. `decision` for a branch, `terminator` for a start or end point. */
|
|
||||||
shape?: BoxShape
|
|
||||||
/** Catalog stencil name. When set the node renders as an icon rather than a box. */
|
|
||||||
icon?: string
|
|
||||||
/**
|
|
||||||
* Semantic group name, e.g. "remote" or "local". Nodes sharing a group get the same
|
|
||||||
* fill colour from the engine's palette, assigned in order of first appearance — the
|
|
||||||
* caller names the grouping and never touches a colour.
|
|
||||||
*/
|
|
||||||
group?: string
|
|
||||||
/** Information role (heading, callout, metric…); the theme decides how it looks. */
|
|
||||||
role?: Role
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An arrow. Direction matters: it is what determines the layering. */
|
|
||||||
export interface GraphEdge {
|
|
||||||
source: string
|
|
||||||
target: string
|
|
||||||
label?: string
|
|
||||||
dashed?: boolean
|
|
||||||
/** Thick coloured arrow for THE key relationship. */
|
|
||||||
bold?: boolean
|
|
||||||
/** Arrowhead tokens, passed through — see LinkSpec. */
|
|
||||||
head?: string
|
|
||||||
tail?: string
|
|
||||||
headFill?: boolean
|
|
||||||
tailFill?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphOptions {
|
|
||||||
/** "col" (default): layers stack downwards. "row": layers run left to right. */
|
|
||||||
flow?: "col" | "row"
|
|
||||||
/** Container to embed the graph in; absent means the page. */
|
|
||||||
parent?: string
|
|
||||||
/**
|
|
||||||
* Namespace for the synthetic layer-container ids. Without one, two graphs on one
|
|
||||||
* page would both emit `__layers`/`__layer0` and the second would be rejected as a
|
|
||||||
* duplicate id.
|
|
||||||
*/
|
|
||||||
prefix?: string
|
|
||||||
/** Id for the outer container itself; defaults to `${prefix}__layers`. */
|
|
||||||
rootId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distance between layers. */
|
|
||||||
const LAYER_GAP = 48
|
|
||||||
/** Distance between nodes within a layer. */
|
|
||||||
const NODE_GAP = 60
|
|
||||||
/** Prefix for the generated layer container ids. */
|
|
||||||
const LAYER_ID = "__layer"
|
|
||||||
|
|
||||||
export interface GraphResult {
|
|
||||||
operations: Operation[]
|
|
||||||
/** The nodes of each layer, in the order they were placed. */
|
|
||||||
layers: string[][]
|
|
||||||
/** Edges dropped because an endpoint is not in the node list. */
|
|
||||||
unknownEndpoints: string[]
|
|
||||||
/** Edges that had to be treated as loops rather than as layering constraints. */
|
|
||||||
backEdges: { source: string; target: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Break every cycle, so the graph can be layered at all.
|
|
||||||
*
|
|
||||||
* A depth-first walk; any arrow pointing at a node still on the current path is a way back
|
|
||||||
* to where we came from, and cannot be a "this comes after that" constraint. Those arrows
|
|
||||||
* are still DRAWN — a review loop is the point of the diagram — they just do not get a say
|
|
||||||
* in which layer anything lands in.
|
|
||||||
*/
|
|
||||||
function breakCycles(
|
|
||||||
nodes: string[],
|
|
||||||
edges: GraphEdge[],
|
|
||||||
): { forward: GraphEdge[]; back: GraphEdge[] } {
|
|
||||||
const out = new Map<string, GraphEdge[]>(nodes.map((n) => [n, []]))
|
|
||||||
for (const e of edges) out.get(e.source)?.push(e)
|
|
||||||
|
|
||||||
const forward: GraphEdge[] = []
|
|
||||||
const back: GraphEdge[] = []
|
|
||||||
const onPath = new Set<string>()
|
|
||||||
const done = new Set<string>()
|
|
||||||
|
|
||||||
// An explicit stack, not recursion: a 500-node dependency graph is a plausible input and
|
|
||||||
// a recursive walk over one would overflow.
|
|
||||||
for (const root of nodes) {
|
|
||||||
if (done.has(root)) continue
|
|
||||||
const stack: { id: string; next: number }[] = [{ id: root, next: 0 }]
|
|
||||||
onPath.add(root)
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const top = stack[stack.length - 1]
|
|
||||||
const list = out.get(top.id) ?? []
|
|
||||||
if (top.next >= list.length) {
|
|
||||||
onPath.delete(top.id)
|
|
||||||
done.add(top.id)
|
|
||||||
stack.pop()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const e = list[top.next++]
|
|
||||||
if (onPath.has(e.target)) {
|
|
||||||
back.push(e)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
forward.push(e)
|
|
||||||
if (!done.has(e.target)) {
|
|
||||||
onPath.add(e.target)
|
|
||||||
stack.push({ id: e.target, next: 0 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { forward, back }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign each node to a layer: the longest path to it from any node with no predecessor.
|
|
||||||
*
|
|
||||||
* Longest path rather than shortest, because a node has to come after EVERYTHING that feeds
|
|
||||||
* it. Take the shortest and an arrow ends up pointing backwards: with `a→b`, `a→c`, `c→b`,
|
|
||||||
* the shortest path puts b in layer 1 alongside c, and then `c→b` points sideways.
|
|
||||||
*/
|
|
||||||
function assignLayers(nodes: string[], forward: GraphEdge[]): string[][] {
|
|
||||||
const layer = new Map<string, number>(nodes.map((n) => [n, 0]))
|
|
||||||
// Relaxation, bounded by the node count: the longest possible chain visits every node
|
|
||||||
// once, so after that many rounds nothing can still be moving.
|
|
||||||
for (let round = 0; round < nodes.length; round++) {
|
|
||||||
let moved = false
|
|
||||||
for (const e of forward) {
|
|
||||||
const want = (layer.get(e.source) ?? 0) + 1
|
|
||||||
if (want > (layer.get(e.target) ?? 0)) {
|
|
||||||
layer.set(e.target, want)
|
|
||||||
moved = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!moved) break
|
|
||||||
}
|
|
||||||
const depth = Math.max(0, ...layer.values()) + 1
|
|
||||||
const layers: string[][] = Array.from({ length: depth }, () => [])
|
|
||||||
// Declaration order within a layer, so the ordering pass starts somewhere predictable.
|
|
||||||
for (const n of nodes) layers[layer.get(n) ?? 0].push(n)
|
|
||||||
return layers
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder each layer to reduce the number of arrows that cross.
|
|
||||||
*
|
|
||||||
* Barycentre sweeping: a node is placed at the average position of the nodes it connects to
|
|
||||||
* in the neighbouring layer, and the whole diagram is swept downwards then upwards
|
|
||||||
* repeatedly. Each sweep can only be judged against the previous layer's order, so a node
|
|
||||||
* pulled into a better place drags its own neighbours in the next sweep.
|
|
||||||
*
|
|
||||||
* The heuristic, not an exact minimum: finding the true minimum number of crossings is
|
|
||||||
* NP-hard even for two layers. In practice this reaches zero crossings on the flowcharts the
|
|
||||||
* model actually produces — verified on a 14-node pipeline with two diamonds and a rollback
|
|
||||||
* loop, and on a bipartite graph whose declared order forces three crossings.
|
|
||||||
*/
|
|
||||||
function reduceCrossings(layers: string[][], edges: GraphEdge[]): void {
|
|
||||||
if (layers.length < 2) return
|
|
||||||
const PASSES = 8
|
|
||||||
const into = new Map<string, string[]>()
|
|
||||||
const outOf = new Map<string, string[]>()
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.source === e.target) continue
|
|
||||||
;(into.get(e.target) ?? into.set(e.target, []).get(e.target))?.push(
|
|
||||||
e.source,
|
|
||||||
)
|
|
||||||
;(outOf.get(e.source) ?? outOf.set(e.source, []).get(e.source))?.push(
|
|
||||||
e.target,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = layers.map((l) => [...l])
|
|
||||||
let bestScore = countCrossings(layers, edges)
|
|
||||||
|
|
||||||
for (let pass = 0; pass < PASSES && bestScore > 0; pass++) {
|
|
||||||
const pos = new Map<string, number>()
|
|
||||||
for (const l of layers)
|
|
||||||
l.forEach((n, i) => {
|
|
||||||
pos.set(n, i)
|
|
||||||
})
|
|
||||||
const down = pass % 2 === 0
|
|
||||||
const order = down
|
|
||||||
? layers.map((_, i) => i).slice(1)
|
|
||||||
: layers
|
|
||||||
.map((_, i) => i)
|
|
||||||
.slice(0, -1)
|
|
||||||
.reverse()
|
|
||||||
|
|
||||||
for (const i of order) {
|
|
||||||
const neighbours = down ? into : outOf
|
|
||||||
const key = new Map<string, number>()
|
|
||||||
layers[i].forEach((n, idx) => {
|
|
||||||
const nb = (neighbours.get(n) ?? [])
|
|
||||||
.map((m) => pos.get(m))
|
|
||||||
.filter((v): v is number => v !== undefined)
|
|
||||||
// A node with no neighbour in that direction keeps its place, rather than
|
|
||||||
// being pushed to one end by a default of zero.
|
|
||||||
key.set(
|
|
||||||
n,
|
|
||||||
nb.length ? nb.reduce((a, b) => a + b, 0) / nb.length : idx,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
layers[i] = [...layers[i]].sort(
|
|
||||||
(a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the best arrangement seen: sweeping is not monotonic, and a later pass can be
|
|
||||||
// worse than an earlier one.
|
|
||||||
const score = countCrossings(layers, edges)
|
|
||||||
if (score < bestScore) {
|
|
||||||
bestScore = score
|
|
||||||
best = layers.map((l) => [...l])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < layers.length; i++) layers[i] = best[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many pairs of arrows cross between adjacent layers.
|
|
||||||
*
|
|
||||||
* Two arrows between the same pair of layers cross exactly when their endpoints are in the
|
|
||||||
* opposite order on the two sides. That is all this counts — arrows spanning more than one
|
|
||||||
* layer are ignored here, because their crossings depend on routing rather than ordering.
|
|
||||||
*/
|
|
||||||
function countCrossings(layers: string[][], edges: GraphEdge[]): number {
|
|
||||||
const layerOf = new Map<string, number>()
|
|
||||||
const posOf = new Map<string, number>()
|
|
||||||
layers.forEach((l, i) => {
|
|
||||||
l.forEach((n, j) => {
|
|
||||||
layerOf.set(n, i)
|
|
||||||
posOf.set(n, j)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
let total = 0
|
|
||||||
for (let i = 0; i + 1 < layers.length; i++) {
|
|
||||||
const span = edges.filter(
|
|
||||||
(e) =>
|
|
||||||
layerOf.get(e.source) === i && layerOf.get(e.target) === i + 1,
|
|
||||||
)
|
|
||||||
for (let a = 0; a < span.length; a++)
|
|
||||||
for (let b = a + 1; b < span.length; b++) {
|
|
||||||
const s1 = posOf.get(span[a].source) ?? 0
|
|
||||||
const t1 = posOf.get(span[a].target) ?? 0
|
|
||||||
const s2 = posOf.get(span[b].source) ?? 0
|
|
||||||
const t2 = posOf.get(span[b].target) ?? 0
|
|
||||||
if ((s1 - s2) * (t1 - t2) < 0) total++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn a graph into the operations that draw it.
|
|
||||||
*
|
|
||||||
* The output is ordinary operations — nothing here is a new kind of thing the rest of the
|
|
||||||
* engine has to know about. A layer of one node is emitted directly rather than wrapped,
|
|
||||||
* because a single-child row container would just add a level of nesting with nothing to
|
|
||||||
* arrange.
|
|
||||||
*/
|
|
||||||
export function graphToOperations(
|
|
||||||
nodes: GraphNode[],
|
|
||||||
edges: GraphEdge[],
|
|
||||||
opts: GraphOptions = {},
|
|
||||||
): GraphResult {
|
|
||||||
const flow = opts.flow ?? "col"
|
|
||||||
const ids = nodes.map((n) => n.id)
|
|
||||||
const known = new Set(ids)
|
|
||||||
|
|
||||||
const unknownEndpoints: string[] = []
|
|
||||||
const usable: GraphEdge[] = []
|
|
||||||
for (const e of edges) {
|
|
||||||
if (!known.has(e.source)) unknownEndpoints.push(e.source)
|
|
||||||
if (!known.has(e.target)) unknownEndpoints.push(e.target)
|
|
||||||
if (known.has(e.source) && known.has(e.target)) usable.push(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A self-loop tells us nothing about layering and would make the cycle break drop a real
|
|
||||||
// arrow, so it is set aside and drawn as-is.
|
|
||||||
const loops = usable.filter((e) => e.source === e.target)
|
|
||||||
const between = usable.filter((e) => e.source !== e.target)
|
|
||||||
|
|
||||||
const { forward, back } = breakCycles(ids, between)
|
|
||||||
const layers = assignLayers(ids, forward)
|
|
||||||
reduceCrossings(layers, forward)
|
|
||||||
|
|
||||||
// The flow axis is the OUTER container's direction; a layer runs across it.
|
|
||||||
const outerDir = flow
|
|
||||||
const layerDir = flow === "col" ? "row" : "col"
|
|
||||||
const ns = opts.prefix ?? ""
|
|
||||||
const root = opts.rootId ?? `${ns}${LAYER_ID}s`
|
|
||||||
|
|
||||||
const operations: Operation[] = [
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: root,
|
|
||||||
...(opts.parent ? { parent: opts.parent } : {}),
|
|
||||||
label: "",
|
|
||||||
dir: outerDir,
|
|
||||||
gap: LAYER_GAP,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
||||||
const add = (id: string, parent: string): Operation => {
|
|
||||||
const n = byId.get(id) as GraphNode
|
|
||||||
return n.icon
|
|
||||||
? {
|
|
||||||
op: "add_icon",
|
|
||||||
id: n.id,
|
|
||||||
parent,
|
|
||||||
name: n.icon,
|
|
||||||
label: n.label,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
op: "add_box",
|
|
||||||
id: n.id,
|
|
||||||
parent,
|
|
||||||
label: n.label,
|
|
||||||
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
|
|
||||||
...(n.role && n.role !== "body" ? { role: n.role } : {}),
|
|
||||||
...(n.group ? { group: n.group } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layers.forEach((members, i) => {
|
|
||||||
if (members.length === 0) return
|
|
||||||
if (members.length === 1) {
|
|
||||||
operations.push(add(members[0], root))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const band = `${ns}${LAYER_ID}${i}`
|
|
||||||
operations.push({
|
|
||||||
op: "add_container",
|
|
||||||
id: band,
|
|
||||||
parent: root,
|
|
||||||
label: "",
|
|
||||||
dir: layerDir,
|
|
||||||
gap: NODE_GAP,
|
|
||||||
})
|
|
||||||
for (const m of members) operations.push(add(m, band))
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const e of [...between, ...loops])
|
|
||||||
operations.push({
|
|
||||||
op: "link",
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
...(e.label ? { label: e.label } : {}),
|
|
||||||
...(e.dashed ? { dashed: true } : {}),
|
|
||||||
...(e.bold ? { bold: true } : {}),
|
|
||||||
...(e.head !== undefined
|
|
||||||
? { head: e.head, headFill: e.headFill ?? false }
|
|
||||||
: {}),
|
|
||||||
...(e.tail !== undefined
|
|
||||||
? { tail: e.tail, tailFill: e.tailFill ?? false }
|
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
operations,
|
|
||||||
layers: layers.filter((l) => l.length > 0),
|
|
||||||
unknownEndpoints: [...new Set(unknownEndpoints)],
|
|
||||||
backEdges: back.map((e) => ({ source: e.source, target: e.target })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* The engine's entry point: one call takes the current canvas XML plus a list of
|
|
||||||
* structural operations and returns new canvas XML.
|
|
||||||
*
|
|
||||||
* current XML → parse → apply operations → check names → layout → render → new XML
|
|
||||||
*
|
|
||||||
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
|
|
||||||
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
|
|
||||||
* box, adding an annotation — are simply part of the input to the next layout. There is
|
|
||||||
* no second copy of the state, and therefore nothing to reconcile.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { checkNames, resolveStyle } from "./catalog"
|
|
||||||
import {
|
|
||||||
applyOperations,
|
|
||||||
collectNames,
|
|
||||||
type Operation,
|
|
||||||
outline,
|
|
||||||
} from "./operations"
|
|
||||||
import { parseDiagram } from "./parse"
|
|
||||||
import { renderDiagram } from "./render"
|
|
||||||
import { nearestShape, resolveShape } from "./shapes"
|
|
||||||
import { type DiagramTree, walkTree } from "./types"
|
|
||||||
|
|
||||||
export interface RestructureResult {
|
|
||||||
/** New canvas XML, or null when the request could not be carried out. */
|
|
||||||
xml: string | null
|
|
||||||
/** Compact outline of the resulting structure, for the model to read back. */
|
|
||||||
outline: string
|
|
||||||
/** Operations that could not be applied, and invented stencil names. */
|
|
||||||
errors: string[]
|
|
||||||
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestructureOptions {
|
|
||||||
/** Which page of a multi-page document to work on. */
|
|
||||||
pageIndex?: number
|
|
||||||
/** Diagram-wide icon glyph size. */
|
|
||||||
iconSize?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply structural operations to whatever is on the canvas.
|
|
||||||
*
|
|
||||||
* `currentXml` may be empty — that is how a diagram gets built from scratch.
|
|
||||||
*
|
|
||||||
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
|
|
||||||
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
|
|
||||||
* missing icons. The error carries suggestions from the catalog so the model can fix it
|
|
||||||
* in one more turn.
|
|
||||||
*/
|
|
||||||
export function restructureDiagram(
|
|
||||||
currentXml: string,
|
|
||||||
ops: Operation[],
|
|
||||||
opts: RestructureOptions = {},
|
|
||||||
): RestructureResult {
|
|
||||||
const warnings: string[] = []
|
|
||||||
|
|
||||||
let tree: DiagramTree
|
|
||||||
if (currentXml.trim()) {
|
|
||||||
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
|
|
||||||
tree = parsed.tree
|
|
||||||
warnings.push(...parsed.warnings)
|
|
||||||
} else {
|
|
||||||
tree = { roots: [], links: [], foreign: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const applied = applyOperations(tree, ops)
|
|
||||||
const errors = [...applied.errors]
|
|
||||||
warnings.push(...applied.warnings)
|
|
||||||
|
|
||||||
// Catch invented names before rendering, so the model gets a correctable error
|
|
||||||
// instead of a diagram with blank squares in it.
|
|
||||||
for (const bad of checkNames(collectNames(applied.tree))) {
|
|
||||||
const hint = bad.suggestions.length
|
|
||||||
? ` Did you mean: ${bad.suggestions.join(", ")}?`
|
|
||||||
: ""
|
|
||||||
errors.push(
|
|
||||||
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shape tokens: an injection-capable token is an error; an unknown-but-safe one
|
|
||||||
// passes through (draw.io degrades it to a rectangle) but gets a warning, so a typo
|
|
||||||
// is a one-turn fix instead of a silently rectangular "cyclinder" forever.
|
|
||||||
for (const n of walkTree(applied.tree)) {
|
|
||||||
if (n.kind !== "box" || !n.shape || n.shape === "box") continue
|
|
||||||
const resolved = resolveShape(n.shape)
|
|
||||||
if (!resolved) {
|
|
||||||
errors.push(
|
|
||||||
`shape "${n.shape}" (node ${n.id}) contains characters that are not allowed in a shape token.`,
|
|
||||||
)
|
|
||||||
} else if (resolved.passthrough) {
|
|
||||||
const near = nearestShape(n.shape)
|
|
||||||
warnings.push(
|
|
||||||
`shape "${n.shape}" (node ${n.id}) is not in the engine's catalog — passed through to draw.io, which renders unknown shapes as rectangles.${near ? ` Did you mean "${near}"?` : ""}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0)
|
|
||||||
return { xml: null, outline: outline(applied.tree), errors, warnings }
|
|
||||||
|
|
||||||
const rendered = renderDiagram(applied.tree, {
|
|
||||||
resolveStyle,
|
|
||||||
iconSize: opts.iconSize,
|
|
||||||
})
|
|
||||||
if (rendered.danglingLinks.length)
|
|
||||||
warnings.push(
|
|
||||||
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
xml: rendered.xml,
|
|
||||||
outline: outline(applied.tree),
|
|
||||||
errors: [],
|
|
||||||
warnings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the current canvas structure without changing it. */
|
|
||||||
export function describeDiagram(
|
|
||||||
currentXml: string,
|
|
||||||
pageIndex = 0,
|
|
||||||
): { outline: string; warnings: string[]; needsAdoption: boolean } {
|
|
||||||
if (!currentXml.trim())
|
|
||||||
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
|
|
||||||
const { tree, warnings, needsAdoption } = parseDiagram(
|
|
||||||
currentXml,
|
|
||||||
pageIndex,
|
|
||||||
)
|
|
||||||
return { outline: outline(tree), warnings, needsAdoption }
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
|
|
||||||
export {
|
|
||||||
type GraphEdge,
|
|
||||||
type GraphNode,
|
|
||||||
type GraphOptions,
|
|
||||||
graphToOperations,
|
|
||||||
} from "./graph"
|
|
||||||
export { type Operation, OperationSchema } from "./operations"
|
|
||||||
export { parseDiagram } from "./parse"
|
|
||||||
export { renderDiagram } from "./render"
|
|
||||||
export type { DiagramNode, DiagramTree } from "./types"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,585 +0,0 @@
|
|||||||
/**
|
|
||||||
* Style markers — how layout structure survives a round-trip through draw.io.
|
|
||||||
*
|
|
||||||
* The layout engine's tree carries information plain draw.io XML does not: which
|
|
||||||
* direction a container stacks its children, the gap between them, and whether the
|
|
||||||
* user has pinned a node's position. We encode that as extra `key=value` tokens in
|
|
||||||
* the cell's style string.
|
|
||||||
*
|
|
||||||
* Two behaviours this relies on, both verified in a real browser (Playwright drag
|
|
||||||
* against the embedded editor, reading the editor's own autosave payload):
|
|
||||||
*
|
|
||||||
* 1. draw.io PRESERVES style keys it does not understand. After a user drags a
|
|
||||||
* shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came
|
|
||||||
* back byte-identical.
|
|
||||||
* 2. On a DUPLICATE key, the LAST value wins. A style ending in
|
|
||||||
* `container=0;pointerEvents=0;container=1;` behaved as a container: a shape
|
|
||||||
* dragged into it was reparented. So we can append a normalising token without
|
|
||||||
* first parsing out the old one.
|
|
||||||
*
|
|
||||||
* (2) matters because the AWS catalog is inconsistent: group_region, group_vpc,
|
|
||||||
* group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship
|
|
||||||
* WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2,
|
|
||||||
* group_security_group and group_corporate_data_center ship WITH it. Appending
|
|
||||||
* unconditionally normalises all of them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */
|
|
||||||
export const MARKER = {
|
|
||||||
/** Node kind, so the parser does not have to re-guess it from the shape. */
|
|
||||||
kind: "dai_kind",
|
|
||||||
/** Child stacking direction of a container: "row" | "col" | "grid". */
|
|
||||||
dir: "dai_dir",
|
|
||||||
/** Gap between children, in px. */
|
|
||||||
gap: "dai_gap",
|
|
||||||
/** Column count, for grid containers. */
|
|
||||||
cols: "dai_cols",
|
|
||||||
/** Set by the user to freeze a node's position across re-layouts. */
|
|
||||||
pin: "dai_pin",
|
|
||||||
/**
|
|
||||||
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
|
|
||||||
* base64 image with no name anywhere in it, so the style alone cannot identify it.
|
|
||||||
*/
|
|
||||||
name: "dai_name",
|
|
||||||
/**
|
|
||||||
* Which (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
|
|
||||||
*
|
|
||||||
* Position alone cannot recover this once the user drags a node: the cell it lands in
|
|
||||||
* is a guess, whereas the marker records which lane the model assigned it to. It is
|
|
||||||
* also the only way an empty cell stays empty — geometry can only tell us where things
|
|
||||||
* ARE, never that a role deliberately does nothing at a given step.
|
|
||||||
*/
|
|
||||||
cell: "dai_cell",
|
|
||||||
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
|
|
||||||
lanes: "dai_lanes",
|
|
||||||
/** A pool's milestone labels, tab-separated. */
|
|
||||||
phases: "dai_phases",
|
|
||||||
/** A pool's orientation: "h" or "v". */
|
|
||||||
orient: "dai_orient",
|
|
||||||
/** Vertical distance between consecutive messages in a sequence diagram. */
|
|
||||||
step: "dai_step",
|
|
||||||
/** How a radial container fans its branches out: "radial" or "down". */
|
|
||||||
spread: "dai_spread",
|
|
||||||
/** The node's information role (banner, heading, callout…), for the round trip. */
|
|
||||||
role: "dai_role",
|
|
||||||
/** The node's semantic zone, whose hue ramp colours it. */
|
|
||||||
group: "dai_group",
|
|
||||||
/**
|
|
||||||
* The declared shape token, verbatim. Appearance-based reverse mapping is ambiguous
|
|
||||||
* (aliases, rotated variants, styles with no unique shape= token), so the round trip
|
|
||||||
* carries the declaration itself.
|
|
||||||
*/
|
|
||||||
shape: "dai_shape",
|
|
||||||
/**
|
|
||||||
* Marks a node's size as engine-computed rather than user-fixed. Without it, the
|
|
||||||
* w/h read back from the canvas would freeze the first layout's measurement: change
|
|
||||||
* the label and the box would keep the old size instead of re-measuring.
|
|
||||||
*/
|
|
||||||
auto: "dai_auto",
|
|
||||||
/** Share of the parent's leftover flow-axis space — flex-grow. */
|
|
||||||
grow: "dai_grow",
|
|
||||||
/** Cross-axis position within the parent: "start" | "center" | "end". */
|
|
||||||
align: "dai_align",
|
|
||||||
/** How a container spreads children along its own axis — justify-content. */
|
|
||||||
justify: "dai_justify",
|
|
||||||
/** A container's cross-axis default for children that declare no align of their own. */
|
|
||||||
alignItems: "dai_aitems",
|
|
||||||
/** Opted out of the content-width floor when weights divide a row — CSS's min-width:0. */
|
|
||||||
minw0: "dai_minw0",
|
|
||||||
/**
|
|
||||||
* Declared width cap, px.
|
|
||||||
*
|
|
||||||
* Has to be a marker rather than inferred from the drawn width: the two are only equal
|
|
||||||
* when the cap actually bit. A box capped at 400 that happens to be 260 wide would come
|
|
||||||
* back with a 260 cap, and the next re-layout could never let it grow again.
|
|
||||||
*/
|
|
||||||
maxw: "dai_maxw",
|
|
||||||
/** A container's interior padding, px. */
|
|
||||||
pad: "dai_pad",
|
|
||||||
/**
|
|
||||||
* The page's declared width:height, on the default layer's cell.
|
|
||||||
*
|
|
||||||
* Page-level rather than per-node, so it goes on layer "1" — the one cell every
|
|
||||||
* diagram has and draw.io never discards. It cannot be inferred from pageWidth and
|
|
||||||
* pageHeight: those are what the last layout produced, so reading them back would
|
|
||||||
* turn whatever shape a diagram happened to come out as into a standing request to
|
|
||||||
* keep it.
|
|
||||||
*/
|
|
||||||
aspect: "dai_aspect",
|
|
||||||
/**
|
|
||||||
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
|
|
||||||
* columns, its milestone strip. The parser must not read these back as nodes — they are
|
|
||||||
* re-derived from the pool's own parameters on every layout — and the edge router must
|
|
||||||
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
|
|
||||||
*/
|
|
||||||
lane: "dai_lane",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type NodeKind =
|
|
||||||
| "group"
|
|
||||||
| "grid"
|
|
||||||
| "pool"
|
|
||||||
| "sequence"
|
|
||||||
| "radial"
|
|
||||||
| "icon"
|
|
||||||
| "box"
|
|
||||||
| "title"
|
|
||||||
export type Direction = "row" | "col" | "grid"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokens that make a shape behave as a container in draw.io: it accepts a shape
|
|
||||||
* dragged into it and reparents that shape (setting `parent` and switching the
|
|
||||||
* child's geometry to parent-relative).
|
|
||||||
*
|
|
||||||
* `pointerEvents=0` keeps clicks falling through to the children — without it the
|
|
||||||
* frame swallows them and the user cannot select what is inside. `collapsible=0`
|
|
||||||
* hides the fold arrow. `recursiveResize=0` stops children from being scaled when
|
|
||||||
* the frame is resized, which would fight the layout engine.
|
|
||||||
*/
|
|
||||||
const CONTAINER_TOKENS =
|
|
||||||
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A container that groups children for layout but should not be visible.
|
|
||||||
*
|
|
||||||
* The reference project solves this with a "phantom": a wrapper that participates in
|
|
||||||
* layout and then emits NO cell, reparenting its children onto the nearest visible
|
|
||||||
* ancestor. That makes the round-trip lossy by construction — the wrapper's direction
|
|
||||||
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
|
|
||||||
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
|
|
||||||
* container's "col" direction, leaving children in a 2-D arrangement that can only be
|
|
||||||
* read back as a grid.
|
|
||||||
*
|
|
||||||
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
|
|
||||||
* in exchange for structure that survives being read back.
|
|
||||||
*/
|
|
||||||
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
|
|
||||||
|
|
||||||
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
|
|
||||||
export function readMarker(style: string, key: string): string | null {
|
|
||||||
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
|
|
||||||
const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g")
|
|
||||||
let last: string | null = null
|
|
||||||
let m = re.exec(style)
|
|
||||||
while (m !== null) {
|
|
||||||
last = m[1]
|
|
||||||
m = re.exec(style)
|
|
||||||
}
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
|
|
||||||
const KINDS: readonly NodeKind[] = [
|
|
||||||
"group",
|
|
||||||
"grid",
|
|
||||||
"pool",
|
|
||||||
"sequence",
|
|
||||||
"radial",
|
|
||||||
"icon",
|
|
||||||
"box",
|
|
||||||
"title",
|
|
||||||
]
|
|
||||||
|
|
||||||
export function readKind(style: string): NodeKind | null {
|
|
||||||
const v = readMarker(style, MARKER.kind)
|
|
||||||
return KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The (lane, column) cell a node occupies in a swimlane pool, or null.
|
|
||||||
*
|
|
||||||
* Both must be non-negative integers: a malformed value is safer read as "no cell
|
|
||||||
* declared" (which puts the node in lane 0 column 0) than as a negative index, which would
|
|
||||||
* place it outside the pool's frame.
|
|
||||||
*/
|
|
||||||
export function readCell(style: string): { lane: number; col: number } | null {
|
|
||||||
const v = readMarker(style, MARKER.cell)
|
|
||||||
if (!v) return null
|
|
||||||
const m = v.match(/^(\d+),(\d+)$/)
|
|
||||||
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tab-separated marker list, as written by `joinList`.
|
|
||||||
*
|
|
||||||
* A tab cannot appear in a draw.io style value — the editor writes styles as a single
|
|
||||||
* semicolon-separated line — so it is safe as a separator inside one value, where a comma
|
|
||||||
* would collide with the label text it has to carry.
|
|
||||||
*/
|
|
||||||
export function readList(style: string, key: string): string[] | null {
|
|
||||||
const v = readMarker(style, key)
|
|
||||||
if (v === null) return null
|
|
||||||
if (v === "") return []
|
|
||||||
return v.split("\t").map(decodeURIComponent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Encode a list of labels into one marker value. */
|
|
||||||
export function joinList(items: string[]): string {
|
|
||||||
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
|
|
||||||
return items.map((s) => encodeURIComponent(s)).join("\t")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
|
|
||||||
export function isLaneChrome(style: string): boolean {
|
|
||||||
return readMarker(style, MARKER.lane) !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readDir(style: string): Direction | null {
|
|
||||||
const v = readMarker(style, MARKER.dir)
|
|
||||||
if (v === "row" || v === "col" || v === "grid") return v
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */
|
|
||||||
export function readIntMarker(style: string, key: string): number | null {
|
|
||||||
const v = readMarker(style, key)
|
|
||||||
if (v === null) return null
|
|
||||||
const n = Number(v)
|
|
||||||
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Has the user pinned this node? Any value other than "0"/""/"false" counts as
|
|
||||||
* pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's
|
|
||||||
* "Edit Style" dialog gets what they expect.
|
|
||||||
*/
|
|
||||||
export function isPinned(style: string): boolean {
|
|
||||||
const v = readMarker(style, MARKER.pin)
|
|
||||||
if (v === null) return false
|
|
||||||
const s = v.trim().toLowerCase()
|
|
||||||
return s !== "" && s !== "0" && s !== "false"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append `key=value;`, replacing any existing occurrence of the key first.
|
|
||||||
*
|
|
||||||
* Styles are re-stamped on every render, and a style recovered from the canvas already
|
|
||||||
* carries last render's markers — blindly appending grew the string by one duplicate per
|
|
||||||
* round-trip, unboundedly. Duplicates resolve last-wins in draw.io so nothing ever LOOKED
|
|
||||||
* wrong, which is why it went unnoticed until a byte-identity test caught it.
|
|
||||||
*
|
|
||||||
* Only `dai_*` keys are cleaned. mxGraph keys are appended verbatim because last-wins is
|
|
||||||
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
|
|
||||||
* style that may say `container=0`.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Set each `key=value;` token of `tokens` on a style, replacing any value already there.
|
|
||||||
*
|
|
||||||
* Matching is per token, not on the whole run: a catalog style may already declare
|
|
||||||
* `container=1` while saying nothing about `pointerEvents`, and re-adding the whole run
|
|
||||||
* because one token was missing is what let these accumulate.
|
|
||||||
*
|
|
||||||
* Exported because the same defect appeared a second time, on EDGES: an edge's style starts
|
|
||||||
* from whatever the canvas held, which already carried the previous pass's `exitX`/`entryX`
|
|
||||||
* port keys, and the router appended a fresh set on top of them every render — 76 characters
|
|
||||||
* per round-trip, without bound. Any code that re-stamps a computed mxGraph key onto a style
|
|
||||||
* recovered from the canvas needs this rather than `+=`.
|
|
||||||
*/
|
|
||||||
export function appendOnce(style: string, tokens: string): string {
|
|
||||||
let s = style
|
|
||||||
for (const tok of tokens.split(";")) {
|
|
||||||
if (!tok) continue
|
|
||||||
const key = tok.slice(0, tok.indexOf("="))
|
|
||||||
// The key must not be present with ANY value: `container=0` from a catalog stencil
|
|
||||||
// has to be overwritten, which is what appending the correct value does.
|
|
||||||
const has = new RegExp(`(?:^|;)${key}=[^;]*;`).test(s)
|
|
||||||
if (has) {
|
|
||||||
s = s.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
|
||||||
}
|
|
||||||
s = s.endsWith(";") || s === "" ? s : `${s};`
|
|
||||||
s += `${tok};`
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
function append(style: string, key: string, value: string | number): string {
|
|
||||||
const cleaned = key.startsWith("dai_")
|
|
||||||
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
|
||||||
: style
|
|
||||||
const base =
|
|
||||||
cleaned.endsWith(";") || cleaned === "" ? cleaned : `${cleaned};`
|
|
||||||
return `${base}${key}=${value};`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp a container's style: make it a real draw.io container and record its
|
|
||||||
* layout parameters.
|
|
||||||
*
|
|
||||||
* Appends rather than rewrites. Duplicate keys are legal and the last one wins, so
|
|
||||||
* a catalog style that already says `container=1` is unharmed, and one that says
|
|
||||||
* nothing (or `container=0`) is corrected.
|
|
||||||
*/
|
|
||||||
export function stampContainer(
|
|
||||||
style: string,
|
|
||||||
opts: {
|
|
||||||
kind: "group" | "grid"
|
|
||||||
dir: Direction
|
|
||||||
gap: number
|
|
||||||
cols?: number
|
|
||||||
/** Layout-only wrapper: emit a real cell, but draw nothing. */
|
|
||||||
invisible?: boolean
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
|
||||||
// Appended only when not already there. These are plain mxGraph keys, so `append`'s
|
|
||||||
// de-duplication (which is limited to `dai_*`) does not cover them — and a container
|
|
||||||
// goes through here on EVERY re-layout, so a blind `+=` grew the style string by
|
|
||||||
// another `container=1;pointerEvents=0;collapsible=0;recursiveResize=0;` per round
|
|
||||||
// trip, without bound. Harmless to draw.io, which takes the last value, but the XML
|
|
||||||
// never reached a fixed point and every edit shipped a longer style.
|
|
||||||
s = appendOnce(s, CONTAINER_TOKENS)
|
|
||||||
if (opts.invisible) s = appendOnce(s, INVISIBLE_TOKENS)
|
|
||||||
s = append(s, MARKER.kind, opts.kind)
|
|
||||||
s = append(s, MARKER.dir, opts.dir)
|
|
||||||
s = append(s, MARKER.gap, Math.round(opts.gap))
|
|
||||||
if (opts.kind === "grid" && opts.cols != null)
|
|
||||||
s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols)))
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
|
|
||||||
*
|
|
||||||
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
|
|
||||||
* cells sitting inside it, and they are what a shape should reparent into when the user
|
|
||||||
* drags it — that is how "the user moved this step to a different role" gets recorded. If
|
|
||||||
* the pool itself claimed the drop, every node would come back in lane 0.
|
|
||||||
*/
|
|
||||||
export function stampPool(
|
|
||||||
style: string,
|
|
||||||
opts: {
|
|
||||||
lanes: string[]
|
|
||||||
phases: string[]
|
|
||||||
orientation: "horizontal" | "vertical"
|
|
||||||
gap: number
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
let s = append(style, MARKER.kind, "pool")
|
|
||||||
s = append(s, MARKER.lanes, joinList(opts.lanes))
|
|
||||||
s = append(s, MARKER.phases, joinList(opts.phases))
|
|
||||||
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
|
|
||||||
return append(s, MARKER.gap, Math.round(opts.gap))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stamp a sequence container: participant spacing and message spacing. */
|
|
||||||
export function stampSequence(
|
|
||||||
style: string,
|
|
||||||
opts: { gap: number; step: number },
|
|
||||||
): string {
|
|
||||||
const s = append(style, MARKER.kind, "sequence")
|
|
||||||
return append(
|
|
||||||
append(s, MARKER.gap, Math.round(opts.gap)),
|
|
||||||
MARKER.step,
|
|
||||||
Math.round(opts.step),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
|
|
||||||
export function stampRadial(
|
|
||||||
style: string,
|
|
||||||
opts: { spread: "radial" | "down"; gap: number },
|
|
||||||
): string {
|
|
||||||
const s = append(style, MARKER.kind, "radial")
|
|
||||||
return append(
|
|
||||||
append(s, MARKER.spread, opts.spread),
|
|
||||||
MARKER.gap,
|
|
||||||
Math.round(opts.gap),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp one of a pool's lane bands.
|
|
||||||
*
|
|
||||||
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
|
|
||||||
* there and the marker on the band tells the parser which lane that is. The lane index is
|
|
||||||
* the band's identity, not its position, so the assignment survives the pool being
|
|
||||||
* re-measured to a different size.
|
|
||||||
*/
|
|
||||||
export function stampLane(style: string, lane: number): string {
|
|
||||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
|
||||||
s += CONTAINER_TOKENS
|
|
||||||
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp a pool's own decoration — a lane-name column or a milestone strip.
|
|
||||||
*
|
|
||||||
* `dai_lane=-1` marks it as chrome the renderer rebuilds, so the parser drops it rather
|
|
||||||
* than reading it back as a node. Unlike a lane band it is deliberately NOT a draw.io
|
|
||||||
* container: a step dropped on a label column belongs to no role, and letting it reparent
|
|
||||||
* there would lose the step's lane.
|
|
||||||
*/
|
|
||||||
export function stampPoolDecoration(style: string): string {
|
|
||||||
return append(style, MARKER.lane, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record which pool cell a node occupies. */
|
|
||||||
export function stampCell(
|
|
||||||
style: string,
|
|
||||||
cell: { lane: number; col: number },
|
|
||||||
): string {
|
|
||||||
return append(
|
|
||||||
style,
|
|
||||||
MARKER.cell,
|
|
||||||
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is this an invisible layout wrapper? Both colours set to `none` and no group
|
|
||||||
* stencil — a visible frame always has a stroke or a stencil.
|
|
||||||
*/
|
|
||||||
export function isInvisible(style: string): boolean {
|
|
||||||
if (/grIcon=/.test(style)) return false
|
|
||||||
const fill = readMarker(style, "fillColor")
|
|
||||||
const stroke = readMarker(style, "strokeColor")
|
|
||||||
return fill === "none" && stroke === "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamp a leaf with its kind, so the parser need not infer it.
|
|
||||||
*
|
|
||||||
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
|
|
||||||
* base64 image with no name in it, so the style alone cannot identify which icon it is.
|
|
||||||
*/
|
|
||||||
export function stampLeaf(
|
|
||||||
style: string,
|
|
||||||
kind: "icon" | "box" | "title",
|
|
||||||
opts: { name?: string } = {},
|
|
||||||
): string {
|
|
||||||
const s = append(style, MARKER.kind, kind)
|
|
||||||
return opts.name ? append(s, MARKER.name, opts.name) : s
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stamp the node's information role, replacing any previous one. */
|
|
||||||
export function stampRole(style: string, role: string): string {
|
|
||||||
return append(style, MARKER.role, role)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stamp the node's semantic zone, replacing any previous one. */
|
|
||||||
export function stampGroup(style: string, group: string): string {
|
|
||||||
return append(style, MARKER.group, encodeURIComponent(group))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stamp the declared shape token, so the round trip carries the declaration itself. */
|
|
||||||
export function stampShape(style: string, shape: string): string {
|
|
||||||
return append(style, MARKER.shape, encodeURIComponent(shape))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a node's size as engine-computed, so a re-layout re-measures it. */
|
|
||||||
export function stampAuto(style: string): string {
|
|
||||||
return append(style, MARKER.auto, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Was this node's size computed by the engine (vs fixed by the user or the model)? */
|
|
||||||
export function isAutoSized(style: string): boolean {
|
|
||||||
return readMarker(style, MARKER.auto) === "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
type FlexAlign = "start" | "center" | "end" | "stretch"
|
|
||||||
type FlexJustify = "start" | "center" | "end" | "between" | "around" | "evenly"
|
|
||||||
|
|
||||||
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
|
|
||||||
export function stampFlex(
|
|
||||||
style: string,
|
|
||||||
opts: {
|
|
||||||
grow?: number
|
|
||||||
align?: FlexAlign
|
|
||||||
justify?: FlexJustify
|
|
||||||
alignItems?: FlexAlign
|
|
||||||
maxW?: number
|
|
||||||
minW0?: boolean
|
|
||||||
pad?: number
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
let s = style
|
|
||||||
if (opts.grow != null && opts.grow > 0)
|
|
||||||
s = append(s, MARKER.grow, opts.grow)
|
|
||||||
if (opts.align && opts.align !== "center")
|
|
||||||
s = append(s, MARKER.align, opts.align)
|
|
||||||
if (opts.justify && opts.justify !== "start")
|
|
||||||
s = append(s, MARKER.justify, opts.justify)
|
|
||||||
if (opts.alignItems) s = append(s, MARKER.alignItems, opts.alignItems)
|
|
||||||
if (opts.maxW != null && opts.maxW > 0)
|
|
||||||
s = append(s, MARKER.maxw, Math.round(opts.maxW))
|
|
||||||
if (opts.minW0) s = append(s, MARKER.minw0, 1)
|
|
||||||
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the align marker back. Anything unrecognised means the default (center). */
|
|
||||||
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
|
|
||||||
const v = readMarker(style, MARKER.align)
|
|
||||||
return v === "start" || v === "end" || v === "stretch" ? v : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a container's cross-axis default. Null means it declared none. */
|
|
||||||
export function readAlignItems(style: string): FlexAlign | null {
|
|
||||||
const v = readMarker(style, MARKER.alignItems)
|
|
||||||
return v === "start" || v === "end" || v === "stretch" || v === "center"
|
|
||||||
? v
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the justify marker back. Anything unrecognised means the default (start). */
|
|
||||||
export function readJustify(
|
|
||||||
style: string,
|
|
||||||
): Exclude<FlexJustify, "start"> | null {
|
|
||||||
const v = readMarker(style, MARKER.justify)
|
|
||||||
return v === "center" ||
|
|
||||||
v === "end" ||
|
|
||||||
v === "between" ||
|
|
||||||
v === "around" ||
|
|
||||||
v === "evenly"
|
|
||||||
? v
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the declared width cap back, or null when there was none. */
|
|
||||||
export function readMaxW(style: string): number | null {
|
|
||||||
const v = Number(readMarker(style, MARKER.maxw))
|
|
||||||
return Number.isFinite(v) && v > 0 ? v : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Did this node opt out of the content-width floor? */
|
|
||||||
export function readMinW0(style: string): boolean {
|
|
||||||
return readMarker(style, MARKER.minw0) === "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The page's declared aspect ratio, stamped on the default layer. */
|
|
||||||
export function stampAspect(layerXml: string, aspect: number): string {
|
|
||||||
return layerXml.replace(
|
|
||||||
/<mxCell id="1" parent="0"\/>/,
|
|
||||||
`<mxCell id="1" parent="0" style="${MARKER.aspect}=${aspect};"/>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the page's declared aspect back out of a model body.
|
|
||||||
*
|
|
||||||
* Scans for the marker anywhere in the page rather than parsing the layer cell: the
|
|
||||||
* marker name is namespaced, so a match cannot be anything else, and this keeps working
|
|
||||||
* if draw.io ever reorders or reformats that cell.
|
|
||||||
*/
|
|
||||||
export function readAspect(page: string): number | undefined {
|
|
||||||
const m = new RegExp(`${MARKER.aspect}=([\\d.]+)`).exec(page)
|
|
||||||
if (!m) return undefined
|
|
||||||
const v = Number(m[1])
|
|
||||||
return Number.isFinite(v) && v > 0
|
|
||||||
? Math.min(4, Math.max(0.25, v))
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
|
||||||
export function stripMarkers(style: string): string {
|
|
||||||
return style
|
|
||||||
.split(";")
|
|
||||||
.filter((tok) => tok !== "" && !tok.startsWith("dai_"))
|
|
||||||
.join(";")
|
|
||||||
.concat(";")
|
|
||||||
.replace(/^;$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Does this style carry any engine marker? Used to tell engine output from imported files. */
|
|
||||||
export function hasMarkers(style: string): boolean {
|
|
||||||
return /(?:^|;)dai_[a-z]+=/.test(style)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,235 +0,0 @@
|
|||||||
/**
|
|
||||||
* The shape vocabulary: what a box can BE, beyond a labelled rectangle.
|
|
||||||
*
|
|
||||||
* draw.io ships hundreds of shapes; the engine's declarative layer used to allow six.
|
|
||||||
* That gap — not colours, not spacing — was why engine output looked flat next to
|
|
||||||
* hand-written XML: a database drawn as a grey rectangle labelled "database" instead of
|
|
||||||
* a cylinder. This module opens the vocabulary in two tiers:
|
|
||||||
*
|
|
||||||
* CATALOG — ~20 curated shapes the engine fully understands. Each entry carries the
|
|
||||||
* complete style fragment (including the matching `perimeter=`, which draw.io's own
|
|
||||||
* style reference warns is required or edges connect to the bounding box), how much
|
|
||||||
* larger the box must be for its text to fit inside the non-rectangular outline
|
|
||||||
* (verified empirically in the real editor: a rhombus needs ~1.5× the rectangle's
|
|
||||||
* size for the same text), and whether the label renders below the glyph instead of
|
|
||||||
* inside it.
|
|
||||||
*
|
|
||||||
* PASS-THROUGH — any other token that looks like a draw.io shape name is emitted
|
|
||||||
* verbatim as `shape=<token>;`. Verified in the real editor: an unknown token
|
|
||||||
* degrades to a rectangle, it does not break the page. A conservative text scale
|
|
||||||
* covers the common case that the real shape is roughly convex. The tool response
|
|
||||||
* carries a near-match hint ("cyclinder → cylinder?") so a typo is a one-turn fix,
|
|
||||||
* not a silent permanent degradation.
|
|
||||||
*
|
|
||||||
* Style strings are merged structurally, not concatenated: each fragment is parsed to
|
|
||||||
* key=value tokens and later fragments override earlier ones per key. This is what
|
|
||||||
* makes shape and theme composable by rule — the shape fragment owns geometry keys
|
|
||||||
* (shape, perimeter, rounded…), the theme owns colour and type keys, and an overlap
|
|
||||||
* (a theme that says rounded=1 on a rhombus) resolves by order instead of emitting
|
|
||||||
* two conflicting tokens.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** How a known shape renders and measures. */
|
|
||||||
export interface ShapeSpec {
|
|
||||||
/** Geometry style tokens ONLY — no colours, no fonts; those belong to the theme. */
|
|
||||||
style: string
|
|
||||||
/**
|
|
||||||
* How much larger than a rectangle the box must be for the same text to fit
|
|
||||||
* inside the outline. 1.0 for the rectangle family; ~1.5 for a rhombus, whose
|
|
||||||
* inscribed rectangle is half its bounding box.
|
|
||||||
*/
|
|
||||||
textScale: number
|
|
||||||
/** The label renders below the glyph, not inside it (umlActor and friends). */
|
|
||||||
labelOutside?: boolean
|
|
||||||
/** Fixed glyph size for labelOutside shapes, which do not scale with text. */
|
|
||||||
glyph?: { w: number; h: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The curated catalog. Keys are the vocabulary the model is taught; several are
|
|
||||||
* semantic aliases for the same geometry (decision/diamond), because the model will
|
|
||||||
* reach for both names.
|
|
||||||
*/
|
|
||||||
export const SHAPE_CATALOG: Record<string, ShapeSpec> = {
|
|
||||||
// ---- the rectangle family (the original six) ----
|
|
||||||
box: { style: "rounded=0;", textScale: 1 },
|
|
||||||
round: { style: "rounded=1;arcSize=12;", textScale: 1 },
|
|
||||||
terminator: { style: "rounded=1;arcSize=50;", textScale: 1.15 },
|
|
||||||
decision: {
|
|
||||||
style: "rhombus;perimeter=rhombusPerimeter;",
|
|
||||||
textScale: 1.5,
|
|
||||||
},
|
|
||||||
diamond: {
|
|
||||||
style: "rhombus;perimeter=rhombusPerimeter;",
|
|
||||||
textScale: 1.5,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
style: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
|
|
||||||
textScale: 1.2,
|
|
||||||
},
|
|
||||||
document: { style: "shape=document;boundedLbl=1;", textScale: 1.15 },
|
|
||||||
|
|
||||||
// ---- the semantic vocabulary (D2's tier: a node that IS a thing) ----
|
|
||||||
/** A database or datastore. */
|
|
||||||
cylinder: {
|
|
||||||
style: "shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=12;",
|
|
||||||
textScale: 1.3,
|
|
||||||
},
|
|
||||||
/** A message queue: a cylinder on its side. */
|
|
||||||
queue: {
|
|
||||||
style: "shape=cylinder3;direction=south;boundedLbl=1;backgroundOutline=1;size=12;",
|
|
||||||
textScale: 1.3,
|
|
||||||
},
|
|
||||||
/** An actor or user. Label below the figure. */
|
|
||||||
person: {
|
|
||||||
style: "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;outlineConnect=0;",
|
|
||||||
textScale: 1,
|
|
||||||
labelOutside: true,
|
|
||||||
glyph: { w: 40, h: 60 },
|
|
||||||
},
|
|
||||||
/** An external system, the internet. */
|
|
||||||
cloud: { style: "ellipse;shape=cloud;", textScale: 1.6 },
|
|
||||||
/** A service or process step. */
|
|
||||||
hexagon: {
|
|
||||||
style: "shape=hexagon;perimeter=hexagonPerimeter2;fixedSize=1;size=16;",
|
|
||||||
textScale: 1.25,
|
|
||||||
},
|
|
||||||
/** A concept, state or category. */
|
|
||||||
ellipse: { style: "ellipse;", textScale: 1.3 },
|
|
||||||
/** A speech-bubble annotation. */
|
|
||||||
callout: {
|
|
||||||
style: "shape=callout;perimeter=calloutPerimeter;rounded=1;size=16;position=0.5;base=24;",
|
|
||||||
textScale: 1.35,
|
|
||||||
},
|
|
||||||
/** A chevron stage in a pipeline. */
|
|
||||||
step: {
|
|
||||||
style: "shape=step;perimeter=stepPerimeter;fixedSize=1;size=16;",
|
|
||||||
textScale: 1.2,
|
|
||||||
},
|
|
||||||
/** A sticky note. */
|
|
||||||
note: { style: "shape=note;size=14;", textScale: 1.1 },
|
|
||||||
/** A card with a cut corner. */
|
|
||||||
card: { style: "shape=card;size=14;", textScale: 1.1 },
|
|
||||||
/** A process box with side bars (predefined subroutine). */
|
|
||||||
process: { style: "shape=process;size=0.1;", textScale: 1.2 },
|
|
||||||
/** Punched tape — legacy data, files. */
|
|
||||||
tape: { style: "shape=tape;size=0.2;", textScale: 1.3 },
|
|
||||||
/** A double-walled cube. */
|
|
||||||
cube: { style: "shape=cube;size=12;", textScale: 1.25 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A shape token that may pass through unrecognised: draw.io style-key charset only. */
|
|
||||||
const SAFE_TOKEN = /^[a-zA-Z0-9._]+$/
|
|
||||||
|
|
||||||
export interface ResolvedShape {
|
|
||||||
spec: ShapeSpec
|
|
||||||
/** Set when the token was not in the catalog and passed through verbatim. */
|
|
||||||
passthrough?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a shape token: catalog entry, safe pass-through, or null for a token that
|
|
||||||
* could inject style keys (`;`/`=`/quotes) and must be rejected outright.
|
|
||||||
*/
|
|
||||||
export function resolveShape(token: string): ResolvedShape | null {
|
|
||||||
const known = SHAPE_CATALOG[token]
|
|
||||||
if (known) return { spec: known }
|
|
||||||
if (!SAFE_TOKEN.test(token)) return null
|
|
||||||
// Unknown but safe: emit verbatim. draw.io degrades an unregistered shape to a
|
|
||||||
// rectangle, so the worst case is a plain box — same as before the vocabulary
|
|
||||||
// existed. The conservative scale covers roughly-convex real shapes.
|
|
||||||
return {
|
|
||||||
spec: { style: `shape=${token};`, textScale: 1.25 },
|
|
||||||
passthrough: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The catalog key most similar to a token, for "did you mean" hints. */
|
|
||||||
export function nearestShape(token: string): string | null {
|
|
||||||
const t = token.toLowerCase()
|
|
||||||
let best: string | null = null
|
|
||||||
let bestD = 3 // more than 2 edits away is not a typo
|
|
||||||
for (const key of Object.keys(SHAPE_CATALOG)) {
|
|
||||||
const d = editDistance(t, key.toLowerCase(), bestD)
|
|
||||||
if (d < bestD) {
|
|
||||||
bestD = d
|
|
||||||
best = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bounded Levenshtein distance; returns limit when the strings are further apart. */
|
|
||||||
function editDistance(a: string, b: string, limit: number): number {
|
|
||||||
if (Math.abs(a.length - b.length) >= limit) return limit
|
|
||||||
const prev = new Array(b.length + 1)
|
|
||||||
for (let j = 0; j <= b.length; j++) prev[j] = j
|
|
||||||
for (let i = 1; i <= a.length; i++) {
|
|
||||||
let diag = prev[0]
|
|
||||||
prev[0] = i
|
|
||||||
let rowMin = prev[0]
|
|
||||||
for (let j = 1; j <= b.length; j++) {
|
|
||||||
const cur = Math.min(
|
|
||||||
prev[j] + 1,
|
|
||||||
prev[j - 1] + 1,
|
|
||||||
diag + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
||||||
)
|
|
||||||
diag = prev[j]
|
|
||||||
prev[j] = cur
|
|
||||||
if (cur < rowMin) rowMin = cur
|
|
||||||
}
|
|
||||||
if (rowMin >= limit) return limit
|
|
||||||
}
|
|
||||||
return Math.min(prev[b.length], limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- structured style merge ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge style fragments by key, later fragments winning.
|
|
||||||
*
|
|
||||||
* A draw.io style is `tok;key=value;key=value;` — bare class tokens (rhombus, ellipse,
|
|
||||||
* text) come first and key=value pairs follow. String concatenation made every
|
|
||||||
* conflict a duplicate key resolved by draw.io's last-wins rule, which worked until
|
|
||||||
* shape fragments and theme fragments both owned geometry keys (a theme's rounded=1
|
|
||||||
* against a shape's rhombus). Merging structurally keeps exactly one token per key and
|
|
||||||
* one bare-token set, so the output is canonical and the ownership rule — theme owns
|
|
||||||
* colour and type, shape owns geometry — is enforced by fragment ORDER, not by hoping
|
|
||||||
* the keys never meet.
|
|
||||||
*
|
|
||||||
* Bare tokens are kept in first-appearance order, except that a later fragment's bare
|
|
||||||
* SHAPE CLASS (rhombus/ellipse/triangle) replaces an earlier one — two shape classes
|
|
||||||
* on one cell is a contradiction, not a union.
|
|
||||||
*/
|
|
||||||
export function mergeStyle(...fragments: (string | undefined)[]): string {
|
|
||||||
const bare: string[] = []
|
|
||||||
const kv = new Map<string, string>()
|
|
||||||
const SHAPE_CLASSES = new Set(["rhombus", "ellipse", "triangle"])
|
|
||||||
for (const f of fragments) {
|
|
||||||
if (!f) continue
|
|
||||||
for (const tok of f.split(";")) {
|
|
||||||
if (tok === "") continue
|
|
||||||
const eq = tok.indexOf("=")
|
|
||||||
if (eq < 0) {
|
|
||||||
if (SHAPE_CLASSES.has(tok)) {
|
|
||||||
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
|
|
||||||
if (i >= 0) bare.splice(i, 1)
|
|
||||||
}
|
|
||||||
if (!bare.includes(tok)) bare.push(tok)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const key = tok.slice(0, eq)
|
|
||||||
kv.set(key, tok.slice(eq + 1))
|
|
||||||
// An explicit shape= also displaces a bare shape class from an earlier
|
|
||||||
// fragment — same contradiction as two bare classes.
|
|
||||||
if (key === "shape") {
|
|
||||||
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
|
|
||||||
if (i >= 0) bare.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let out = bare.join(";")
|
|
||||||
if (out) out += ";"
|
|
||||||
for (const [k, v] of kv) out += `${k}=${v};`
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
/**
|
|
||||||
* The theme: design tokens plus one composition rule, in place of style tables.
|
|
||||||
*
|
|
||||||
* The engine's original deal was: the model declares structure, the engine computes
|
|
||||||
* geometry. But every box rendered identically — white, 11px, black border — so anything
|
|
||||||
* whose meaning lives in visual hierarchy (a paper-summary poster, a cheat sheet, a
|
|
||||||
* comparison panel) came out flat, and the only escape was hand-written XML with no layout
|
|
||||||
* guarantees at all.
|
|
||||||
*
|
|
||||||
* Two ideas fix that generally, not per diagram type:
|
|
||||||
*
|
|
||||||
* ROLE — what a node IS in the information hierarchy: a masthead, a section heading, a
|
|
||||||
* key number, fine print. The model judges this well. Each role maps to a type size and
|
|
||||||
* an emphasis (filled / outlined / ghost), not to any colour.
|
|
||||||
*
|
|
||||||
* GROUP — which semantic zone a node belongs to: remote vs local, one poster section vs
|
|
||||||
* another. Each distinct group name gets one HUE RAMP — a light tint, a mid stroke, a
|
|
||||||
* dark text colour — assigned in order of first appearance.
|
|
||||||
*
|
|
||||||
* `themedStyle(role, hue, kind)` composes the two by rule. A heading container in group 2
|
|
||||||
* gets that hue's tint as its panel and the dark step for its title; a metric in the same
|
|
||||||
* group gets the mid step as a heavy border. Nothing is enumerated per combination, so a
|
|
||||||
* new diagram kind gets full theming by tagging its nodes — there is no table to extend.
|
|
||||||
* The model never sees a hex value; the same declaration always renders the same way.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** What a node is, in the information hierarchy of the diagram. */
|
|
||||||
export type Role =
|
|
||||||
| "banner" // the masthead: large type on the theme's one dark field
|
|
||||||
| "heading" // a section title / titled panel
|
|
||||||
| "body" // ordinary content (the default look)
|
|
||||||
| "callout" // something the reader must not miss
|
|
||||||
| "good" // a positive verdict (always green, group or not)
|
|
||||||
| "bad" // a negative verdict or warning (always red)
|
|
||||||
| "metric" // the key number
|
|
||||||
| "muted" // fine print
|
|
||||||
|
|
||||||
export const ROLES: readonly Role[] = [
|
|
||||||
"banner",
|
|
||||||
"heading",
|
|
||||||
"body",
|
|
||||||
"callout",
|
|
||||||
"good",
|
|
||||||
"bad",
|
|
||||||
"metric",
|
|
||||||
"muted",
|
|
||||||
]
|
|
||||||
|
|
||||||
export function isRole(v: string | null | undefined): v is Role {
|
|
||||||
return ROLES.includes(v as Role)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- tokens ----
|
|
||||||
|
|
||||||
/** One hue, three steps: a field to sit on, a line to draw with, a colour to write in. */
|
|
||||||
export interface HueRamp {
|
|
||||||
tint: string
|
|
||||||
base: string
|
|
||||||
dark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The hue ramps groups draw from, in assignment order.
|
|
||||||
*
|
|
||||||
* Tint/base pairs are draw.io's classic palette, so themed output looks native to the
|
|
||||||
* editor; the dark step is the same hue pulled down far enough for 4.5:1 text on white.
|
|
||||||
*/
|
|
||||||
export const HUES: readonly HueRamp[] = [
|
|
||||||
{ tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }, // blue
|
|
||||||
{ tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }, // green
|
|
||||||
{ tint: "#FFE6CC", base: "#D79B00", dark: "#8A5A00" }, // orange
|
|
||||||
{ tint: "#E1D5E7", base: "#9673A6", dark: "#4A2E5E" }, // purple
|
|
||||||
{ tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }, // red
|
|
||||||
{ tint: "#FFF2CC", base: "#D6B656", dark: "#7A5C00" }, // yellow
|
|
||||||
]
|
|
||||||
|
|
||||||
/** The neutral ramp, for ungrouped nodes: today's grey-on-white look. */
|
|
||||||
export const NEUTRAL: HueRamp = {
|
|
||||||
tint: "#F5F8FB",
|
|
||||||
base: "#5A6B7B",
|
|
||||||
dark: "#1A1A1A",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Semantic verdict hues: good is green and bad is red no matter what group says. */
|
|
||||||
const GOOD: HueRamp = { tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }
|
|
||||||
const BAD: HueRamp = { tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }
|
|
||||||
/** The callout field: a warm highlight distinct from every group tint. */
|
|
||||||
const CALLOUT: HueRamp = { tint: "#FFF9C4", base: "#B8860B", dark: "#6D4C00" }
|
|
||||||
|
|
||||||
/** Type scale, px. One scale for every diagram kind. */
|
|
||||||
export const TYPE = { xs: 9, sm: 11, md: 13, lg: 15, xl: 20 } as const
|
|
||||||
|
|
||||||
/** The hue ramp for the n-th distinct group. Wraps: a 7th group reuses the 1st hue. */
|
|
||||||
export function hueOf(index: number): HueRamp {
|
|
||||||
return HUES[index % HUES.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the composition rule ----
|
|
||||||
|
|
||||||
/** How a role renders, independent of colour. */
|
|
||||||
interface RoleSpec {
|
|
||||||
size: number
|
|
||||||
bold: boolean
|
|
||||||
/** filled: dark field, light text. tinted: hue field. outlined: white field, hue border.
|
|
||||||
* ghost: no field, no border — pure text. */
|
|
||||||
emphasis: "filled" | "tinted" | "outlined" | "ghost"
|
|
||||||
/** Overrides the group hue; verdicts stay green/red whatever zone they sit in. */
|
|
||||||
hue?: HueRamp
|
|
||||||
/** Fill the container's cross axis, the way a masthead spans its page. */
|
|
||||||
stretch?: boolean
|
|
||||||
/** Minimum cell height. */
|
|
||||||
minH: number
|
|
||||||
/** Character width relative to 11px type, for the measure pass. */
|
|
||||||
charScale: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The masthead field when no group says otherwise: the deep navy of the first hue. */
|
|
||||||
const BANNER: HueRamp = { tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }
|
|
||||||
|
|
||||||
const ROLE_SPECS: Record<Role, RoleSpec> = {
|
|
||||||
banner: {
|
|
||||||
size: TYPE.xl,
|
|
||||||
bold: true,
|
|
||||||
emphasis: "filled",
|
|
||||||
hue: BANNER,
|
|
||||||
stretch: true,
|
|
||||||
minH: 64,
|
|
||||||
charScale: 1.8,
|
|
||||||
},
|
|
||||||
heading: {
|
|
||||||
size: TYPE.lg,
|
|
||||||
bold: true,
|
|
||||||
emphasis: "ghost",
|
|
||||||
stretch: true,
|
|
||||||
minH: 32,
|
|
||||||
charScale: 1.35,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
size: TYPE.sm,
|
|
||||||
bold: false,
|
|
||||||
emphasis: "outlined",
|
|
||||||
minH: 44,
|
|
||||||
charScale: 1,
|
|
||||||
},
|
|
||||||
callout: {
|
|
||||||
size: TYPE.sm,
|
|
||||||
bold: true,
|
|
||||||
emphasis: "tinted",
|
|
||||||
hue: CALLOUT,
|
|
||||||
minH: 44,
|
|
||||||
charScale: 1,
|
|
||||||
},
|
|
||||||
good: {
|
|
||||||
size: TYPE.sm,
|
|
||||||
bold: false,
|
|
||||||
emphasis: "tinted",
|
|
||||||
hue: GOOD,
|
|
||||||
minH: 44,
|
|
||||||
charScale: 1,
|
|
||||||
},
|
|
||||||
bad: {
|
|
||||||
size: TYPE.sm,
|
|
||||||
bold: false,
|
|
||||||
emphasis: "tinted",
|
|
||||||
hue: BAD,
|
|
||||||
minH: 44,
|
|
||||||
charScale: 1,
|
|
||||||
},
|
|
||||||
metric: {
|
|
||||||
size: TYPE.xl,
|
|
||||||
bold: true,
|
|
||||||
emphasis: "outlined",
|
|
||||||
minH: 56,
|
|
||||||
charScale: 1.8,
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
size: TYPE.xs,
|
|
||||||
bold: false,
|
|
||||||
emphasis: "ghost",
|
|
||||||
minH: 24,
|
|
||||||
charScale: 0.82,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Does this role already draw itself with no border?
|
|
||||||
*
|
|
||||||
* The parser needs this to tell a THEME's `strokeColor=none` from a DECLARED one. A banner is
|
|
||||||
* a dark filled slab and a heading is ghost text; both are borderless because of what they
|
|
||||||
* are, not because anyone asked. Recording that as an explicit override would make it
|
|
||||||
* outlive a later role change, since `set_role` clears a node's style but keeps its text
|
|
||||||
* overrides.
|
|
||||||
*
|
|
||||||
* Leaf only: the container branch of `themedStyle` always draws a border, whatever the role.
|
|
||||||
*/
|
|
||||||
export function roleIsBorderless(
|
|
||||||
role: Role | undefined,
|
|
||||||
kind: "leaf" | "container",
|
|
||||||
): boolean {
|
|
||||||
if (kind === "container") return false
|
|
||||||
const e = ROLE_SPECS[role ?? "body"].emphasis
|
|
||||||
return e === "filled" || e === "ghost"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Metrics the measure pass needs, so layout reserves what render will draw. */
|
|
||||||
export function roleMetrics(role: Role | undefined): {
|
|
||||||
fontSize: number
|
|
||||||
minH: number
|
|
||||||
charScale: number
|
|
||||||
stretch: boolean
|
|
||||||
} {
|
|
||||||
const s = ROLE_SPECS[role ?? "body"]
|
|
||||||
return {
|
|
||||||
fontSize: s.size,
|
|
||||||
minH: s.minH,
|
|
||||||
charScale: s.charScale,
|
|
||||||
stretch: s.stretch === true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The style tokens for one node: the whole theme in a single rule.
|
|
||||||
*
|
|
||||||
* `hue` is the node's group ramp (or the neutral ramp); a role with a semantic hue
|
|
||||||
* (good/bad/callout) overrides it. `kind` softens the treatment for containers — a
|
|
||||||
* section panel is a field its children sit on, so it takes the tint at panel weight
|
|
||||||
* rather than a leaf's full treatment.
|
|
||||||
*/
|
|
||||||
export function themedStyle(
|
|
||||||
role: Role,
|
|
||||||
hue: HueRamp,
|
|
||||||
kind: "leaf" | "container",
|
|
||||||
): string {
|
|
||||||
const spec = ROLE_SPECS[role]
|
|
||||||
const ramp = spec.hue ?? hue
|
|
||||||
const size = kind === "container" && role === "banner" ? TYPE.lg : spec.size
|
|
||||||
const font = `fontSize=${size};${spec.bold || kind === "container" ? "fontStyle=1;" : ""}`
|
|
||||||
|
|
||||||
if (spec.emphasis === "filled")
|
|
||||||
return `fillColor=${ramp.dark};strokeColor=none;fontColor=#FFFFFF;${font}rounded=1;arcSize=6;`
|
|
||||||
|
|
||||||
if (kind === "container") {
|
|
||||||
// A panel: the tint as a quiet field, the dark step for its title, the base for
|
|
||||||
// its border. This is where "every section gets its own colour" comes from —
|
|
||||||
// a heading container plus a group, no extra mechanism.
|
|
||||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${font}verticalAlign=top;align=left;spacingLeft=10;spacingTop=6;`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spec.emphasis === "ghost")
|
|
||||||
return `fillColor=none;strokeColor=none;fontColor=${ramp.dark};${font}align=left;`
|
|
||||||
|
|
||||||
if (spec.emphasis === "tinted") {
|
|
||||||
// A callout keeps a heavy left bar, the editor's convention for "note well".
|
|
||||||
const bar = role === "callout" ? `strokeWidth=2;` : ""
|
|
||||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${bar}${font}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// outlined: the hue carried by the border and text. A grouped ordinary node takes its
|
|
||||||
// zone's tint as the field — colour-as-grouping is the whole point of naming a zone —
|
|
||||||
// while an ungrouped one stays white. A metric stays white either way, so its number
|
|
||||||
// sits on the page's calmest field with the hue in a heavy border.
|
|
||||||
const weight = role === "metric" ? "strokeWidth=2;" : ""
|
|
||||||
const field = role === "body" && ramp !== NEUTRAL ? ramp.tint : "#FFFFFF"
|
|
||||||
return `fillColor=${field};strokeColor=${ramp.base};fontColor=${ramp.dark};${weight}${font}`
|
|
||||||
}
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tailwind utility classes → the engine's layout fields.
|
|
||||||
*
|
|
||||||
* WHY a second way to say the same thing. The engine's own vocabulary (`dir`, `grow`,
|
|
||||||
* `align`, `justify`, `pad`, `gap`, `maxW`) is words we invented, so a model has seen them
|
|
||||||
* only in our tool description. It has seen `flex-col grow-3 items-stretch p-4` millions of
|
|
||||||
* times. Microsoft's DSL study (arXiv 2407.02742) found models hallucinate custom function
|
|
||||||
* names at a much higher rate than familiar ones, and arXiv 2311.09519 measured a large
|
|
||||||
* improvement from swapping a rare DSL for a popular language, precisely because it puts the
|
|
||||||
* output back in the distribution the model was trained on.
|
|
||||||
*
|
|
||||||
* The other half of why Tailwind and not free-form CSS: its values are a FIXED SCALE, not
|
|
||||||
* arbitrary numbers. `p-4` is 16px because one spacing unit is 4px, and there is no `p-7.5`.
|
|
||||||
* Tailwind's own docs make that the point of the thing — with inline styles "every value is a
|
|
||||||
* magic number", with utilities you pick from a system. That is the property we want, because
|
|
||||||
* an unconstrained number field is exactly where a model invents 13px here and 27px there.
|
|
||||||
*
|
|
||||||
* The supported set was picked by reading Tailwind's property index against the draw.io
|
|
||||||
* renderer's ACTUAL SOURCE — public/drawio/mxgraph/src and public/drawio/js/grapheditor,
|
|
||||||
* vendored in this repo — rather than against a prose style reference. That matters: three
|
|
||||||
* properties were excluded on wrong grounds when the reference was a document, and reading
|
|
||||||
* the code put them back (radius, strikethrough, shadow, all noted below).
|
|
||||||
*
|
|
||||||
* WHAT IS DELIBERATELY NOT HERE, and why:
|
|
||||||
*
|
|
||||||
* - COLOUR of any kind (`bg-*`, `text-red-500`, `border-blue-400`). draw.io has
|
|
||||||
* `fillColor`/`fontColor`/`strokeColor`, so this is possible — but colour is derived from
|
|
||||||
* `role` and `group` precisely so one palette stays coherent, and a colour class would be
|
|
||||||
* a back door into the hex-picking that was removed. Gradients (`bg-linear-to-b from-X
|
|
||||||
* to-Y`) are excluded for the same reason, even though `gradientColor` with a four-way
|
|
||||||
* `gradientDirection` maps onto them exactly (mxShape.js:1392-1393, 1054-1060).
|
|
||||||
*
|
|
||||||
* - Per-SIDE borders (`border-t`, `border-l-4`, `border-x`). draw.io draws these properly:
|
|
||||||
* `shape=partialRectangle` reads independent `top`/`right`/`bottom`/`left` booleans
|
|
||||||
* (Shapes.js:3914-3917) and still fills the background first (3919-3920), so a single
|
|
||||||
* heavy left edge would render correctly. The cost is the SHAPE SLOT: `partialRectangle`
|
|
||||||
* is itself a shape name, so a node could not be both a diamond and left-edge-only. What
|
|
||||||
* a node IS — a database, a decision, a person — outranks how its border looks, so the
|
|
||||||
* shape vocabulary keeps the slot.
|
|
||||||
*
|
|
||||||
* - Per-SIDE padding (`pt-8`, `px-4`). draw.io's `spacingTop`/`spacingRight`/
|
|
||||||
* `spacingBottom`/`spacingLeft` (mxText.js:422-425) look like an exact match and are not:
|
|
||||||
* they pad the LABEL inside its own cell, while this engine's `pad` is the room a
|
|
||||||
* container leaves for its CHILDREN. Accepting `pt-8` would suggest it pushes child nodes
|
|
||||||
* down, which it cannot.
|
|
||||||
*
|
|
||||||
* - `outline-*` (width, colour, style, offset). draw.io has no concept: a shape carries one
|
|
||||||
* border, and nothing draws a second ring outside it. In CSS an outline is a focus ring,
|
|
||||||
* which a static diagram does not have.
|
|
||||||
*
|
|
||||||
* - `opacity-*`. draw.io's `opacity` is 0–100 and would map cleanly, but Tailwind's
|
|
||||||
* `opacity-<number>` takes ANY number — `opacity-37` is valid — so it is not a scale.
|
|
||||||
* Admitting it would give up the one property that makes this vocabulary worth having.
|
|
||||||
*
|
|
||||||
* - `truncate` / `text-ellipsis`. Sets `text-overflow: ellipsis`. draw.io's `overflow`
|
|
||||||
* branches on exactly five values — visible, hidden, fill, width, block (mxText.js:
|
|
||||||
* 1080-1095) — and a repo-wide grep for "ellipsis" finds no implementation, so the text
|
|
||||||
* would be cut with no "…": a class named `truncate` that silently loses characters.
|
|
||||||
*
|
|
||||||
* - Seven of the nine `font-*` weights. See UNSUPPORTED_WEIGHTS below.
|
|
||||||
*
|
|
||||||
* - `text-shadow-*`. Unlike the box `shadow-*` family, draw.io's `textShadow`
|
|
||||||
* (mxText.js:668) is a bare on/off flag with no offset or blur, so Tailwind's six sizes
|
|
||||||
* would collapse into one picture.
|
|
||||||
*
|
|
||||||
* - Per-CORNER radius (`rounded-tl-lg`) and the decorative corner treatments beside it
|
|
||||||
* (snip, fold, inverse round). draw.io does have these, but only on a separate template
|
|
||||||
* shape, `mxgraph.basic.rect` (Shapes.js:4118), which would take the place of the node's
|
|
||||||
* own `shape` — the same trade the per-side borders lose. Whole-shape `rounded-*` IS
|
|
||||||
* supported and costs no slot; see RADIUS.
|
|
||||||
*
|
|
||||||
* - `tracking-*` (letter-spacing), `uppercase`/`lowercase`/`capitalize` (text-transform),
|
|
||||||
* and per-node `leading-*` (line-height). Not merely coarse — absent. Grepping the whole
|
|
||||||
* vendored renderer for letterSpacing/textTransform finds nothing, and line height is a
|
|
||||||
* global constant (`mxConstants.LINE_HEIGHT`) with no per-cell style key.
|
|
||||||
*
|
|
||||||
* - `rotate-*`, `scale-*`, `skew-*`, `translate-*`. draw.io has `rotation`/`flipH`/`flipV`,
|
|
||||||
* but a rotated box breaks the two things this engine guarantees: the layout no longer
|
|
||||||
* knows what area it covers, and the edge router cannot route around it.
|
|
||||||
*
|
|
||||||
* - Document-flow properties (`float`, `clear`, `position`, `top/right/bottom/left`,
|
|
||||||
* `z-index`, `visibility`, `columns`, `break-*`, `object-*`, `overscroll-*`) and the
|
|
||||||
* table and list families. There is no document flow here — every coordinate is computed
|
|
||||||
* — and draw.io has no z-index at all: later cells simply paint on top.
|
|
||||||
*
|
|
||||||
* - `filter`/`backdrop-filter`, `mask-*`, `mix-blend-mode`, `transition-*`, `animation`,
|
|
||||||
* `perspective*`, `cursor`, `resize`, `appearance`, `caret-color`, `accent-color`:
|
|
||||||
* no corresponding key anywhere in the vendored renderer.
|
|
||||||
*
|
|
||||||
* - Arbitrary values (`w-[137px]`, `p-[13px]`). The scale is the feature; a bracket escape
|
|
||||||
* hatch removes it.
|
|
||||||
*
|
|
||||||
* Unknown classes are returned in `ignored` rather than rejected — D2's "warnings over
|
|
||||||
* errors" rule: a diagram that renders with one class dropped beats an error that renders
|
|
||||||
* nothing. The caller reports them, which is how a typo becomes a one-turn fix instead of a
|
|
||||||
* silent no-op.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Align, Justify } from "./types"
|
|
||||||
|
|
||||||
/** What a class string resolves to. Every field optional: a class string sets only what it names. */
|
|
||||||
export interface TwLayout {
|
|
||||||
dir?: "row" | "col"
|
|
||||||
grow?: number
|
|
||||||
align?: Align
|
|
||||||
justify?: Justify
|
|
||||||
alignItems?: Align
|
|
||||||
gap?: number
|
|
||||||
pad?: number
|
|
||||||
maxW?: number
|
|
||||||
/** `min-w-0`: let a weight shrink this below its content width. */
|
|
||||||
minW0?: boolean
|
|
||||||
|
|
||||||
// ---- text, the part draw.io can actually render ----
|
|
||||||
/** `font-bold` / `font-normal`. draw.io has one bold bit, not nine weights. */
|
|
||||||
bold?: boolean
|
|
||||||
/** `italic` / `not-italic`. */
|
|
||||||
italic?: boolean
|
|
||||||
/** `underline` / `no-underline`. */
|
|
||||||
underline?: boolean
|
|
||||||
/** `line-through`. draw.io's fontStyle carries a strikethrough bit beside the other three. */
|
|
||||||
strike?: boolean
|
|
||||||
/** `text-xs`…`text-4xl` → px, from Tailwind's own scale. */
|
|
||||||
fontSize?: number
|
|
||||||
/** `text-left` / `text-center` / `text-right`. */
|
|
||||||
textAlign?: "left" | "center" | "right"
|
|
||||||
/** `align-top` / `align-middle` / `align-bottom`. */
|
|
||||||
verticalAlign?: "top" | "middle" | "bottom"
|
|
||||||
/** `whitespace-nowrap` / `whitespace-normal`. */
|
|
||||||
nowrap?: boolean
|
|
||||||
|
|
||||||
// ---- border ----
|
|
||||||
/** `border` / `border-N` → strokeWidth in px. */
|
|
||||||
borderWidth?: number
|
|
||||||
/** `border-dashed` / `border-dotted` / `border-solid`. */
|
|
||||||
borderStyle?: "solid" | "dashed" | "dotted"
|
|
||||||
/** `rounded`, `rounded-lg`, `rounded-full` → corner radius in px. */
|
|
||||||
radius?: number
|
|
||||||
/** `border-none` / `border-0`. */
|
|
||||||
borderless?: boolean
|
|
||||||
/** `shadow-sm`…`shadow-xl` → 1–4; `shadow-none` → 0. See SHADOW. */
|
|
||||||
shadow?: number
|
|
||||||
|
|
||||||
/** Classes that matched nothing, verbatim and in order. */
|
|
||||||
ignored: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tailwind's spacing scale: one unit is 0.25rem, which is 4px at the default root size.
|
|
||||||
*
|
|
||||||
* Only whole steps are accepted. Tailwind itself has half-steps (`p-0.5`), but a diagram has
|
|
||||||
* no use for 2px padding and allowing them widens the scale for nothing.
|
|
||||||
*/
|
|
||||||
const SPACING_UNIT = 4
|
|
||||||
|
|
||||||
/** `p-6` / `gap-3` → px, or null when the suffix is not a plain scale step. */
|
|
||||||
function scaleToPx(suffix: string): number | null {
|
|
||||||
if (!/^\d+$/.test(suffix)) return null
|
|
||||||
return Number(suffix) * SPACING_UNIT
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tailwind's width fractions, as a share of the parent.
|
|
||||||
*
|
|
||||||
* Expressed as `grow` rather than an absolute width, because that is what the fraction means
|
|
||||||
* inside a flex row: `w-1/3` beside `w-2/3` is the same layout as `grow-1` beside `grow-2`,
|
|
||||||
* and going through grow means the existing proportional path applies — including the rule
|
|
||||||
* that a declared cap outranks it.
|
|
||||||
*/
|
|
||||||
function fractionToGrow(suffix: string): number | null {
|
|
||||||
const m = /^(\d+)\/(\d+)$/.exec(suffix)
|
|
||||||
if (!m) return null
|
|
||||||
const num = Number(m[1])
|
|
||||||
const den = Number(m[2])
|
|
||||||
if (den === 0 || num === 0 || num > den) return null
|
|
||||||
return num
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALIGN_ITEMS: Record<string, Align> = {
|
|
||||||
"items-start": "start",
|
|
||||||
"items-center": "center",
|
|
||||||
"items-end": "end",
|
|
||||||
"items-stretch": "stretch",
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALIGN_SELF: Record<string, Align> = {
|
|
||||||
"self-start": "start",
|
|
||||||
"self-center": "center",
|
|
||||||
"self-end": "end",
|
|
||||||
"self-stretch": "stretch",
|
|
||||||
}
|
|
||||||
|
|
||||||
const JUSTIFY: Record<string, Justify> = {
|
|
||||||
"justify-start": "start",
|
|
||||||
"justify-center": "center",
|
|
||||||
"justify-end": "end",
|
|
||||||
"justify-between": "between",
|
|
||||||
"justify-around": "around",
|
|
||||||
"justify-evenly": "evenly",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tailwind's type scale in px, its own documented values.
|
|
||||||
*
|
|
||||||
* Stops at 4xl. The ladder goes on to 9xl (128px), but a 128px word is not a diagram
|
|
||||||
* label, and offering the step invites a model to pick it.
|
|
||||||
*/
|
|
||||||
const FONT_SIZE: Record<string, number> = {
|
|
||||||
"text-xs": 12,
|
|
||||||
"text-sm": 14,
|
|
||||||
"text-base": 16,
|
|
||||||
"text-lg": 18,
|
|
||||||
"text-xl": 20,
|
|
||||||
"text-2xl": 24,
|
|
||||||
"text-3xl": 30,
|
|
||||||
"text-4xl": 36,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `text-left|center|right` — horizontal text alignment inside the shape.
|
|
||||||
*
|
|
||||||
* `text-justify`, `text-start` and `text-end` are absent because draw.io's `align` has
|
|
||||||
* only the three physical values; justified text is not available at all.
|
|
||||||
*/
|
|
||||||
const TEXT_ALIGN: Record<string, "left" | "center" | "right"> = {
|
|
||||||
"text-left": "left",
|
|
||||||
"text-center": "center",
|
|
||||||
"text-right": "right",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `align-*` → draw.io's verticalAlign. */
|
|
||||||
const VERTICAL_ALIGN: Record<string, "top" | "middle" | "bottom"> = {
|
|
||||||
"align-top": "top",
|
|
||||||
"align-middle": "middle",
|
|
||||||
"align-bottom": "bottom",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tailwind's border-radius scale in px, its own documented values.
|
|
||||||
*
|
|
||||||
* These are REAL pixels, which is only true because of `absoluteArcSize`: draw.io's `arcSize`
|
|
||||||
* is a percentage of the shape by default, but that flag switches it to absolute units
|
|
||||||
* (mxShape.js:1172-1189). Without it a radius class would mean something different on every
|
|
||||||
* box, which is why this looked unimplementable at first glance.
|
|
||||||
*
|
|
||||||
* `rounded-full` is `calc(infinity * 1px)` in Tailwind v4 — "as round as it goes". The same
|
|
||||||
* function clamps the radius to half the shorter side, so any number past half the box's
|
|
||||||
* height gives a stadium. 200 is chosen rather than something enormous because the number
|
|
||||||
* reaches the user: draw.io's Arrange panel shows `arcSize` in an editable field, and a
|
|
||||||
* diagram box taller than 400px does not exist, so 200 is both always enough and readable.
|
|
||||||
*/
|
|
||||||
const RADIUS: Record<string, number> = {
|
|
||||||
"rounded-none": 0,
|
|
||||||
"rounded-xs": 2,
|
|
||||||
"rounded-sm": 4,
|
|
||||||
rounded: 4,
|
|
||||||
"rounded-md": 6,
|
|
||||||
"rounded-lg": 8,
|
|
||||||
"rounded-xl": 12,
|
|
||||||
"rounded-2xl": 16,
|
|
||||||
"rounded-3xl": 24,
|
|
||||||
"rounded-4xl": 32,
|
|
||||||
"rounded-full": 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tailwind's box-shadow steps, as a rung number the renderer turns into draw.io's five
|
|
||||||
* shadow parameters. 0 means "explicitly no shadow".
|
|
||||||
*
|
|
||||||
* draw.io's shadow is not the on/off flag it looks like: `shadowOffsetX`, `shadowOffsetY`,
|
|
||||||
* `shadowBlur`, `shadowColor` and `shadowOpacity` are read independently
|
|
||||||
* (mxShape.js:505-535) and become a CSS `drop-shadow(dx dy blur colour)` (540-552). Since
|
|
||||||
* Tailwind's own steps are also just offset-and-blur, they map one for one.
|
|
||||||
*
|
|
||||||
* Four rungs, not Tailwind's eight. `shadow-2xs` and `shadow-xs` are indistinguishable from
|
|
||||||
* `shadow-sm` at a diagram's scale, and `shadow-2xl`'s 50px blur is noise on a page of
|
|
||||||
* boxes — offering a step invites a model to pick it.
|
|
||||||
*/
|
|
||||||
const SHADOW: Record<string, number> = {
|
|
||||||
"shadow-none": 0,
|
|
||||||
"shadow-sm": 1,
|
|
||||||
"shadow-md": 2,
|
|
||||||
"shadow-lg": 3,
|
|
||||||
"shadow-xl": 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Font-weight classes that are NOT accepted, and why.
|
|
||||||
*
|
|
||||||
* Tailwind has nine weights; draw.io's `fontStyle` is a bitmask whose bold flag is a single
|
|
||||||
* bit. Accepting all nine would collapse five of them onto "bold" and four onto "normal",
|
|
||||||
* which is the same defect that rules out `shadow-*` (six sizes, one on/off flag). So only
|
|
||||||
* `font-bold` and `font-normal` are honoured and the rest are reported, rather than
|
|
||||||
* pretending a distinction the renderer cannot draw.
|
|
||||||
*/
|
|
||||||
const UNSUPPORTED_WEIGHTS = new Set([
|
|
||||||
"font-thin",
|
|
||||||
"font-extralight",
|
|
||||||
"font-light",
|
|
||||||
"font-medium",
|
|
||||||
"font-semibold",
|
|
||||||
"font-extrabold",
|
|
||||||
"font-black",
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a Tailwind class string into layout fields.
|
|
||||||
*
|
|
||||||
* Later classes win over earlier ones, the same as Tailwind's own last-one-wins behaviour
|
|
||||||
* for conflicting utilities, so a caller can append an override without removing anything.
|
|
||||||
*/
|
|
||||||
export function parseTw(classes: string): TwLayout {
|
|
||||||
const out: TwLayout = { ignored: [] }
|
|
||||||
for (const raw of String(classes ?? "").split(/\s+/)) {
|
|
||||||
const cls = raw.trim()
|
|
||||||
if (!cls) continue
|
|
||||||
|
|
||||||
// Direction. `flex` on its own is the default and says nothing here — every engine
|
|
||||||
// container is already a flex container — so it is accepted and ignored rather than
|
|
||||||
// reported, since a model writing `flex flex-col` is not making a mistake.
|
|
||||||
if (cls === "flex" || cls === "flex-row") {
|
|
||||||
if (cls === "flex-row") out.dir = "row"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls === "flex-col") {
|
|
||||||
out.dir = "col"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cls in ALIGN_ITEMS) {
|
|
||||||
out.alignItems = ALIGN_ITEMS[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls in ALIGN_SELF) {
|
|
||||||
out.align = ALIGN_SELF[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls in JUSTIFY) {
|
|
||||||
out.justify = JUSTIFY[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// `grow` alone is flex-grow: 1, `grow-N` is the weight. Tailwind writes the latter
|
|
||||||
// as `grow-[3]`; the plain form is accepted because it is what a model reaches for
|
|
||||||
// and the bracket form carries no extra meaning here.
|
|
||||||
if (cls === "grow") {
|
|
||||||
out.grow = 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const growN = /^grow-(\d+)$/.exec(cls)
|
|
||||||
if (growN) {
|
|
||||||
out.grow = Number(growN[1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `flex-1` / `flex-3`: the shorthand whose whole point is proportional sizing.
|
|
||||||
const flexN = /^flex-(\d+)$/.exec(cls)
|
|
||||||
if (flexN) {
|
|
||||||
out.grow = Number(flexN[1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fractional widths become weights — see fractionToGrow.
|
|
||||||
const wFrac = /^w-(\d+\/\d+)$/.exec(cls)
|
|
||||||
if (wFrac) {
|
|
||||||
const g = fractionToGrow(wFrac[1])
|
|
||||||
if (g !== null) {
|
|
||||||
out.grow = g
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cls === "w-full") {
|
|
||||||
out.align = "stretch"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// `min-w-0` is the standard CSS escape hatch for "let the weight win over my
|
|
||||||
// content width". Without it a weighted child is floored by its own text — that is
|
|
||||||
// real flexbox behaviour, since `min-width` defaults to `auto` — so a narrow column
|
|
||||||
// beside a wide one settles at its text width and a declared 2:1 comes out 1.4:1.
|
|
||||||
if (cls === "min-w-0") {
|
|
||||||
out.minW0 = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spacing. `p-*` is interior padding, `gap-*` the space between children. Tailwind's
|
|
||||||
// per-side variants (`pt-*`, `px-*`) are not here: the engine has one padding value,
|
|
||||||
// and quietly treating `pt-8` as padding on all four sides would be wrong in a way
|
|
||||||
// the model could not see.
|
|
||||||
const pad = /^p-(\d+)$/.exec(cls)
|
|
||||||
if (pad) {
|
|
||||||
const px = scaleToPx(pad[1])
|
|
||||||
if (px !== null) {
|
|
||||||
out.pad = px
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const gap = /^gap-(\d+)$/.exec(cls)
|
|
||||||
if (gap) {
|
|
||||||
const px = scaleToPx(gap[1])
|
|
||||||
if (px !== null) {
|
|
||||||
out.gap = px
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `max-w-*` uses the spacing scale too, so `max-w-96` is 384px. Tailwind's named
|
|
||||||
// sizes are also accepted, because a model reaches for `max-w-md` more readily than
|
|
||||||
// for a step number.
|
|
||||||
const maxW = /^max-w-(\d+)$/.exec(cls)
|
|
||||||
if (maxW) {
|
|
||||||
const px = scaleToPx(maxW[1])
|
|
||||||
if (px !== null) {
|
|
||||||
out.maxW = px
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const named = NAMED_MAX_W[cls]
|
|
||||||
if (named) {
|
|
||||||
out.maxW = named
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- text ----
|
|
||||||
// The three flags draw.io's fontStyle bitmask actually carries. They combine by
|
|
||||||
// adding bits, so bold + italic is legal and needs no special case here.
|
|
||||||
if (cls === "font-bold" || cls === "font-normal") {
|
|
||||||
out.bold = cls === "font-bold"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// The other seven weights fall through to `ignored` on purpose, so the model is
|
|
||||||
// told the distinction was dropped instead of quietly getting plain bold.
|
|
||||||
if (UNSUPPORTED_WEIGHTS.has(cls)) {
|
|
||||||
out.ignored.push(cls)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls === "italic" || cls === "not-italic") {
|
|
||||||
out.italic = cls === "italic"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls === "underline" || cls === "no-underline") {
|
|
||||||
out.underline = cls === "underline"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Strikethrough is its own bit (8) beside bold/italic/underline, so it combines with
|
|
||||||
// them rather than replacing one. `no-underline` above deliberately does NOT clear
|
|
||||||
// it: in CSS both are values of `text-decoration-line`, and Tailwind's `no-underline`
|
|
||||||
// means "not underlined", not "undecorated".
|
|
||||||
if (cls === "line-through") {
|
|
||||||
out.strike = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `text-*` is three different Tailwind properties sharing one prefix: size
|
|
||||||
// (text-lg), alignment (text-left) and COLOUR (text-red-500). The size and
|
|
||||||
// alignment tables are exact-match, so a colour class falls through to `ignored`
|
|
||||||
// rather than being mistaken for a size.
|
|
||||||
if (cls in FONT_SIZE) {
|
|
||||||
out.fontSize = FONT_SIZE[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls in TEXT_ALIGN) {
|
|
||||||
out.textAlign = TEXT_ALIGN[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls in VERTICAL_ALIGN) {
|
|
||||||
out.verticalAlign = VERTICAL_ALIGN[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cls === "whitespace-nowrap" || cls === "whitespace-normal") {
|
|
||||||
out.nowrap = cls === "whitespace-nowrap"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- border ----
|
|
||||||
// `border` alone is 1px, `border-N` is N px — Tailwind's border width is a plain
|
|
||||||
// pixel count, not the 4px spacing scale.
|
|
||||||
if (cls === "border") {
|
|
||||||
out.borderWidth = 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `border-0` and `border-none` both mean no border, so they are handled before the
|
|
||||||
// numeric case (which would otherwise read border-0 as a zero-width border and
|
|
||||||
// leave draw.io drawing its default hairline).
|
|
||||||
if (cls === "border-none" || cls === "border-0") {
|
|
||||||
out.borderless = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const bw = /^border-(\d+)$/.exec(cls)
|
|
||||||
if (bw) {
|
|
||||||
out.borderWidth = Number(bw[1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
cls === "border-solid" ||
|
|
||||||
cls === "border-dashed" ||
|
|
||||||
cls === "border-dotted"
|
|
||||||
) {
|
|
||||||
out.borderStyle = cls.slice("border-".length) as
|
|
||||||
| "solid"
|
|
||||||
| "dashed"
|
|
||||||
| "dotted"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Whole-shape corner radius. Per-corner classes (`rounded-tl-lg`) fall through to
|
|
||||||
// `ignored`: draw.io only offers those on a separate template shape.
|
|
||||||
if (cls in RADIUS) {
|
|
||||||
out.radius = RADIUS[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop shadow. Per-side border classes (`border-l-4`) fall through to `ignored`, and
|
|
||||||
// so does every colour form (`shadow-blue-500`) since these tables are exact-match.
|
|
||||||
if (cls in SHADOW) {
|
|
||||||
out.shadow = SHADOW[cls]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out.ignored.push(cls)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tailwind's named max-width steps, in px.
|
|
||||||
*
|
|
||||||
* Tailwind's own values, rounded to whole pixels. Stopping at `4xl` is deliberate: anything
|
|
||||||
* wider than about a thousand pixels is not a cap a diagram needs, and offering the whole
|
|
||||||
* ladder invites a model to pick one at random.
|
|
||||||
*/
|
|
||||||
const NAMED_MAX_W: Record<string, number> = {
|
|
||||||
"max-w-xs": 320,
|
|
||||||
"max-w-sm": 384,
|
|
||||||
"max-w-md": 448,
|
|
||||||
"max-w-lg": 512,
|
|
||||||
"max-w-xl": 576,
|
|
||||||
"max-w-2xl": 672,
|
|
||||||
"max-w-3xl": 768,
|
|
||||||
"max-w-4xl": 896,
|
|
||||||
}
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
/**
|
|
||||||
* The declarative node tree the layout engine works on.
|
|
||||||
*
|
|
||||||
* The model never writes coordinates. It declares nesting and direction; the engine
|
|
||||||
* computes every x/y/width/height. The tree is not persisted anywhere — it is
|
|
||||||
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
|
|
||||||
* stays the single source of truth and a user's manual edits are an input, never
|
|
||||||
* something to be reconciled against a second copy of the state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Direction } from "./markers"
|
|
||||||
import type { Role } from "./theme"
|
|
||||||
|
|
||||||
export type { Direction } from "./markers"
|
|
||||||
export type { Role } from "./theme"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which cell of a swimlane pool a node sits in.
|
|
||||||
*
|
|
||||||
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
|
|
||||||
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
|
|
||||||
*/
|
|
||||||
export interface PoolCell {
|
|
||||||
lane: number
|
|
||||||
col: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
|
|
||||||
* pin to either edge, centre (the default), or stretch to fill the axis.
|
|
||||||
*/
|
|
||||||
export type Align = "start" | "center" | "end" | "stretch"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Presentation a node may override, beyond what its `role` decides.
|
|
||||||
*
|
|
||||||
* The admission test is that draw.io can draw the distinction FAITHFULLY — see tw.ts for the
|
|
||||||
* properties that failed it and why. Most fields here are one style key with one value; a few
|
|
||||||
* (`shadow`, `borderStyle`, the radius trio) expand to a fixed group of keys, which is fine
|
|
||||||
* because the field still names one visual decision. What is not allowed is a field whose
|
|
||||||
* values collapse onto fewer pictures than it promises.
|
|
||||||
*
|
|
||||||
* Kept as one optional object rather than a dozen loose fields so the round-trip has one
|
|
||||||
* thing to carry and the node type does not grow a field per CSS property.
|
|
||||||
*
|
|
||||||
* `role` remains the primary way to say what a node IS; this is for the cases where the
|
|
||||||
* model needs to override one aspect of how it looks.
|
|
||||||
*/
|
|
||||||
export interface TextStyle {
|
|
||||||
/** Bold. draw.io's fontStyle carries one bold bit, not a weight ladder. */
|
|
||||||
bold?: boolean
|
|
||||||
italic?: boolean
|
|
||||||
underline?: boolean
|
|
||||||
/** Strikethrough — a fourth bit in the same mask, so it combines with the others. */
|
|
||||||
strike?: boolean
|
|
||||||
/** Type size in px. */
|
|
||||||
size?: number
|
|
||||||
/** Horizontal text alignment inside the shape. */
|
|
||||||
align?: "left" | "center" | "right"
|
|
||||||
/** Vertical text alignment inside the shape. */
|
|
||||||
valign?: "top" | "middle" | "bottom"
|
|
||||||
/** Keep the label on one line instead of wrapping it. */
|
|
||||||
nowrap?: boolean
|
|
||||||
/** Border thickness in px. */
|
|
||||||
borderWidth?: number
|
|
||||||
/** Border line style. Dashed and dotted read as "planned", "optional", "logical". */
|
|
||||||
borderStyle?: "solid" | "dashed" | "dotted"
|
|
||||||
/**
|
|
||||||
* Corner radius in px.
|
|
||||||
*
|
|
||||||
* Real pixels, not a percentage: draw.io's `arcSize` is a percentage of the shape by
|
|
||||||
* default, but `absoluteArcSize=1` switches it to absolute units, and it halves the
|
|
||||||
* value, so an 8px radius is emitted as `arcSize=16` (mxShape.getArcSize,
|
|
||||||
* mxShape.js:1172-1189).
|
|
||||||
*
|
|
||||||
* Overrides the radius of a shape that has one of its own: `round` and `terminator` are
|
|
||||||
* rounded rectangles already, and changing how round they are does not change what they
|
|
||||||
* are, so a radius class is allowed to win.
|
|
||||||
*/
|
|
||||||
radius?: number
|
|
||||||
/** No border at all — a plain colour field. */
|
|
||||||
borderless?: boolean
|
|
||||||
/**
|
|
||||||
* Drop shadow, as a rung: 1–4 for Tailwind's sm/md/lg/xl, 0 for explicitly none.
|
|
||||||
*
|
|
||||||
* A rung rather than raw offsets because draw.io takes five separate numbers
|
|
||||||
* (`shadowOffsetX/Y`, `shadowBlur`, `shadowColor`, `shadowOpacity` — mxShape.js:505-535)
|
|
||||||
* and letting a caller set them individually is exactly the magic-number freedom this
|
|
||||||
* vocabulary exists to remove.
|
|
||||||
*/
|
|
||||||
shadow?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How a container spreads its children along its own stacking axis — CSS's
|
|
||||||
* justify-content, and Yoga's six values.
|
|
||||||
*
|
|
||||||
* Until this existed the policy was hard-coded and differed per axis: a row padded its
|
|
||||||
* gaps and centred the result, a column packed to the top and left every spare pixel in
|
|
||||||
* one slab at the bottom. That slab is the empty bottom-left corner of a poster, and
|
|
||||||
* nothing the model could declare would move it.
|
|
||||||
*/
|
|
||||||
export type Justify =
|
|
||||||
| "start"
|
|
||||||
| "center"
|
|
||||||
| "end"
|
|
||||||
| "between"
|
|
||||||
| "around"
|
|
||||||
| "evenly"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a box IS, drawn as its conventional outline.
|
|
||||||
*
|
|
||||||
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
|
|
||||||
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
|
|
||||||
* token passes through verbatim and degrades to a rectangle if the editor does not
|
|
||||||
* know it. See shapes.ts.
|
|
||||||
*/
|
|
||||||
export type BoxShape = string
|
|
||||||
|
|
||||||
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
|
|
||||||
export interface IconNode {
|
|
||||||
kind: "icon"
|
|
||||||
id: string
|
|
||||||
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
/** Glyph size in px. Defaults to the diagram's icon size. */
|
|
||||||
size?: number
|
|
||||||
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
|
|
||||||
style?: string
|
|
||||||
/** User froze this node's position — the engine must not move it. */
|
|
||||||
pinned?: boolean
|
|
||||||
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
|
|
||||||
rect?: Rect
|
|
||||||
/** Position within a `pool` parent. Ignored elsewhere. */
|
|
||||||
cell?: PoolCell
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A plain labelled rectangle, for things the catalog has no icon for. */
|
|
||||||
export interface BoxNode {
|
|
||||||
kind: "box"
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
w?: number
|
|
||||||
h?: number
|
|
||||||
fill?: string
|
|
||||||
stroke?: string
|
|
||||||
bold?: boolean
|
|
||||||
/** What this node IS in the information hierarchy; the theme decides how that looks. */
|
|
||||||
role?: Role
|
|
||||||
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
|
|
||||||
group?: string
|
|
||||||
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
|
|
||||||
grow?: number
|
|
||||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
|
||||||
align?: Align
|
|
||||||
/**
|
|
||||||
* Hard cap on width, px. Text rewraps to fit instead of running the box wider, so
|
|
||||||
* this is what stops one long sentence stretching a whole page into a letterbox.
|
|
||||||
* Higher priority than `grow`, matching Yoga's min/max rule.
|
|
||||||
*/
|
|
||||||
maxW?: number
|
|
||||||
/** Let a `grow` weight shrink this below its own text width — CSS's `min-width: 0`. */
|
|
||||||
minW0?: boolean
|
|
||||||
/** Presentation overrides: type, alignment, border. Absent means the role decides. */
|
|
||||||
text?: TextStyle
|
|
||||||
/** Flowchart outline. Absent means a plain rectangle. */
|
|
||||||
shape?: BoxShape
|
|
||||||
style?: string
|
|
||||||
pinned?: boolean
|
|
||||||
rect?: Rect
|
|
||||||
/** Position within a `pool` parent. Ignored elsewhere. */
|
|
||||||
cell?: PoolCell
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A page title. At most one per diagram; laid out outside the tree flow. */
|
|
||||||
export interface TitleNode {
|
|
||||||
kind: "title"
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A container that stacks its children in one direction.
|
|
||||||
*
|
|
||||||
* `gname` is the catalog group stencil (group_vpc, group_region, …). When null the
|
|
||||||
* container renders as a plain frame — a labelled rectangle with a border.
|
|
||||||
*/
|
|
||||||
export interface GroupNode {
|
|
||||||
kind: "group"
|
|
||||||
id: string
|
|
||||||
gname: string | null
|
|
||||||
label: string
|
|
||||||
dir: Extract<Direction, "row" | "col">
|
|
||||||
gap: number
|
|
||||||
children: DiagramNode[]
|
|
||||||
fill?: string
|
|
||||||
stroke?: string
|
|
||||||
/** Section role; a themed panel for its children. */
|
|
||||||
role?: Role
|
|
||||||
/** Semantic zone name; the panel takes this hue's tint. */
|
|
||||||
group?: string
|
|
||||||
/** Share of the parent's leftover flow-axis space, like flex-grow. */
|
|
||||||
grow?: number
|
|
||||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
|
||||||
align?: Align
|
|
||||||
/** How the children spread along `dir`. Absent = start (packed, no extra spacing). */
|
|
||||||
justify?: Justify
|
|
||||||
/** Cross-axis default for every child that does not declare its own `align`. */
|
|
||||||
alignItems?: Align
|
|
||||||
/** Hard cap on width, px. Children wrap or shrink to fit rather than overflow it. */
|
|
||||||
maxW?: number
|
|
||||||
/**
|
|
||||||
* Let a `grow` weight shrink this below its own content width — CSS's `min-width: 0`.
|
|
||||||
*
|
|
||||||
* Without it a weighted child is floored by its text, which is real flexbox behaviour
|
|
||||||
* (`min-width` defaults to `auto`) but means a declared 2:1 quietly resolves to
|
|
||||||
* whatever the two columns' text allows.
|
|
||||||
*/
|
|
||||||
minW0?: boolean
|
|
||||||
/** Presentation overrides: title type, alignment, frame border. */
|
|
||||||
text?: TextStyle
|
|
||||||
/** Interior padding, px. Absent = the default (24). */
|
|
||||||
pad?: number
|
|
||||||
style?: string
|
|
||||||
pinned?: boolean
|
|
||||||
rect?: Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A container that packs its children into a fixed number of columns. */
|
|
||||||
export interface GridNode {
|
|
||||||
kind: "grid"
|
|
||||||
id: string
|
|
||||||
gname: string | null
|
|
||||||
label: string
|
|
||||||
cols: number
|
|
||||||
gap: number
|
|
||||||
children: DiagramNode[]
|
|
||||||
fill?: string
|
|
||||||
stroke?: string
|
|
||||||
style?: string
|
|
||||||
pinned?: boolean
|
|
||||||
rect?: Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A swimlane pool: a sparse grid of (lane, column) cells.
|
|
||||||
*
|
|
||||||
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
|
|
||||||
* cells stay empty — that is the whole point of a swimlane diagram, where a step belongs
|
|
||||||
* to exactly one role and the columns show the order things happen in.
|
|
||||||
*
|
|
||||||
* `phases` is an optional band of milestone labels above the columns.
|
|
||||||
*/
|
|
||||||
export interface PoolNode {
|
|
||||||
kind: "pool"
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
/** Role names, one per band. */
|
|
||||||
lanes: string[]
|
|
||||||
/** Milestone labels spanning the columns. Empty means no milestone band. */
|
|
||||||
phases: string[]
|
|
||||||
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
|
|
||||||
orientation: "horizontal" | "vertical"
|
|
||||||
gap: number
|
|
||||||
children: DiagramNode[]
|
|
||||||
style?: string
|
|
||||||
pinned?: boolean
|
|
||||||
rect?: Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sequence diagram: participants across the top, lifelines hanging below them.
|
|
||||||
*
|
|
||||||
* Children are the participant heads, in left-to-right order. The messages are ordinary
|
|
||||||
* links whose `step` gives the vertical order — so the same `link` operation that draws
|
|
||||||
* an arrow in a flowchart draws a message here.
|
|
||||||
*
|
|
||||||
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
|
|
||||||
* ever attaches to a lifeline directly.
|
|
||||||
*/
|
|
||||||
export interface SequenceNode {
|
|
||||||
kind: "sequence"
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
/** Horizontal distance between participant centres. */
|
|
||||||
gap: number
|
|
||||||
/** Vertical distance between consecutive messages. */
|
|
||||||
step: number
|
|
||||||
children: DiagramNode[]
|
|
||||||
style?: string
|
|
||||||
pinned?: boolean
|
|
||||||
rect?: Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A mind map or org chart: a root with branches radiating from it.
|
|
||||||
*
|
|
||||||
* Children are a FLAT list of every node in the map. The hierarchy comes from the links —
|
|
||||||
* an arrow from A to B means B is a branch of A — not from nesting.
|
|
||||||
*
|
|
||||||
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
|
|
||||||
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
|
|
||||||
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
|
|
||||||
* org chart the arrows ARE the structure.
|
|
||||||
*
|
|
||||||
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
|
|
||||||
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
|
|
||||||
* chart wants: a reporting line only reads correctly downwards.
|
|
||||||
*/
|
|
||||||
export interface RadialNode {
|
|
||||||
kind: "radial"
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
spread: "radial" | "down"
|
|
||||||
/** Distance from a parent's edge to its children. */
|
|
||||||
gap: number
|
|
||||||
children: DiagramNode[]
|
|
||||||
style?: string
|
|
||||||
pinned?: boolean
|
|
||||||
rect?: Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContainerNode =
|
|
||||||
| GroupNode
|
|
||||||
| GridNode
|
|
||||||
| PoolNode
|
|
||||||
| SequenceNode
|
|
||||||
| RadialNode
|
|
||||||
export type LeafNode = IconNode | BoxNode | TitleNode
|
|
||||||
export type DiagramNode = ContainerNode | LeafNode
|
|
||||||
|
|
||||||
export interface Rect {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
|
|
||||||
export interface LinkSpec {
|
|
||||||
/** Cell id, so an existing edge can be addressed by later operations. */
|
|
||||||
id?: string
|
|
||||||
source: string
|
|
||||||
target: string
|
|
||||||
label?: string
|
|
||||||
/** Dashed line — replication, sync, policy, lineage. */
|
|
||||||
dashed?: boolean
|
|
||||||
/**
|
|
||||||
* A bold arrow: the relationship IS the point — a transformation, the main flow.
|
|
||||||
* Thick and coloured, a visual element rather than a hairline connector.
|
|
||||||
*/
|
|
||||||
bold?: boolean
|
|
||||||
/**
|
|
||||||
* Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens:
|
|
||||||
* block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany,
|
|
||||||
* ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target,
|
|
||||||
* nothing at the source). `headFill`/`tailFill` distinguish UML composition
|
|
||||||
* (filled diamond) from aggregation (hollow) — conventions where fill IS meaning.
|
|
||||||
*/
|
|
||||||
head?: string
|
|
||||||
tail?: string
|
|
||||||
headFill?: boolean
|
|
||||||
tailFill?: boolean
|
|
||||||
/** Step number, rendered as an "N. " prefix on the label. */
|
|
||||||
step?: number
|
|
||||||
/** Verbatim style, when recovered from XML. */
|
|
||||||
style?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A whole diagram page: the node forest plus its arrows. */
|
|
||||||
export interface DiagramTree {
|
|
||||||
/** Top-level nodes, in layout order. */
|
|
||||||
roots: DiagramNode[]
|
|
||||||
links: LinkSpec[]
|
|
||||||
/** Page title, if the diagram has one. */
|
|
||||||
title?: string
|
|
||||||
/**
|
|
||||||
* Target width : height of the whole page. 1 is square, 1.6 landscape, 0.7 portrait.
|
|
||||||
*
|
|
||||||
* This is the one number that decides whether a diagram reads as a poster or as a
|
|
||||||
* letterbox, and it cannot be derived: the same content is a legitimate 1-column
|
|
||||||
* portrait or 3-column landscape. So the model declares it, the engine gives the top
|
|
||||||
* level a width to match, and every proportional rule below finally has a share of
|
|
||||||
* something real to divide up.
|
|
||||||
*/
|
|
||||||
aspect?: number
|
|
||||||
/**
|
|
||||||
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
|
|
||||||
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
|
|
||||||
* a re-layout never destroys work the engine does not understand.
|
|
||||||
*/
|
|
||||||
foreign: ForeignCell[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A cell carried through the round-trip without interpretation. */
|
|
||||||
export interface ForeignCell {
|
|
||||||
id: string
|
|
||||||
/** The cell's own serialised XML, verbatim. */
|
|
||||||
xml: string
|
|
||||||
/** Parent id at parse time, so it can be re-attached. */
|
|
||||||
parent: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isContainer(n: DiagramNode): n is ContainerNode {
|
|
||||||
return (
|
|
||||||
n.kind === "group" ||
|
|
||||||
n.kind === "grid" ||
|
|
||||||
n.kind === "pool" ||
|
|
||||||
n.kind === "sequence" ||
|
|
||||||
n.kind === "radial"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLeaf(n: DiagramNode): n is LeafNode {
|
|
||||||
return !isContainer(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Depth-first walk over a node and its descendants. */
|
|
||||||
export function* walk(n: DiagramNode): Generator<DiagramNode> {
|
|
||||||
yield n
|
|
||||||
if (isContainer(n)) for (const c of n.children) yield* walk(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every node in a tree, in document order. */
|
|
||||||
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
|
|
||||||
for (const r of t.roots) yield* walk(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find a node by id, or null. */
|
|
||||||
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
|
|
||||||
for (const n of walkTree(t)) if (n.id === id) return n
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The container holding `id`, or null when it is a root or absent. */
|
|
||||||
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
|
|
||||||
for (const n of walkTree(t)) {
|
|
||||||
if (!isContainer(n)) continue
|
|
||||||
if (n.children.some((c) => c.id === id)) return n
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
/**
|
|
||||||
* Obstacle-avoiding orthogonal routing: the orthogonal visibility graph, and A* over it.
|
|
||||||
*
|
|
||||||
* The router beside this file works by trying a list of candidate shapes — straight, an L, a
|
|
||||||
* Z with its trunk in some lane — and keeping the first that is clear. That can only ever be
|
|
||||||
* as good as the list, and a fixed list is not enough: measured over 250 generated
|
|
||||||
* flowcharts, 347 arrows were drawn through a box that had nothing to do with them. Adding
|
|
||||||
* shapes to the list moves the failures around rather than removing them.
|
|
||||||
*
|
|
||||||
* This is the complete alternative, from Wybrow, Marriott & Stuckey, "Orthogonal Connector
|
|
||||||
* Routing" (Graph Drawing 2009) — the algorithm behind libavoid. Two ideas make it work:
|
|
||||||
*
|
|
||||||
* 1. THE GRID IS FINITE AND SUFFICIENT. Take the "interesting points": every obstacle
|
|
||||||
* corner and every connection point. Their x-coordinates and y-coordinates define a
|
|
||||||
* grid. The paper's observation, with proof: for any valid orthogonal route there is a
|
|
||||||
* route using only this grid that is no longer and has no more bends — shrink each
|
|
||||||
* segment onto the nearest grid line. So searching the grid loses nothing, and there is
|
|
||||||
* no resolution to tune. This is what a uniform pixel grid gets wrong in both
|
|
||||||
* directions at once: too coarse and it cannot fit through a narrow gap, too fine and
|
|
||||||
* the search explodes.
|
|
||||||
*
|
|
||||||
* 2. THE STATE INCLUDES THE DIRECTION OF ARRIVAL. Bends have to be paid for, and whether
|
|
||||||
* the next step is a bend depends on which way this one came in. So a search state is
|
|
||||||
* (point, incoming direction), not just (point). Without that the cost function cannot
|
|
||||||
* see bends at all.
|
|
||||||
*
|
|
||||||
* The heuristic is the one libavoid uses: Manhattan distance to the target plus the minimum
|
|
||||||
* number of bends still needed, times the bend cost. It never overestimates — the remaining
|
|
||||||
* path is at least the straight-line Manhattan distance, and it must contain at least that
|
|
||||||
* many bends — so A* returns a cheapest route, not merely a route.
|
|
||||||
*
|
|
||||||
* Written from the paper's description rather than ported: the reference implementation is a
|
|
||||||
* C++ library built for interactive re-routing, with incremental scanline updates and pin
|
|
||||||
* management that a one-shot XML generator has no use for.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Rect } from "./types"
|
|
||||||
|
|
||||||
export interface Point {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which way a path segment travels. Indices are used as array offsets. */
|
|
||||||
const DIRS = [
|
|
||||||
{ dx: 0, dy: -1 }, // 0 north
|
|
||||||
{ dx: 1, dy: 0 }, // 1 east
|
|
||||||
{ dx: 0, dy: 1 }, // 2 south
|
|
||||||
{ dx: -1, dy: 0 }, // 3 west
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cost of one bend, in pixels of path length.
|
|
||||||
*
|
|
||||||
* libavoid's default is 10. It has to be positive or the search has no reason to prefer a
|
|
||||||
* straight line to a staircase of the same length, and the two look nothing alike.
|
|
||||||
*/
|
|
||||||
const BEND_COST = 10
|
|
||||||
|
|
||||||
/** Clearance kept around an obstacle, matching the router's own margin. */
|
|
||||||
const MARGIN = 7
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Does the segment from `p` to `q` pass through any obstacle?
|
|
||||||
*
|
|
||||||
* Obstacles are expanded by `MARGIN` first, so a route grazing a border counts as a hit —
|
|
||||||
* an arrow drawn hard against a box reads as touching it.
|
|
||||||
*/
|
|
||||||
function blocked(p: Point, q: Point, obstacles: Rect[]): boolean {
|
|
||||||
const lo = { x: Math.min(p.x, q.x), y: Math.min(p.y, q.y) }
|
|
||||||
const hi = { x: Math.max(p.x, q.x), y: Math.max(p.y, q.y) }
|
|
||||||
for (const r of obstacles) {
|
|
||||||
if (
|
|
||||||
lo.x < r.x + r.w + MARGIN &&
|
|
||||||
hi.x > r.x - MARGIN &&
|
|
||||||
lo.y < r.y + r.h + MARGIN &&
|
|
||||||
hi.y > r.y - MARGIN
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The minimum number of bends to get from `p`, travelling in direction `d`, to `t`.
|
|
||||||
*
|
|
||||||
* This is the table in the paper's Figure 2(a), as an arithmetic rule rather than sixteen
|
|
||||||
* cases. Two independent questions: is the target ahead along the current axis, and is it
|
|
||||||
* off to the side? Each answer costs bends, and they compose.
|
|
||||||
*/
|
|
||||||
function bendsToTarget(p: Point, d: number, t: Point): number {
|
|
||||||
const { dx, dy } = DIRS[d]
|
|
||||||
// How far the target lies along the direction of travel, and across it.
|
|
||||||
const along = dx !== 0 ? (t.x - p.x) * dx : (t.y - p.y) * dy
|
|
||||||
const across = dx !== 0 ? t.y - p.y : t.x - p.x
|
|
||||||
if (across === 0) {
|
|
||||||
// Dead ahead: no bend. Directly behind: out and back, two bends.
|
|
||||||
return along >= 0 ? 0 : 2
|
|
||||||
}
|
|
||||||
// Off to the side: one bend if it is also ahead, two if it is behind.
|
|
||||||
return along > 0 ? 1 : 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A cheapest obstacle-free orthogonal path from `from` to `to`, or null if none exists.
|
|
||||||
*
|
|
||||||
* `startDir` and `endDir` are the directions the path must leave and arrive by — the side of
|
|
||||||
* the shape each end attaches to. Constraining them is what stops an arrow leaving a box and
|
|
||||||
* immediately turning back across it: a departure direction the search must honour on its
|
|
||||||
* first step cannot double back.
|
|
||||||
*
|
|
||||||
* `extraLanes` lets the caller add grid lines the obstacles alone would not produce, which
|
|
||||||
* matters when a port sits somewhere other than an obstacle corner.
|
|
||||||
*/
|
|
||||||
export function routeOrthogonal(
|
|
||||||
from: Point,
|
|
||||||
to: Point,
|
|
||||||
startDir: number,
|
|
||||||
endDir: number,
|
|
||||||
obstacles: Rect[],
|
|
||||||
extraLanes: { xs: number[]; ys: number[] } = { xs: [], ys: [] },
|
|
||||||
): Point[] | null {
|
|
||||||
// --- the interesting-points grid
|
|
||||||
const xs = new Set<number>([from.x, to.x, ...extraLanes.xs])
|
|
||||||
const ys = new Set<number>([from.y, to.y, ...extraLanes.ys])
|
|
||||||
for (const r of obstacles) {
|
|
||||||
// Just outside each edge, so a lane hugging an obstacle is still usable.
|
|
||||||
xs.add(r.x - MARGIN - 1)
|
|
||||||
xs.add(r.x + r.w + MARGIN + 1)
|
|
||||||
ys.add(r.y - MARGIN - 1)
|
|
||||||
ys.add(r.y + r.h + MARGIN + 1)
|
|
||||||
}
|
|
||||||
const X = [...xs].sort((a, b) => a - b)
|
|
||||||
const Y = [...ys].sort((a, b) => a - b)
|
|
||||||
const xi = new Map(X.map((v, i) => [v, i]))
|
|
||||||
const yi = new Map(Y.map((v, i) => [v, i]))
|
|
||||||
|
|
||||||
const sx = xi.get(from.x)
|
|
||||||
const sy = yi.get(from.y)
|
|
||||||
const tx = xi.get(to.x)
|
|
||||||
const ty = yi.get(to.y)
|
|
||||||
if (sx == null || sy == null || tx == null || ty == null) return null
|
|
||||||
|
|
||||||
// --- A* over (grid point, incoming direction)
|
|
||||||
const key = (ix: number, iy: number, d: number) =>
|
|
||||||
(iy * X.length + ix) * 4 + d
|
|
||||||
const best = new Map<number, number>()
|
|
||||||
const parent = new Map<number, number>()
|
|
||||||
// A binary heap would be tidier, but the frontier stays small on diagram-sized inputs and
|
|
||||||
// a sorted insert keeps this readable.
|
|
||||||
const open: { ix: number; iy: number; d: number; g: number; f: number }[] =
|
|
||||||
[]
|
|
||||||
const push = (ix: number, iy: number, d: number, g: number, f: number) => {
|
|
||||||
let lo = 0
|
|
||||||
let hi = open.length
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi) >> 1
|
|
||||||
if (open[mid].f > f) lo = mid + 1
|
|
||||||
else hi = mid
|
|
||||||
}
|
|
||||||
open.splice(lo, 0, { ix, iy, d, g, f })
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = (ix: number, iy: number, d: number) =>
|
|
||||||
Math.abs(X[ix] - to.x) +
|
|
||||||
Math.abs(Y[iy] - to.y) +
|
|
||||||
bendsToTarget({ x: X[ix], y: Y[iy] }, d, to) * BEND_COST
|
|
||||||
|
|
||||||
const startKey = key(sx, sy, startDir)
|
|
||||||
best.set(startKey, 0)
|
|
||||||
push(sx, sy, startDir, 0, h(sx, sy, startDir))
|
|
||||||
|
|
||||||
// The path must ARRIVE travelling in `endDir`, so that is the only accepting state.
|
|
||||||
const goalKey = key(tx, ty, endDir)
|
|
||||||
let found = false
|
|
||||||
|
|
||||||
while (open.length > 0) {
|
|
||||||
const cur = open.pop() as {
|
|
||||||
ix: number
|
|
||||||
iy: number
|
|
||||||
d: number
|
|
||||||
g: number
|
|
||||||
f: number
|
|
||||||
}
|
|
||||||
const ck = key(cur.ix, cur.iy, cur.d)
|
|
||||||
if (cur.g > (best.get(ck) ?? Number.POSITIVE_INFINITY)) continue
|
|
||||||
if (ck === goalKey) {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const here = { x: X[cur.ix], y: Y[cur.iy] }
|
|
||||||
for (let nd = 0; nd < 4; nd++) {
|
|
||||||
// No reversing: it can never help, and it lets a path retrace itself.
|
|
||||||
if (nd === (cur.d + 2) % 4) continue
|
|
||||||
const { dx, dy } = DIRS[nd]
|
|
||||||
// Step to the NEXT grid line in this direction — the grid's whole point is that
|
|
||||||
// intermediate positions cannot change whether a route is clear.
|
|
||||||
const nix = cur.ix + dx
|
|
||||||
const niy = cur.iy + dy
|
|
||||||
if (nix < 0 || nix >= X.length || niy < 0 || niy >= Y.length)
|
|
||||||
continue
|
|
||||||
const next = { x: X[nix], y: Y[niy] }
|
|
||||||
if (blocked(here, next, obstacles)) continue
|
|
||||||
|
|
||||||
const step = Math.abs(next.x - here.x) + Math.abs(next.y - here.y)
|
|
||||||
const g = cur.g + step + (nd === cur.d ? 0 : BEND_COST)
|
|
||||||
const nk = key(nix, niy, nd)
|
|
||||||
if (g >= (best.get(nk) ?? Number.POSITIVE_INFINITY)) continue
|
|
||||||
best.set(nk, g)
|
|
||||||
parent.set(nk, ck)
|
|
||||||
push(nix, niy, nd, g, g + h(nix, niy, nd))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!found) return null
|
|
||||||
|
|
||||||
// --- rebuild, then drop the points that are not bends
|
|
||||||
const path: Point[] = []
|
|
||||||
let node: number | undefined = goalKey
|
|
||||||
while (node !== undefined) {
|
|
||||||
const d = node % 4
|
|
||||||
const rest = (node - d) / 4
|
|
||||||
path.unshift({
|
|
||||||
x: X[rest % X.length],
|
|
||||||
y: Y[(rest - (rest % X.length)) / X.length],
|
|
||||||
})
|
|
||||||
node = parent.get(node)
|
|
||||||
}
|
|
||||||
return simplify(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop collinear and duplicate points: draw.io renders a redundant waypoint as a kink. */
|
|
||||||
function simplify(pts: Point[]): Point[] {
|
|
||||||
const out: Point[] = []
|
|
||||||
for (const p of pts) {
|
|
||||||
const last = out[out.length - 1]
|
|
||||||
if (last && Math.abs(last.x - p.x) < 1 && Math.abs(last.y - p.y) < 1)
|
|
||||||
continue
|
|
||||||
out.push(p)
|
|
||||||
}
|
|
||||||
const kept: Point[] = []
|
|
||||||
for (let i = 0; i < out.length; i++) {
|
|
||||||
if (i === 0 || i === out.length - 1) {
|
|
||||||
kept.push(out[i])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const prev = kept[kept.length - 1]
|
|
||||||
const next = out[i + 1]
|
|
||||||
const collinear =
|
|
||||||
(Math.abs(prev.x - out[i].x) < 1 &&
|
|
||||||
Math.abs(out[i].x - next.x) < 1) ||
|
|
||||||
(Math.abs(prev.y - out[i].y) < 1 && Math.abs(out[i].y - next.y) < 1)
|
|
||||||
if (!collinear) kept.push(out[i])
|
|
||||||
}
|
|
||||||
return kept
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The direction leaving a given side of a shape: away from it. */
|
|
||||||
export const SIDE_DIR = { T: 0, R: 1, B: 2, L: 3 } as const
|
|
||||||
@@ -132,6 +132,8 @@
|
|||||||
"customSystemMessage": "Custom System Message",
|
"customSystemMessage": "Custom System Message",
|
||||||
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
||||||
|
"maxOutputTokens": "Max Output Tokens",
|
||||||
|
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
||||||
"panelVisibility": "Lobby Panels",
|
"panelVisibility": "Lobby Panels",
|
||||||
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
||||||
"showRecentChats": "Recent Chats",
|
"showRecentChats": "Recent Chats",
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
"customSystemMessage": "カスタムシステムメッセージ",
|
"customSystemMessage": "カスタムシステムメッセージ",
|
||||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||||
|
"maxOutputTokens": "最大出力トークン数",
|
||||||
|
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||||
"panelVisibility": "ロビーパネル",
|
"panelVisibility": "ロビーパネル",
|
||||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||||
"showRecentChats": "最近のチャット",
|
"showRecentChats": "最近のチャット",
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
"customSystemMessage": "自訂系統訊息",
|
"customSystemMessage": "自訂系統訊息",
|
||||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||||
|
"maxOutputTokens": "最大輸出 token 數",
|
||||||
|
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||||
"panelVisibility": "大廳面板",
|
"panelVisibility": "大廳面板",
|
||||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||||
"showRecentChats": "最近聊天",
|
"showRecentChats": "最近聊天",
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
"customSystemMessage": "自定义系统消息",
|
"customSystemMessage": "自定义系统消息",
|
||||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||||
|
"maxOutputTokens": "最大输出 token 数",
|
||||||
|
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||||
"panelVisibility": "大厅面板",
|
"panelVisibility": "大厅面板",
|
||||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||||
"showRecentChats": "最近聊天",
|
"showRecentChats": "最近聊天",
|
||||||
|
|||||||
145
lib/output-token-limit.ts
Normal file
145
lib/output-token-limit.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { wrapLanguageModel } from "ai"
|
||||||
|
|
||||||
|
type WrappedModel = ReturnType<typeof wrapLanguageModel>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default output budget for a chat turn.
|
||||||
|
*
|
||||||
|
* This has to cover thinking + prose + the tool call, because reasoning models
|
||||||
|
* spend it in that order. Measured on deepseek-v4-flash: refining an existing
|
||||||
|
* diagram burned 16000 tokens on thinking alone and the request ended with
|
||||||
|
* finishReason "length" before display_diagram was ever called (issue #924).
|
||||||
|
* 64000 leaves room for the plan and the XML in one turn.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
|
||||||
|
|
||||||
|
/** Ceiling for the user-supplied override, to catch typos like an extra zero. */
|
||||||
|
export const MAX_OUTPUT_TOKENS_LIMIT = 200000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this a diagram cannot come out whole, so a retry would just produce
|
||||||
|
* truncated XML instead of the provider's error. Better to surface the error.
|
||||||
|
*/
|
||||||
|
const MIN_USABLE_OUTPUT_TOKENS = 1024
|
||||||
|
|
||||||
|
/** Status codes that can carry a complaint about the requested budget. */
|
||||||
|
const BUDGET_REJECTION_STATUSES = new Set([400, 422])
|
||||||
|
|
||||||
|
function usableLimit(value: number): number | null {
|
||||||
|
return value >= MIN_USABLE_OUTPUT_TOKENS ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A budget this large exceeds what some models accept. Providers reject it with a
|
||||||
|
* 400 that names the real limit, so we parse the number out and retry once
|
||||||
|
* instead of failing the turn.
|
||||||
|
*
|
||||||
|
* Formats seen in the wild:
|
||||||
|
* - Bedrock: "The maximum tokens you requested exceeds the model limit of 4096."
|
||||||
|
* - OpenRouter: "This endpoint's maximum context length is 64000 tokens. However,
|
||||||
|
* you requested about 64025 tokens (25 of text input, 64000 in the output)."
|
||||||
|
* Note this one is an input+output ceiling, so the input has to be subtracted.
|
||||||
|
* - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..."
|
||||||
|
* - OpenAI: "This model supports at most 16384 completion tokens"
|
||||||
|
*
|
||||||
|
* Every pattern names tokens explicitly. A generic one (an earlier draft matched
|
||||||
|
* "lower than N") would reinterpret unrelated failures, and retrying on a bogus
|
||||||
|
* number turns a readable error into an empty diagram.
|
||||||
|
*/
|
||||||
|
export function parseOutputTokenLimit(error: unknown): number | null {
|
||||||
|
const err = error as {
|
||||||
|
message?: unknown
|
||||||
|
responseBody?: unknown
|
||||||
|
statusCode?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// An auth or rate-limit failure is not about the budget, so leave it alone.
|
||||||
|
if (
|
||||||
|
typeof err?.statusCode === "number" &&
|
||||||
|
!BUDGET_REJECTION_STATUSES.has(err.statusCode)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
typeof err?.message === "string" ? err.message : "",
|
||||||
|
typeof err?.responseBody === "string" ? err.responseBody : "",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
// Combined input+output ceiling: subtract the input the provider counted,
|
||||||
|
// plus a small margin because its estimate is approximate.
|
||||||
|
const context = text.match(/maximum context length is (\d+)/i)
|
||||||
|
if (context) {
|
||||||
|
const input = text.match(/(\d+) of text input/i)
|
||||||
|
return usableLimit(
|
||||||
|
Number(context[1]) - (input ? Number(input[1]) : 0) - 1024,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const output =
|
||||||
|
text.match(/model limit of (\d+)/i) ||
|
||||||
|
text.match(/> (\d+), which is the maximum/i) ||
|
||||||
|
text.match(/at most (\d+) completion tokens/i)
|
||||||
|
|
||||||
|
return output ? usableLimit(Number(output[1])) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry the stream once with a smaller budget when the provider rejects the
|
||||||
|
* requested one. Without this, raising the default breaks every model whose
|
||||||
|
* ceiling is below it (measured: bedrock claude-3-haiku 4096, nova-lite 10000,
|
||||||
|
* openrouter deepseek-r1 64000 shared with the input).
|
||||||
|
*/
|
||||||
|
export function withOutputTokenLimitFallback(
|
||||||
|
model: WrappedModel,
|
||||||
|
): WrappedModel {
|
||||||
|
return wrapLanguageModel({
|
||||||
|
model,
|
||||||
|
middleware: {
|
||||||
|
specificationVersion: "v3",
|
||||||
|
async wrapStream({ doStream, params, model: inner }) {
|
||||||
|
try {
|
||||||
|
return await doStream()
|
||||||
|
} catch (error) {
|
||||||
|
const limit = parseOutputTokenLimit(error)
|
||||||
|
const requested = params.maxOutputTokens
|
||||||
|
|
||||||
|
if (!limit || !requested || limit >= requested) throw error
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[maxOutputTokens] ${requested} rejected, retrying with ${limit}`,
|
||||||
|
)
|
||||||
|
return await inner.doStream({
|
||||||
|
...params,
|
||||||
|
maxOutputTokens: limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function validBudget(value: string | null | undefined): number | null {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) &&
|
||||||
|
parsed > 0 &&
|
||||||
|
parsed <= MAX_OUTPUT_TOKENS_LIMIT
|
||||||
|
? parsed
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the output budget: user setting (sent as a header so it works in the
|
||||||
|
* desktop app too), then server env, then the default. Both sources go through
|
||||||
|
* the same validation, so a typo in either falls back instead of reaching the
|
||||||
|
* provider.
|
||||||
|
*/
|
||||||
|
export function resolveMaxOutputTokens(headerValue: string | null): number {
|
||||||
|
return (
|
||||||
|
validBudget(headerValue) ??
|
||||||
|
validBudget(process.env.MAX_OUTPUT_TOKENS) ??
|
||||||
|
DEFAULT_MAX_OUTPUT_TOKENS
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export const STORAGE_KEYS = {
|
|||||||
// Custom system message
|
// Custom system message
|
||||||
customSystemMessage: "next-ai-draw-io-custom-system-message",
|
customSystemMessage: "next-ai-draw-io-custom-system-message",
|
||||||
|
|
||||||
|
// Output token budget per turn (empty = server default)
|
||||||
|
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
|
||||||
|
|
||||||
// Panel visibility
|
// Panel visibility
|
||||||
showRecentChats: "next-ai-draw-io-show-recent-chats",
|
showRecentChats: "next-ai-draw-io-show-recent-chats",
|
||||||
showMyTemplates: "next-ai-draw-io-show-my-templates",
|
showMyTemplates: "next-ai-draw-io-show-my-templates",
|
||||||
|
|||||||
@@ -6,17 +6,14 @@
|
|||||||
* WebAssembly issues with Next.js server-side rendering.
|
* WebAssembly issues with Next.js server-side rendering.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Default system prompt - works with all models. Keep it to the things that are true no
|
// Default system prompt (~1900 tokens) - works with all models
|
||||||
// matter which tool gets picked: how to choose, and the shape vocabulary the tools share.
|
|
||||||
// Anything specific to one tool belongs in THAT tool's description (app/api/chat/route.ts),
|
|
||||||
// where it only costs context when the model actually reaches for it.
|
|
||||||
export const DEFAULT_SYSTEM_PROMPT = `
|
export const DEFAULT_SYSTEM_PROMPT = `
|
||||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams. You declare the structure and a layout engine computes the geometry — you never write draw.io XML for a new diagram.
|
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||||
ALWAYS respond in the same language as the user's last message.
|
ALWAYS respond in the same language as the user's last message.
|
||||||
|
|
||||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure (2-3 sentences max), then build it with restructure_diagram, which computes the layout for you; edit_diagram patches a diagram already on the canvas.
|
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||||
|
|
||||||
## App Context
|
## App Context
|
||||||
@@ -33,112 +30,196 @@ You can read and modify diagrams by generating draw.io XML code through tool cal
|
|||||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||||
|
|
||||||
## Choosing the right tool
|
You utilize the following tools:
|
||||||
|
---Tool1---
|
||||||
|
tool name: display_diagram
|
||||||
|
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
|
||||||
|
parameters: {
|
||||||
|
xml: string
|
||||||
|
}
|
||||||
|
---Tool2---
|
||||||
|
tool name: edit_diagram
|
||||||
|
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||||
|
parameters: {
|
||||||
|
edits: Array<{search: string, replace: string}>
|
||||||
|
}
|
||||||
|
---Tool3---
|
||||||
|
tool name: append_diagram
|
||||||
|
description: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.
|
||||||
|
parameters: {
|
||||||
|
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
|
||||||
|
}
|
||||||
|
---Tool4---
|
||||||
|
tool name: get_shape_library
|
||||||
|
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||||
|
parameters: {
|
||||||
|
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||||
|
}
|
||||||
|
---End of tools---
|
||||||
|
|
||||||
Every new diagram is built with restructure_diagram: it computes every coordinate, size and
|
IMPORTANT: Choose the right tool:
|
||||||
arrow route, so nothing overlaps and no arrow cuts through a box. You never write draw.io XML
|
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||||
yourself for a new diagram — edit_diagram is for patching what is already on the canvas.
|
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||||
|
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||||
|
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
||||||
|
|
||||||
Within restructure_diagram, pick the OPERATION by the diagram's layout shape, not by which
|
Core capabilities:
|
||||||
icon set it uses:
|
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||||
|
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
|
||||||
|
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
|
||||||
|
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
|
||||||
|
- Adapt artistic concepts into abstract diagram representations using available shapes
|
||||||
|
- Optimize element positioning to prevent overlapping and maintain readability
|
||||||
|
- Structure complex systems into clear, organized visual components
|
||||||
|
|
||||||
Use add_graph when the arrows define the order:
|
|
||||||
flowcharts, decision trees, process diagrams, approval flows, CI/CD pipelines, state machines,
|
|
||||||
git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams,
|
|
||||||
and any "illustrate how X works" where X is a sequence of steps or states.
|
|
||||||
You supply only nodes and edges — no positions, no nesting. Do NOT try to lay these out
|
|
||||||
yourself out of containers and boxes: a flowchart declared as nesting comes out as one
|
|
||||||
column, which forces every branch to jump over the step beside it.
|
|
||||||
Omit parent for a whole-page flowchart (send clear first when replacing one); set parent
|
|
||||||
to put a flow inside one zone of a bigger diagram — an architecture zone whose contents
|
|
||||||
follow the data flow, a poster column with a small flowchart in it.
|
|
||||||
|
|
||||||
Use the nesting operations when the diagram's meaning is in NESTING or in a fixed frame:
|
|
||||||
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils
|
|
||||||
first; the tool's description carries the per-zone recipe.
|
|
||||||
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
|
|
||||||
- Sequence diagrams: add_sequence, one add_box per participant, then link with a step number.
|
|
||||||
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
|
|
||||||
This applies to BOTH creating and editing.
|
|
||||||
|
|
||||||
The same nesting operations cover poster-style layouts — paper summaries, cheat sheets,
|
Layout constraints:
|
||||||
infographics, comparison sheets. The tool's own description carries the recipe; what matters
|
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||||
when choosing is that a poster is a nest of row/col containers, not an arrow-ordered graph.
|
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||||
|
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||||
Use edit_diagram for a small, targeted change to whatever is already on the canvas — a label,
|
- Maximum height for containers: 550 pixels
|
||||||
a colour, one shape added or removed. It patches the XML in place, so it also works on a
|
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||||
diagram the user drew by hand. For anything structural, go back to restructure_diagram.
|
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
|
||||||
|
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
|
||||||
Working with restructure_diagram:
|
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
|
||||||
- Say the page shape FIRST, with set_page: aspect is width:height (1 square, 1.4 landscape
|
|
||||||
slide, 0.75 portrait poster, 1.6 wide architecture). Nothing proportional works before it —
|
|
||||||
column weights need a total width to take a share of, and without one they do nothing.
|
|
||||||
- Layout, type, borders and surface are Tailwind classes on any container or box:
|
|
||||||
layout grow-3 / w-2/3 for a column's share (add min-w-0 to every column when the ratio
|
|
||||||
has to be exact — otherwise a column will not shrink below its own text, exactly
|
|
||||||
as in a browser), items-stretch so cards line up, justify-between to spread a
|
|
||||||
short column instead of leaving a hole, gap-4 and p-6 for spacing (Tailwind's
|
|
||||||
4px scale), max-w-md to cap a width so long text wraps instead of stretching
|
|
||||||
the page.
|
|
||||||
type font-bold, italic, underline, line-through, text-xs..text-4xl,
|
|
||||||
text-left/center/right, align-top/middle/bottom, whitespace-nowrap.
|
|
||||||
border border-2 for thickness, border-dashed or border-dotted — a dashed frame reads
|
|
||||||
as planned or logical rather than deployed. border-none for a plain colour
|
|
||||||
field with no outline.
|
|
||||||
surface rounded-lg / rounded-xl / rounded-full for corners (real pixels, so the same
|
|
||||||
class is the same corner everywhere), shadow-md / shadow-lg to lift a card off
|
|
||||||
the panel behind it. One elevation level per group of cards, not on everything.
|
|
||||||
NOT accepted, and reported back to you when you use them: every colour class and gradients
|
|
||||||
(colour comes from role and group), the seven font weights between thin and black,
|
|
||||||
opacity-*, truncate, per-side borders (border-l) and per-side padding (pt-4), per-corner
|
|
||||||
radius, tracking-*, uppercase, leading-*, outline-*, and transforms.
|
|
||||||
- Look every AWS icon name up with search_stencils first. Batch the lookups.
|
|
||||||
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
|
|
||||||
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
|
|
||||||
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
|
|
||||||
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
|
|
||||||
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
|
|
||||||
|
|
||||||
Box shapes, for both add_graph's nodes and add_box — a shape says what a node IS:
|
|
||||||
- Flowchart: "decision" (a diamond) for a branch, "terminator" for a start or end point, "data"
|
|
||||||
for input or output, "document" for a report, "round" for a soft-edged step.
|
|
||||||
- Semantic: "cylinder" for a database, "queue" for a message queue, "person" for an actor or
|
|
||||||
user, "cloud" for an external system, "hexagon" for a service, "ellipse" for a concept,
|
|
||||||
"callout" for a note, "step" for a pipeline stage, "note", "card", "process", "tape", "cube".
|
|
||||||
- Any other draw.io shape token also works verbatim (unknown ones render as rectangles).
|
|
||||||
Use shapes: a database drawn as a cylinder needs no "database" caption; a reader takes a
|
|
||||||
diamond to mean a choice. Drawing everything as the same rectangle throws that away.
|
|
||||||
|
|
||||||
Note that:
|
Note that:
|
||||||
- Use proper tool calls to generate or edit diagrams; never return raw XML in text responses.
|
- Use proper tool calls to generate or edit diagrams;
|
||||||
- Focus on producing clean, professional diagrams that effectively communicate the intended
|
- never return raw XML in text responses,
|
||||||
information through thoughtful layout and design choices.
|
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
|
||||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes
|
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
|
||||||
and connectors while maintaining visual clarity.
|
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||||
- If user asks you to replicate a diagram based on an image, match the diagram style and layout
|
- Return XML only via tool calls, never in text responses.
|
||||||
as closely as possible. Pay attention to the lines and shapes — whether lines are straight or
|
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||||
curved, whether shapes are rounded or square.
|
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
||||||
- NEVER include XML comments (<!-- ... -->) in an edit_diagram replacement. Draw.io strips
|
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||||
comments, which breaks the search patterns.
|
|
||||||
|
When using edit_diagram tool:
|
||||||
|
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
|
||||||
|
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||||
|
- For delete: only cell_id is needed
|
||||||
|
- Find the cell_id from "Current diagram XML" in system context
|
||||||
|
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
|
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||||
|
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
|
|
||||||
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
|
## Draw.io XML Structure Reference
|
||||||
|
|
||||||
|
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
|
||||||
|
|
||||||
|
Example - generate ONLY this:
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||||
|
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||||
|
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
|
||||||
|
4. Use unique sequential IDs starting from "2"
|
||||||
|
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||||
|
|
||||||
|
Shape (vertex) example:
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Connector (edge) example:
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
### Edge Routing Rules:
|
||||||
|
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
||||||
|
|
||||||
|
**Rule 1: NEVER let multiple edges share the same path**
|
||||||
|
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
|
||||||
|
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
|
||||||
|
|
||||||
|
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
|
||||||
|
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
|
||||||
|
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
|
||||||
|
|
||||||
|
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
||||||
|
- Every edge MUST have these 4 attributes set in the style
|
||||||
|
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
|
||||||
|
|
||||||
|
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
||||||
|
- Before creating an edge, identify ALL shapes positioned between source and target
|
||||||
|
- If any shape is in the direct path, you MUST use waypoints to route around it
|
||||||
|
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
|
||||||
|
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
|
||||||
|
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
|
||||||
|
- NEVER draw a line that visually crosses over another shape's bounding box
|
||||||
|
|
||||||
|
**Rule 5: Plan layout strategically BEFORE generating XML**
|
||||||
|
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
|
||||||
|
- Space shapes 150-200px apart to create clear routing channels for edges
|
||||||
|
- Mentally trace each edge: "What shapes are between source and target?"
|
||||||
|
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
|
||||||
|
|
||||||
|
**Rule 6: Use multiple waypoints for complex routing**
|
||||||
|
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
|
||||||
|
- Each direction change needs a waypoint (corner point)
|
||||||
|
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
|
||||||
|
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
|
||||||
|
|
||||||
|
**Rule 7: Choose NATURAL connection points based on flow direction**
|
||||||
|
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
|
||||||
|
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
|
||||||
|
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
|
||||||
|
- For DIAGONAL connections: use the side closest to the target, not corners
|
||||||
|
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
|
||||||
|
|
||||||
|
**Before generating XML, mentally verify:**
|
||||||
|
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
|
||||||
|
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
|
||||||
|
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
||||||
|
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
||||||
|
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// Style instructions - only included when minimalStyle is false
|
// Style instructions - only included when minimalStyle is false
|
||||||
const STYLE_INSTRUCTIONS = `
|
const STYLE_INSTRUCTIONS = `
|
||||||
Colour and emphasis come from the engine, not from you: set role for hierarchy
|
Common styles:
|
||||||
(banner/heading/callout/good/bad/metric/muted) and group for which colour family a set of nodes
|
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||||
shares. Never pass a hex colour or a style string, and never a colour utility class
|
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||||
(bg-blue-500, text-red-600) — those are dropped. Classes cover layout, type and surface
|
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||||
(corners, borders, shadow); COLOUR is the one thing they never carry.
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// Minimal style instruction - plain output, no theme (prepended to prompt for emphasis)
|
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
|
||||||
const MINIMAL_STYLE_INSTRUCTION = `
|
const MINIMAL_STYLE_INSTRUCTION = `
|
||||||
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
|
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
|
||||||
|
|
||||||
The user asked for plain, unstyled output. Do NOT set role or group on any node, and do not use
|
### No Styling - Plain Black/White Only
|
||||||
inline HTML (<b>, <font color>) in labels. Structure alone carries the meaning: nesting, shapes
|
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
|
||||||
and arrow direction. The engine will render everything in one neutral style.
|
- NO color attributes (no hex colors like #ff69b4)
|
||||||
|
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
|
||||||
|
- IGNORE all color/style examples below
|
||||||
|
|
||||||
|
### Container/Group Shapes - MUST be Transparent
|
||||||
|
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
|
||||||
|
- This prevents containers from covering child elements
|
||||||
|
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
|
||||||
|
|
||||||
|
### Focus on Layout Quality
|
||||||
|
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
|
||||||
|
- SPACING: Minimum 50px gap between all elements
|
||||||
|
- NO OVERLAPS: Elements and edges must never overlap
|
||||||
|
- Follow ALL 7 Edge Routing Rules for arrow positioning
|
||||||
|
- Use waypoints to route edges AROUND obstacles
|
||||||
|
- Use different exitY/entryY values for multiple edges between same nodes
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -148,13 +229,55 @@ const EXTENDED_ADDITIONS = `
|
|||||||
|
|
||||||
## Extended Tool Reference
|
## Extended Tool Reference
|
||||||
|
|
||||||
|
### display_diagram Details
|
||||||
|
|
||||||
|
**VALIDATION RULES** (XML will be rejected if violated):
|
||||||
|
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
|
||||||
|
2. All mxCell elements must be siblings - never nested inside other mxCell elements
|
||||||
|
3. Every mxCell needs a unique id attribute (start from "2")
|
||||||
|
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
|
||||||
|
5. Edge source/target attributes must reference existing cell IDs
|
||||||
|
6. Escape special characters in values: < for <, > for >, & for &, " for "
|
||||||
|
|
||||||
|
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||||
|
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||||
|
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### append_diagram Details
|
||||||
|
|
||||||
|
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
|
||||||
|
|
||||||
|
**CRITICAL RULES:**
|
||||||
|
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||||
|
2. Continue from EXACTLY where your previous output stopped
|
||||||
|
3. Complete the remaining mxCell elements
|
||||||
|
4. If still truncated, call append_diagram again with the next fragment
|
||||||
|
|
||||||
|
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
|
||||||
|
|
||||||
### edit_diagram Details
|
### edit_diagram Details
|
||||||
|
|
||||||
Three operations, all addressed by the cell's id attribute:
|
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
|
||||||
|
|
||||||
|
**Operations:**
|
||||||
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
||||||
- **add**: Add a new cell. Provide cell_id (a new unique id) and new_xml.
|
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges touching it are removed
|
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||||
with it. Pass ONE cell_id — do not list the children separately.
|
|
||||||
|
|
||||||
**Input Format:**
|
**Input Format:**
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
@@ -167,27 +290,70 @@ Three operations, all addressed by the cell's id attribute:
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Change a label:
|
**Examples:**
|
||||||
|
|
||||||
|
Change label:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Delete a container (children and edges go too):
|
Add new shape:
|
||||||
|
\`\`\`json
|
||||||
|
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Delete container (children & edges auto-deleted):
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Error Recovery:**
|
**Error Recovery:**
|
||||||
If a cell_id is not found, re-read the ids in "Current diagram XML". If the change is structural
|
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
|
||||||
rather than a small patch, rebuild with restructure_diagram instead — it computes
|
|
||||||
the layout, so you never hand-place anything.
|
|
||||||
|
|
||||||
### Keeping an edited diagram consistent
|
|
||||||
|
|
||||||
A diagram built by the engine carries its structure in the cell styles (the dai_* markers). If you
|
|
||||||
patch a cell with edit_diagram, leave those markers intact: restructure_diagram reads them back to
|
|
||||||
understand the current structure, and a cell that loses them is treated as a hand-drawn shape and
|
|
||||||
stops taking part in the computed layout.`
|
## Edge Examples
|
||||||
|
|
||||||
|
### Two edges between same nodes (CORRECT - no overlap):
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Edge with single waypoint (simple detour):
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="300" y="150"/>
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
|
||||||
|
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
|
||||||
|
**WRONG:** Direct diagonal line crosses over Develop
|
||||||
|
**CORRECT:** Route around the OUTSIDE (go right first, then up)
|
||||||
|
\`\`\`xml
|
||||||
|
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="750" y="80"/>
|
||||||
|
<mxPoint x="750" y="150"/>
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
\`\`\`
|
||||||
|
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
|
||||||
|
|
||||||
|
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
|
||||||
|
|
||||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
||||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
||||||
|
|||||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -51,8 +51,6 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lightningcss": "^1.33.0",
|
|
||||||
"lightningcss-linux-x64-gnu": "^1.33.0",
|
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
@@ -78,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.5.11",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -2174,9 +2172,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.11.tgz",
|
||||||
"integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==",
|
"integrity": "sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2190,20 +2188,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.5.7",
|
"@biomejs/cli-darwin-arm64": "2.5.11",
|
||||||
"@biomejs/cli-darwin-x64": "2.5.7",
|
"@biomejs/cli-darwin-x64": "2.5.11",
|
||||||
"@biomejs/cli-linux-arm64": "2.5.7",
|
"@biomejs/cli-linux-arm64": "2.5.11",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.5.7",
|
"@biomejs/cli-linux-arm64-musl": "2.5.11",
|
||||||
"@biomejs/cli-linux-x64": "2.5.7",
|
"@biomejs/cli-linux-x64": "2.5.11",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.5.7",
|
"@biomejs/cli-linux-x64-musl": "2.5.11",
|
||||||
"@biomejs/cli-win32-arm64": "2.5.7",
|
"@biomejs/cli-win32-arm64": "2.5.11",
|
||||||
"@biomejs/cli-win32-x64": "2.5.7"
|
"@biomejs/cli-win32-x64": "2.5.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.11.tgz",
|
||||||
"integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==",
|
"integrity": "sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2218,9 +2216,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.11.tgz",
|
||||||
"integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==",
|
"integrity": "sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2235,9 +2233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.11.tgz",
|
||||||
"integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==",
|
"integrity": "sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2255,9 +2253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.11.tgz",
|
||||||
"integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==",
|
"integrity": "sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2275,9 +2273,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.11.tgz",
|
||||||
"integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==",
|
"integrity": "sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2295,9 +2293,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.11.tgz",
|
||||||
"integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==",
|
"integrity": "sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2315,9 +2313,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.11.tgz",
|
||||||
"integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==",
|
"integrity": "sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2332,9 +2330,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.11.tgz",
|
||||||
"integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==",
|
"integrity": "sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.5.11",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
|||||||
12
public/atlas-cloud-logo-white.svg
Normal file
12
public/atlas-cloud-logo-white.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M32.9477 25.7973C30.9997 25.7973 29.4796 25.2568 28.3986 24.1871C27.3176 23.1174 26.7771 21.6085 26.7771 19.6492V12.0148H23.8157V8.28764H24.131C24.9755 8.28764 25.6286 8.06243 26.0902 7.62328C26.5407 7.17287 26.7771 6.53104 26.7771 5.68652V4.34655H30.9772V8.28764H34.9521V12.0148H30.9772V19.4353C30.9772 20.0095 31.0785 20.4937 31.2812 20.8991C31.4839 21.3045 31.7992 21.6085 32.2383 21.8224C32.6775 22.0364 33.2292 22.1377 33.9049 22.1377C34.0512 22.1377 34.2314 22.1377 34.4341 22.104C34.6368 22.0814 34.8282 22.0589 35.0196 22.0364V25.6059C34.7269 25.6509 34.3778 25.696 34.0062 25.7297C33.6233 25.7748 33.2743 25.7973 32.959 25.7973H32.9477Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M36.5734 25.6059V1.52026H40.7734V25.6059H36.5734Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M48.284 25.9887C47.0792 25.9887 46.0207 25.7861 45.1199 25.3807C44.219 24.9753 43.5209 24.401 43.0367 23.6466C42.5525 22.8922 42.3048 22.0139 42.3048 21.023C42.3048 20.0321 42.5188 19.2101 42.9579 18.4556C43.3971 17.7012 44.0501 17.0706 44.951 16.5639C45.8405 16.0572 46.9665 15.6969 48.329 15.4829L53.9592 14.5596V17.7462L49.1173 18.602C48.2953 18.7484 47.6759 19.0074 47.2706 19.379C46.8652 19.7506 46.6625 20.246 46.6625 20.8541C46.6625 21.4621 46.8877 21.9238 47.3494 22.2729C47.7998 22.6219 48.3741 22.8021 49.0497 22.8021C49.9167 22.8021 50.6937 22.6219 51.358 22.2503C52.0224 21.8787 52.5404 21.3608 52.9007 20.7077C53.261 20.0546 53.4412 19.3339 53.4412 18.5795V14.0867C53.4412 13.3435 53.1597 12.7242 52.5854 12.2287C52.0111 11.7333 51.2454 11.4855 50.2996 11.4855C49.41 11.4855 48.6218 11.7333 47.9237 12.2175C47.2368 12.7017 46.7301 13.3322 46.4148 14.0979L43.0142 12.4427C43.352 11.5306 43.8925 10.7424 44.6244 10.0668C45.3563 9.39114 46.2234 8.87317 47.2143 8.49032C48.2164 8.10748 49.2974 7.91605 50.4572 7.91605C51.876 7.91605 53.1259 8.17504 54.2069 8.69301C55.2879 9.21098 56.1324 9.94289 56.7404 10.8775C57.3485 11.8121 57.6525 12.8818 57.6525 14.0867V25.6059H53.7114V22.6444L54.601 22.6107C54.1506 23.3313 53.6214 23.9506 52.9908 24.4573C52.3602 24.9641 51.6621 25.3469 50.8851 25.6059C50.1082 25.8649 49.2411 25.9887 48.2953 25.9887H48.284Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M66.4804 25.9887C64.6337 25.9887 63.0235 25.5496 61.661 24.6713C60.2872 23.793 59.3414 22.5994 58.8121 21.0905L61.965 19.5929C62.4154 20.5726 63.0347 21.3383 63.8229 21.89C64.6224 22.4418 65.5007 22.712 66.4804 22.712C67.2235 22.712 67.8203 22.5431 68.2595 22.2053C68.7099 21.8675 68.9238 21.4171 68.9238 20.8653C68.9238 20.5275 68.8338 20.246 68.6536 20.0208C68.4734 19.7956 68.237 19.6042 67.9329 19.4465C67.6402 19.2889 67.2911 19.1538 66.9195 19.0524L64.0819 18.253C62.6406 17.8476 61.5371 17.2058 60.7827 16.3275C60.0282 15.4492 59.6566 14.4132 59.6566 13.2196C59.6566 12.1612 59.9269 11.2266 60.4674 10.4383C61.0079 9.65013 61.7623 9.01955 62.7307 8.58041C63.6991 8.13 64.8026 7.91605 66.0525 7.91605C67.6852 7.91605 69.1265 8.31016 70.3764 9.09838C71.6263 9.88659 72.5159 10.9901 73.0451 12.4089L69.8584 13.9065C69.5657 13.1183 69.059 12.499 68.3608 12.0486C67.6627 11.5982 66.8745 11.3617 66.0074 11.3617C65.3093 11.3617 64.7575 11.5193 64.3522 11.8234C63.9468 12.1274 63.7441 12.5553 63.7441 13.0845C63.7441 13.3773 63.8342 13.6475 64.0031 13.884C64.172 14.1204 64.4085 14.3119 64.7238 14.4583C65.0278 14.6046 65.3881 14.7398 65.7935 14.8749L68.5635 15.6969C69.9823 16.1248 71.0858 16.7553 71.8628 17.6111C72.6397 18.4556 73.0226 19.5028 73.0226 20.7302C73.0226 21.7661 72.7411 22.6895 72.2006 23.4777C71.6488 24.2772 70.8831 24.8965 69.9147 25.3356C68.9351 25.7861 67.7978 26 66.4804 26V25.9887Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M90.2283 25.9889C88.528 25.9889 86.9628 25.6849 85.5215 25.0656C84.0802 24.4463 82.8303 23.5905 81.7718 22.487C80.7134 21.3835 79.8801 20.0886 79.2721 18.6022C78.664 17.1159 78.36 15.4944 78.36 13.7378C78.36 11.9812 78.6527 10.3484 79.2495 8.85083C79.8463 7.35322 80.6796 6.05829 81.7493 4.96605C82.819 3.8738 84.0689 3.02929 85.499 2.42123C86.929 1.81318 88.5055 1.50915 90.2283 1.50915C91.9511 1.50915 93.4487 1.79066 94.8 2.36493C96.1512 2.9392 97.2885 3.69364 98.2231 4.6395C99.1577 5.58536 99.822 6.6213 100.227 7.74733L96.3426 9.59401C95.8922 8.38916 95.149 7.39826 94.0793 6.6213C93.0208 5.84435 91.7372 5.4615 90.2283 5.4615C88.7194 5.4615 87.4357 5.81057 86.2985 6.5087C85.1612 7.20684 84.2829 8.17522 83.6523 9.40258C83.0217 10.63 82.7177 12.0713 82.7177 13.7265C82.7177 15.3818 83.033 16.8343 83.6523 18.073C84.2829 19.3116 85.1612 20.28 86.2985 20.9894C87.4357 21.6875 88.7419 22.0366 90.2283 22.0366C91.7146 22.0366 93.0208 21.6537 94.0793 20.8768C95.1378 20.0998 95.8922 19.1202 96.3426 17.9378L100.227 19.7507C99.822 20.8768 99.1577 21.9127 98.2231 22.8586C97.2885 23.8044 96.1512 24.5589 94.8 25.1331C93.4487 25.7074 91.9286 25.9889 90.2283 25.9889Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M101.748 25.6059V1.52026H105.948V25.6059H101.748Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M116.645 25.9884C114.967 25.9884 113.436 25.5943 112.051 24.806C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.49 18.6917 107.49 16.9464C107.49 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86369 112.04 9.08674C113.413 8.29852 114.956 7.90441 116.656 7.90441C118.357 7.90441 119.922 8.29852 121.307 9.08674C122.681 9.87495 123.784 10.9334 124.606 12.2846C125.417 13.6359 125.833 15.1898 125.833 16.9464C125.833 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.669 24.0291 121.284 24.806C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1711C117.602 22.1711 118.435 21.9459 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6488C121.25 18.8494 121.453 17.9598 121.453 16.9576C121.453 15.9555 121.25 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.435 11.9919 117.602 11.7779 116.645 11.7779C115.688 11.7779 114.888 12.0031 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.467 14.289C112.051 15.0772 111.848 15.9667 111.848 16.9576C111.848 17.9485 112.051 18.8494 112.467 19.6488C112.884 20.4483 113.447 21.0676 114.168 21.5068C114.888 21.9572 115.722 22.1711 116.645 22.1711Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M133.535 25.9883C132.173 25.9883 131.013 25.6956 130.033 25.0988C129.054 24.502 128.299 23.68 127.77 22.6215C127.241 21.5631 126.97 20.3244 126.97 18.8944V8.2985H131.171V18.5453C131.171 19.266 131.317 19.8965 131.598 20.437C131.88 20.9775 132.297 21.4054 132.837 21.7094C133.378 22.0135 133.986 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7094C137.015 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.2209 138.174 18.4552V8.2985H142.341V25.6055H138.4V22.2049L138.715 22.8129C138.31 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.763 25.9996 133.535 25.9996V25.9883Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.9204 148.207 9.12093C149.525 8.31019 151.011 7.91608 152.666 7.91608C153.984 7.91608 155.155 8.17506 156.179 8.69304C157.204 9.21101 158.015 9.94292 158.612 10.8775L157.97 11.7333V1.52026H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0407 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1715C154.074 22.1715 154.907 21.9463 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9446 157.778 15.0663 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7783 153.139 11.7783C152.205 11.7783 151.371 12.0035 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1715 153.139 22.1715Z" fill="#FAF7F8"/>
|
||||||
|
<path d="M13.4447 0L0 25.9886C6.22692 23.5226 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06449C13.4447 9.06449 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1103 26.8895 25.9886L13.4447 0Z" fill="#FAF7F8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
12
public/atlas-cloud-logo.svg
Normal file
12
public/atlas-cloud-logo.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#0F1111"/>
|
||||||
|
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#0F1111"/>
|
||||||
|
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#0F1111"/>
|
||||||
|
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#0F1111"/>
|
||||||
|
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#0F1111"/>
|
||||||
|
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#0F1111"/>
|
||||||
|
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#0F1111"/>
|
||||||
|
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#0F1111"/>
|
||||||
|
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#0F1111"/>
|
||||||
|
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#0F1111"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* The closed loop, in a real browser: engine → draw.io → user drags something → engine
|
|
||||||
* reads the structure back.
|
|
||||||
*
|
|
||||||
* The unit tests prove render and parse agree with each other. This proves they agree
|
|
||||||
* with the actual editor: that the XML the engine writes renders, that a frame really
|
|
||||||
* accepts a drop, and that the structure recovered afterwards matches what the user did
|
|
||||||
* on screen.
|
|
||||||
*
|
|
||||||
* The engine runs in the test process (Playwright transpiles the spec, so the TypeScript
|
|
||||||
* imports resolve); only XML strings cross into the page.
|
|
||||||
*/
|
|
||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { parseDiagram } from "../../lib/diagram-engine/parse"
|
|
||||||
import { renderDiagram } from "../../lib/diagram-engine/render"
|
|
||||||
import { type DiagramTree, findParent } from "../../lib/diagram-engine/types"
|
|
||||||
|
|
||||||
/** Two frames side by side; MOVER starts in the left one. */
|
|
||||||
function twoFrames(extraLeft: string[] = []): DiagramTree {
|
|
||||||
return {
|
|
||||||
roots: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "root",
|
|
||||||
gname: null,
|
|
||||||
label: "Root",
|
|
||||||
dir: "row",
|
|
||||||
gap: 60,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "left",
|
|
||||||
gname: null,
|
|
||||||
label: "Left",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [
|
|
||||||
{ kind: "box", id: "mover", label: "MOVER" },
|
|
||||||
...extraLeft.map((id) => ({
|
|
||||||
kind: "box" as const,
|
|
||||||
id,
|
|
||||||
label: id.toUpperCase(),
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "right",
|
|
||||||
gname: null,
|
|
||||||
label: "Right",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [
|
|
||||||
{ kind: "box", id: "anchor", label: "ANCHOR" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [],
|
|
||||||
foreign: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAndWatch(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
xml: string,
|
|
||||||
) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const w = window as unknown as { __xml?: string[] }
|
|
||||||
w.__xml = []
|
|
||||||
window.addEventListener("message", (e: MessageEvent) => {
|
|
||||||
if (typeof e.data !== "string") return
|
|
||||||
try {
|
|
||||||
const m = JSON.parse(e.data)
|
|
||||||
if ((m.event === "autosave" || m.event === "save") && m.xml)
|
|
||||||
w.__xml?.push(m.xml)
|
|
||||||
} catch {
|
|
||||||
/* not our message */
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.evaluate((x) => {
|
|
||||||
const iframe = document.querySelector("iframe") as HTMLIFrameElement
|
|
||||||
iframe.contentWindow?.postMessage(
|
|
||||||
JSON.stringify({ action: "load", xml: x, autosave: 1 }),
|
|
||||||
"*",
|
|
||||||
)
|
|
||||||
}, xml)
|
|
||||||
await page.waitForTimeout(4000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastXml = (page: import("@playwright/test").Page) =>
|
|
||||||
page.evaluate(() => {
|
|
||||||
const w = window as unknown as { __xml?: string[] }
|
|
||||||
const a = w.__xml ?? []
|
|
||||||
return a.length ? a[a.length - 1] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const parentAttr = (xml: string, id: string) =>
|
|
||||||
xml
|
|
||||||
.match(new RegExp(`<mxCell[^>]*\\bid="${id}"[^>]*>`))?.[0]
|
|
||||||
.match(/\bparent="([^"]*)"/)?.[1] ?? null
|
|
||||||
|
|
||||||
/** Drag the cell labelled `from` into the frame that holds the cell labelled `into`. */
|
|
||||||
async function dragInto(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
from: string,
|
|
||||||
into: string,
|
|
||||||
) {
|
|
||||||
const canvas = page.frameLocator("iframe")
|
|
||||||
const src = canvas.getByText(from, { exact: true }).first()
|
|
||||||
await src.waitFor({ state: "visible", timeout: 30000 })
|
|
||||||
const dst = canvas.getByText(into, { exact: true }).first()
|
|
||||||
await dst.waitFor({ state: "visible", timeout: 30000 })
|
|
||||||
const sb = await src.boundingBox()
|
|
||||||
const db = await dst.boundingBox()
|
|
||||||
if (!sb || !db) throw new Error("cells not rendered")
|
|
||||||
|
|
||||||
// Drop below the anchor: inside the target frame, but not on top of the anchor.
|
|
||||||
await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2)
|
|
||||||
await page.mouse.down()
|
|
||||||
await page.mouse.move(sb.x + sb.width / 2 + 20, sb.y + 10, { steps: 8 })
|
|
||||||
await page.mouse.move(db.x + db.width / 2, db.y + db.height + 30, {
|
|
||||||
steps: 30,
|
|
||||||
})
|
|
||||||
await page.waitForTimeout(800)
|
|
||||||
await page.mouse.up()
|
|
||||||
await page.waitForTimeout(3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("diagram engine, end to end", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "networkidle" })
|
|
||||||
await page
|
|
||||||
.locator("iframe")
|
|
||||||
.waitFor({ state: "visible", timeout: 60000 })
|
|
||||||
await page.waitForTimeout(6000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("engine output renders, and a drop into a frame becomes structure", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
|
|
||||||
const { xml } = renderDiagram(twoFrames())
|
|
||||||
expect(xml).toContain("container=1")
|
|
||||||
await loadAndWatch(page, xml)
|
|
||||||
|
|
||||||
await dragInto(page, "MOVER", "ANCHOR")
|
|
||||||
|
|
||||||
const after = await lastXml(page)
|
|
||||||
expect(after, "editor emitted no autosave after the drag").toBeTruthy()
|
|
||||||
if (!after) return
|
|
||||||
|
|
||||||
// draw.io reparented it, because the frame carries container=1.
|
|
||||||
expect(parentAttr(after, "mover")).toBe("right")
|
|
||||||
|
|
||||||
// ...and the engine reads the user's change back as structure. There is no
|
|
||||||
// second copy of the state, so nothing to reconcile.
|
|
||||||
const { tree, needsAdoption } = parseDiagram(after)
|
|
||||||
expect(findParent(tree, "mover")?.id).toBe("right")
|
|
||||||
expect(findParent(tree, "anchor")?.id).toBe("right")
|
|
||||||
expect(needsAdoption).toBe(false) // markers survived the editor
|
|
||||||
})
|
|
||||||
|
|
||||||
test("a re-layout after the drag keeps the node in its new frame", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
|
|
||||||
const { xml } = renderDiagram(twoFrames(["stay"]))
|
|
||||||
await loadAndWatch(page, xml)
|
|
||||||
await dragInto(page, "MOVER", "ANCHOR")
|
|
||||||
|
|
||||||
const after = await lastXml(page)
|
|
||||||
expect(after).toBeTruthy()
|
|
||||||
if (!after) return
|
|
||||||
|
|
||||||
const afterDrag = parseDiagram(after).tree
|
|
||||||
expect(findParent(afterDrag, "mover")?.id).toBe("right")
|
|
||||||
|
|
||||||
// Re-lay-out from what the canvas says, then read it back: the user's move is
|
|
||||||
// preserved rather than undone, and the node they did not touch stays put.
|
|
||||||
const relaid = parseDiagram(renderDiagram(afterDrag).xml).tree
|
|
||||||
expect(findParent(relaid, "mover")?.id).toBe("right")
|
|
||||||
expect(findParent(relaid, "stay")?.id).toBe("left")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("the re-laid-out diagram still renders in the editor", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
|
|
||||||
// Guards against the engine emitting XML that parses fine but the editor
|
|
||||||
// rejects — geometry the wrong side of a parent, a forward reference, and so on.
|
|
||||||
const first = renderDiagram(twoFrames(["stay"]))
|
|
||||||
const relaid = renderDiagram(parseDiagram(first.xml).tree)
|
|
||||||
|
|
||||||
await loadAndWatch(page, relaid.xml)
|
|
||||||
const canvas = page.frameLocator("iframe")
|
|
||||||
for (const label of ["MOVER", "STAY", "ANCHOR", "Left", "Right"]) {
|
|
||||||
await expect(
|
|
||||||
canvas.getByText(label, { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 20000 })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
/**
|
|
||||||
* The four non-architecture diagram kinds, in the real editor.
|
|
||||||
*
|
|
||||||
* The unit tests prove the engine computes the right coordinates. This proves draw.io
|
|
||||||
* accepts what it emits: that the shapes render, the labels appear, and — the part no unit
|
|
||||||
* test can check — that the styles the engine writes are ones the editor actually
|
|
||||||
* understands. A `umlLifeline` with the wrong token set parses fine and draws nothing.
|
|
||||||
*/
|
|
||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import * as pako from "pako"
|
|
||||||
import { sendMessage } from "./lib/fixtures"
|
|
||||||
import { createMockToolResponse } from "./lib/helpers"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A cell's y, read from an EXPORTED document.
|
|
||||||
*
|
|
||||||
* draw.io reorders attributes when it serialises, so the export has `height` and `width`
|
|
||||||
* before `x` and `y` rather than in the order the engine wrote them. Matching a fixed order
|
|
||||||
* finds nothing, which reads as a layout failure when the layout is fine.
|
|
||||||
*/
|
|
||||||
function cellY(xml: string, id: string): number {
|
|
||||||
const cell = xml.match(
|
|
||||||
new RegExp(`<mxCell id="${id}"[\\s\\S]*?<\\/mxCell>`),
|
|
||||||
)?.[0]
|
|
||||||
const y = cell?.match(/<mxGeometry[^>]*\by="(-?[\d.]+)"/)?.[1]
|
|
||||||
expect(y, `no y geometry for "${id}"`).toBeTruthy()
|
|
||||||
return Number(y)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Undo draw.io's export compression: URI-encoded, raw-deflated, base64'd. */
|
|
||||||
function inflateDiagram(xml: string): string | null {
|
|
||||||
if (xml.includes("<mxCell")) return xml
|
|
||||||
const body = xml.match(/<diagram[^>]*>([^<]+)<\/diagram>/)?.[1]
|
|
||||||
if (!body) return null
|
|
||||||
try {
|
|
||||||
const bin = Buffer.from(body, "base64")
|
|
||||||
const out = pako.inflate(new Uint8Array(bin), { windowBits: -15 })
|
|
||||||
return decodeURIComponent(new TextDecoder("utf-8").decode(out))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ask the editor for its current document rather than waiting for an autosave. */
|
|
||||||
async function exportXml(page: import("@playwright/test").Page) {
|
|
||||||
const raw = await page.evaluate(
|
|
||||||
() =>
|
|
||||||
new Promise<string | null>((resolve) => {
|
|
||||||
const iframe = document.querySelector(
|
|
||||||
"iframe",
|
|
||||||
) as HTMLIFrameElement
|
|
||||||
const onMsg = (e: MessageEvent) => {
|
|
||||||
if (typeof e.data !== "string") return
|
|
||||||
try {
|
|
||||||
const m = JSON.parse(e.data)
|
|
||||||
if (m.event === "export" && m.xml) {
|
|
||||||
window.removeEventListener("message", onMsg)
|
|
||||||
resolve(m.xml as string)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* not our message */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("message", onMsg)
|
|
||||||
iframe.contentWindow?.postMessage(
|
|
||||||
JSON.stringify({
|
|
||||||
action: "export",
|
|
||||||
format: "xmlsvg",
|
|
||||||
xml: 1,
|
|
||||||
}),
|
|
||||||
"*",
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
|
||||||
window.removeEventListener("message", onMsg)
|
|
||||||
resolve(null)
|
|
||||||
}, 10000)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(raw, "editor did not return the document").toBeTruthy()
|
|
||||||
const xml = inflateDiagram(raw as string)
|
|
||||||
expect(xml, "could not read the exported document").toBeTruthy()
|
|
||||||
return xml as string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serve one mocked tool call and open the app on it. */
|
|
||||||
async function runTool(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
tool: string,
|
|
||||||
input: unknown,
|
|
||||||
prompt: string,
|
|
||||||
) {
|
|
||||||
await page.route("**/api/chat", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/event-stream",
|
|
||||||
body: createMockToolResponse(tool, input, "Drawing it."),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.goto("/", { waitUntil: "networkidle" })
|
|
||||||
await page.locator("iframe").waitFor({ state: "visible", timeout: 60000 })
|
|
||||||
await page.waitForTimeout(6000)
|
|
||||||
await sendMessage(page, prompt)
|
|
||||||
return page.frameLocator("iframe")
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("draw_graph", () => {
|
|
||||||
test("renders a decision flowchart with the right shapes", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
const canvas = await runTool(
|
|
||||||
page,
|
|
||||||
"draw_graph",
|
|
||||||
{
|
|
||||||
title: "Order Approval",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "start",
|
|
||||||
label: "Order received",
|
|
||||||
shape: "terminator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "check",
|
|
||||||
label: "Amount over 1000",
|
|
||||||
shape: "decision",
|
|
||||||
},
|
|
||||||
{ id: "mgr", label: "Manager approval" },
|
|
||||||
{ id: "auto", label: "Auto approve" },
|
|
||||||
{ id: "ship", label: "Ship order" },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ source: "start", target: "check" },
|
|
||||||
{ source: "check", target: "mgr", label: "yes" },
|
|
||||||
{ source: "check", target: "auto", label: "no" },
|
|
||||||
{ source: "mgr", target: "ship" },
|
|
||||||
{ source: "auto", target: "ship" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Draw the order approval flow",
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const label of [
|
|
||||||
"Order Approval",
|
|
||||||
"Order received",
|
|
||||||
"Amount over 1000",
|
|
||||||
"Manager approval",
|
|
||||||
"Auto approve",
|
|
||||||
"Ship order",
|
|
||||||
"yes",
|
|
||||||
])
|
|
||||||
await expect(
|
|
||||||
canvas.getByText(label, { exact: true }).first(),
|
|
||||||
`"${label}" should be on the canvas`,
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
|
|
||||||
const xml = await exportXml(page)
|
|
||||||
// The decision is a diamond and the start is a stadium, not two plain rectangles.
|
|
||||||
expect(xml).toMatch(/id="check"[^>]*rhombus/)
|
|
||||||
expect(xml).toMatch(/id="start"[^>]*arcSize=50/)
|
|
||||||
// Both branches sit on the same row, which is the whole point of the layering.
|
|
||||||
expect(cellY(xml, "mgr")).toBe(cellY(xml, "auto"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe("swimlane pool", () => {
|
|
||||||
test("renders lane bands with each step in its own lane", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
const canvas = await runTool(
|
|
||||||
page,
|
|
||||||
"restructure_diagram",
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "Expense claim",
|
|
||||||
lanes: ["Employee", "Manager", "Finance"],
|
|
||||||
phases: ["Submit", "Review", "Pay"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "fill",
|
|
||||||
parent: "p",
|
|
||||||
label: "Fill form",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "rev",
|
|
||||||
parent: "p",
|
|
||||||
label: "Review claim",
|
|
||||||
lane: 1,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "pay",
|
|
||||||
parent: "p",
|
|
||||||
label: "Pay out",
|
|
||||||
lane: 2,
|
|
||||||
col: 2,
|
|
||||||
},
|
|
||||||
{ op: "link", source: "fill", target: "rev" },
|
|
||||||
{ op: "link", source: "rev", target: "pay" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Draw the expense approval swimlane",
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const label of [
|
|
||||||
"Expense claim",
|
|
||||||
"Employee",
|
|
||||||
"Manager",
|
|
||||||
"Finance",
|
|
||||||
"Submit",
|
|
||||||
"Fill form",
|
|
||||||
"Review claim",
|
|
||||||
"Pay out",
|
|
||||||
])
|
|
||||||
await expect(
|
|
||||||
canvas.getByText(label, { exact: true }).first(),
|
|
||||||
`"${label}" should be on the canvas`,
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
|
|
||||||
const xml = await exportXml(page)
|
|
||||||
// A step is parented to its lane band, which is what records a role change when the
|
|
||||||
// user drags it to another lane.
|
|
||||||
expect(xml).toMatch(/id="fill"[^>]*parent="p__band0"/)
|
|
||||||
expect(xml).toMatch(/id="pay"[^>]*parent="p__band2"/)
|
|
||||||
expect(xml).toContain("dai_lanes=")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe("sequence diagram", () => {
|
|
||||||
test("renders lifelines with messages in step order", async ({ page }) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
const canvas = await runTool(
|
|
||||||
page,
|
|
||||||
"restructure_diagram",
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{ op: "add_sequence", id: "s", label: "Login flow" },
|
|
||||||
{ op: "add_box", id: "u", parent: "s", label: "User" },
|
|
||||||
{ op: "add_box", id: "api", parent: "s", label: "API" },
|
|
||||||
{ op: "add_box", id: "db", parent: "s", label: "Database" },
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "u",
|
|
||||||
target: "api",
|
|
||||||
label: "log in",
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "api",
|
|
||||||
target: "db",
|
|
||||||
label: "find user",
|
|
||||||
step: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "db",
|
|
||||||
target: "api",
|
|
||||||
label: "record",
|
|
||||||
step: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "api",
|
|
||||||
target: "u",
|
|
||||||
label: "token",
|
|
||||||
step: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Draw the login sequence",
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const label of [
|
|
||||||
"Login flow",
|
|
||||||
"User",
|
|
||||||
"API",
|
|
||||||
"Database",
|
|
||||||
"1. log in",
|
|
||||||
"4. token",
|
|
||||||
])
|
|
||||||
await expect(
|
|
||||||
canvas.getByText(label, { exact: true }).first(),
|
|
||||||
`"${label}" should be on the canvas`,
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
|
|
||||||
const xml = await exportXml(page)
|
|
||||||
// draw.io kept the lifeline shape — a style it did not understand would come back
|
|
||||||
// stripped or as a plain rectangle.
|
|
||||||
expect(xml).toMatch(/id="u"[^>]*shape=umlLifeline/)
|
|
||||||
expect((xml.match(/shape=umlLifeline/g) ?? []).length).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe("mind map and org chart", () => {
|
|
||||||
test("renders a mind map with branches on both sides", async ({ page }) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
const canvas = await runTool(
|
|
||||||
page,
|
|
||||||
"restructure_diagram",
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "root",
|
|
||||||
parent: "m",
|
|
||||||
label: "Product Launch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "eng",
|
|
||||||
parent: "m",
|
|
||||||
label: "Engineering",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "api",
|
|
||||||
parent: "m",
|
|
||||||
label: "API work",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mkt",
|
|
||||||
parent: "m",
|
|
||||||
label: "Marketing",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "legal", parent: "m", label: "Legal" },
|
|
||||||
{ op: "link", source: "root", target: "eng" },
|
|
||||||
{ op: "link", source: "root", target: "mkt" },
|
|
||||||
{ op: "link", source: "root", target: "legal" },
|
|
||||||
{ op: "link", source: "eng", target: "api" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Draw a product launch mind map",
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const label of [
|
|
||||||
"Product Launch",
|
|
||||||
"Engineering",
|
|
||||||
"API work",
|
|
||||||
"Marketing",
|
|
||||||
"Legal",
|
|
||||||
])
|
|
||||||
await expect(
|
|
||||||
canvas.getByText(label, { exact: true }).first(),
|
|
||||||
`"${label}" should be on the canvas`,
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
|
|
||||||
const xml = await exportXml(page)
|
|
||||||
expect(xml).toContain("dai_spread=radial")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders an org chart hanging downwards", async ({ page }) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
const canvas = await runTool(
|
|
||||||
page,
|
|
||||||
"restructure_diagram",
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{ op: "add_radial", id: "o", label: "", spread: "down" },
|
|
||||||
{ op: "add_box", id: "ceo", parent: "o", label: "CEO" },
|
|
||||||
{ op: "add_box", id: "cto", parent: "o", label: "CTO" },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "lead",
|
|
||||||
parent: "o",
|
|
||||||
label: "Platform Lead",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "cfo", parent: "o", label: "CFO" },
|
|
||||||
{ op: "link", source: "ceo", target: "cto" },
|
|
||||||
{ op: "link", source: "ceo", target: "cfo" },
|
|
||||||
{ op: "link", source: "cto", target: "lead" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Draw the org chart",
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const label of ["CEO", "CTO", "Platform Lead", "CFO"])
|
|
||||||
await expect(
|
|
||||||
canvas.getByText(label, { exact: true }).first(),
|
|
||||||
`"${label}" should be on the canvas`,
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
|
|
||||||
const xml = await exportXml(page)
|
|
||||||
expect(xml).toContain("dai_spread=down")
|
|
||||||
// Each level strictly below the one above — a reporting line drawn any other way
|
|
||||||
// reads as the wrong relationship. Geometry is parent-relative and all four share the
|
|
||||||
// same parent, so the values are directly comparable.
|
|
||||||
expect(cellY(xml, "ceo")).toBeLessThan(cellY(xml, "cto"))
|
|
||||||
expect(cellY(xml, "cto")).toBeLessThan(cellY(xml, "lead"))
|
|
||||||
expect(cellY(xml, "cto")).toBe(cellY(xml, "cfo"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -36,38 +36,6 @@ export function createMockSSEResponse(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a mock SSE response for a tool whose input is not `{ xml }`.
|
|
||||||
*
|
|
||||||
* createMockSSEResponse hardcodes the display_diagram shape; this takes the input
|
|
||||||
* object verbatim, so tools like restructure_diagram can be driven through the same
|
|
||||||
* path the model uses.
|
|
||||||
*/
|
|
||||||
export function createMockToolResponse(
|
|
||||||
toolName: string,
|
|
||||||
input: unknown,
|
|
||||||
text: string,
|
|
||||||
) {
|
|
||||||
const messageId = `msg_${Date.now()}`
|
|
||||||
const toolCallId = `call_${Date.now()}`
|
|
||||||
const textId = `text_${Date.now()}`
|
|
||||||
|
|
||||||
const events = [
|
|
||||||
{ type: "start", messageId },
|
|
||||||
{ type: "text-start", id: textId },
|
|
||||||
{ type: "text-delta", id: textId, delta: text },
|
|
||||||
{ type: "text-end", id: textId },
|
|
||||||
{ type: "tool-input-start", toolCallId, toolName },
|
|
||||||
{ type: "tool-input-available", toolCallId, toolName, input },
|
|
||||||
{ type: "finish" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
|
|
||||||
"data: [DONE]\n\n"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a text-only SSE response (no tool call)
|
* Creates a text-only SSE response (no tool call)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* restructure_diagram through the real tool path, in the real app.
|
|
||||||
*
|
|
||||||
* The unit tests cover the engine; this covers the wiring: a tool call arriving on the
|
|
||||||
* stream, the client handler reading the live canvas, the engine running in the browser,
|
|
||||||
* and the result landing in the editor.
|
|
||||||
*/
|
|
||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import * as pako from "pako"
|
|
||||||
import { getChatInput, sendMessage } from "./lib/fixtures"
|
|
||||||
import { createMockToolResponse } from "./lib/helpers"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo draw.io's export compression: the <diagram> body is URI-encoded, raw-deflated and
|
|
||||||
* base64'd. Returns the document unchanged when it is already plain XML.
|
|
||||||
*/
|
|
||||||
function inflateDiagram(xml: string): string | null {
|
|
||||||
if (xml.includes("<mxCell")) return xml
|
|
||||||
const body = xml.match(/<diagram[^>]*>([^<]+)<\/diagram>/)?.[1]
|
|
||||||
if (!body) return null
|
|
||||||
try {
|
|
||||||
const bin = Buffer.from(body, "base64")
|
|
||||||
const out = pako.inflate(new Uint8Array(bin), { windowBits: -15 })
|
|
||||||
return decodeURIComponent(new TextDecoder("utf-8").decode(out))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Operations that build a small VPC diagram from an empty canvas. */
|
|
||||||
const BUILD = {
|
|
||||||
operations: [
|
|
||||||
{ op: "set_title", title: "Test VPC" },
|
|
||||||
{ op: "add_box", id: "users", label: "Users" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "vpc",
|
|
||||||
label: "VPC 10.0.0.0/16",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_vpc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "alb",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "application_load_balancer",
|
|
||||||
label: "ALB",
|
|
||||||
},
|
|
||||||
{ op: "add_icon", id: "ec2", parent: "vpc", name: "ec2", label: "EC2" },
|
|
||||||
{ op: "link", source: "users", target: "alb", label: "HTTPS", step: 1 },
|
|
||||||
{ op: "link", source: "alb", target: "ec2", label: "route", step: 2 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("restructure_diagram", () => {
|
|
||||||
test("a structural tool call renders a diagram in the editor", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180000)
|
|
||||||
|
|
||||||
await page.route("**/api/chat", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/event-stream",
|
|
||||||
body: createMockToolResponse(
|
|
||||||
"restructure_diagram",
|
|
||||||
BUILD,
|
|
||||||
"Building the VPC diagram.",
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto("/", { waitUntil: "networkidle" })
|
|
||||||
await page
|
|
||||||
.locator("iframe")
|
|
||||||
.waitFor({ state: "visible", timeout: 60000 })
|
|
||||||
await page.waitForTimeout(6000)
|
|
||||||
|
|
||||||
await sendMessage(page, "Draw a simple VPC")
|
|
||||||
|
|
||||||
// The engine's output has to actually reach the canvas.
|
|
||||||
const canvas = page.frameLocator("iframe")
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("VPC 10.0.0.0/16", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("ALB", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 20000 })
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("Users", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 20000 })
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("Test VPC", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 20000 })
|
|
||||||
|
|
||||||
// Step numbers come through on the edge labels.
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("1. HTTPS", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 20000 })
|
|
||||||
|
|
||||||
// And the XML carries real stencils and container markers, not a fallback box.
|
|
||||||
// Ask the editor for the document directly rather than waiting for an autosave:
|
|
||||||
// loading a diagram does not always trigger one, and clicking a shape to force it
|
|
||||||
// is unreliable when an edge overlaps the label.
|
|
||||||
const xml = await page.evaluate(
|
|
||||||
() =>
|
|
||||||
new Promise<string | null>((resolve) => {
|
|
||||||
const iframe = document.querySelector(
|
|
||||||
"iframe",
|
|
||||||
) as HTMLIFrameElement
|
|
||||||
const onMsg = (e: MessageEvent) => {
|
|
||||||
if (typeof e.data !== "string") return
|
|
||||||
try {
|
|
||||||
const m = JSON.parse(e.data)
|
|
||||||
if (m.event === "export" && m.xml) {
|
|
||||||
window.removeEventListener("message", onMsg)
|
|
||||||
resolve(m.xml as string)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* not our message */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("message", onMsg)
|
|
||||||
iframe.contentWindow?.postMessage(
|
|
||||||
JSON.stringify({
|
|
||||||
action: "export",
|
|
||||||
format: "xmlsvg",
|
|
||||||
xml: 1,
|
|
||||||
}),
|
|
||||||
"*",
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
|
||||||
window.removeEventListener("message", onMsg)
|
|
||||||
resolve(null)
|
|
||||||
}, 10000)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(xml, "editor did not return the document").toBeTruthy()
|
|
||||||
// draw.io compresses the <diagram> body on export (base64 + raw deflate).
|
|
||||||
const plain = xml ? inflateDiagram(xml) : null
|
|
||||||
expect(plain, "could not decompress the exported diagram").toBeTruthy()
|
|
||||||
if (plain) {
|
|
||||||
expect(plain).toContain("resIcon=mxgraph.aws4.ec2")
|
|
||||||
expect(plain).toContain("grIcon=mxgraph.aws4.group_vpc")
|
|
||||||
expect(plain).toContain("container=1")
|
|
||||||
expect(plain).toContain("dai_dir=col")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("an invented stencil name comes back as a correctable error", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120000)
|
|
||||||
|
|
||||||
await page.route("**/api/chat", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/event-stream",
|
|
||||||
body: createMockToolResponse(
|
|
||||||
"restructure_diagram",
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "x",
|
|
||||||
name: "s3_bucket_storage",
|
|
||||||
label: "Bucket",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Adding a bucket.",
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto("/", { waitUntil: "networkidle" })
|
|
||||||
await page
|
|
||||||
.locator("iframe")
|
|
||||||
.waitFor({ state: "visible", timeout: 60000 })
|
|
||||||
await page.waitForTimeout(6000)
|
|
||||||
|
|
||||||
await sendMessage(page, "Add an S3 bucket")
|
|
||||||
|
|
||||||
// The call is rejected rather than rendering a blank square where the icon should
|
|
||||||
// be. The catalog's suggestions go back to the model over the tool-result
|
|
||||||
// channel; the UI only shows that the tool errored (which is how this repo
|
|
||||||
// surfaces every tool error — see error-handling.spec.ts).
|
|
||||||
await expect(page.locator('text="Error"').first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Nothing was drawn.
|
|
||||||
const canvas = page.frameLocator("iframe")
|
|
||||||
await expect(canvas.getByText("Bucket", { exact: true })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("a second call edits the existing diagram instead of replacing it", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(240000)
|
|
||||||
|
|
||||||
let call = 0
|
|
||||||
await page.route("**/api/chat", async (route) => {
|
|
||||||
call++
|
|
||||||
const body =
|
|
||||||
call === 1
|
|
||||||
? createMockToolResponse(
|
|
||||||
"restructure_diagram",
|
|
||||||
BUILD,
|
|
||||||
"Building it.",
|
|
||||||
)
|
|
||||||
: createMockToolResponse(
|
|
||||||
"restructure_diagram",
|
|
||||||
{
|
|
||||||
operations: [
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "rds",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "rds",
|
|
||||||
label: "RDS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "ec2",
|
|
||||||
target: "rds",
|
|
||||||
label: "query",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Adding the database.",
|
|
||||||
)
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/event-stream",
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto("/", { waitUntil: "networkidle" })
|
|
||||||
await page
|
|
||||||
.locator("iframe")
|
|
||||||
.waitFor({ state: "visible", timeout: 60000 })
|
|
||||||
await page.waitForTimeout(6000)
|
|
||||||
|
|
||||||
const canvas = page.frameLocator("iframe")
|
|
||||||
await sendMessage(page, "Draw a simple VPC")
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("ALB", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
|
|
||||||
// Second turn: one operation adds a node. Everything from the first turn stays.
|
|
||||||
await getChatInput(page).waitFor({ state: "visible" })
|
|
||||||
await sendMessage(page, "Add a database")
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("RDS", { exact: true }).first(),
|
|
||||||
).toBeVisible({ timeout: 30000 })
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("ALB", { exact: true }).first(),
|
|
||||||
).toBeVisible()
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("Users", { exact: true }).first(),
|
|
||||||
).toBeVisible()
|
|
||||||
await expect(
|
|
||||||
canvas.getByText("VPC 10.0.0.0/16", { exact: true }).first(),
|
|
||||||
).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -18,6 +18,26 @@ test.describe("Settings", () => {
|
|||||||
await expect(dialog.locator('text="English"')).toBeVisible()
|
await expect(dialog.locator('text="English"')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("max output tokens is editable and persists", async ({ page }) => {
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const input = page.locator("#max-output-tokens")
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
|
||||||
|
await input.fill("48000")
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
localStorage.getItem("next-ai-draw-io-max-output-tokens"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe("48000")
|
||||||
|
|
||||||
|
// Non-digits are dropped so the header always carries a plain number
|
||||||
|
await input.fill("12k000")
|
||||||
|
await expect(input).toHaveValue("12000")
|
||||||
|
})
|
||||||
|
|
||||||
test("draw.io theme toggle exists", async ({ page }) => {
|
test("draw.io theme toggle exists", async ({ page }) => {
|
||||||
await openSettings(page)
|
await openSettings(page)
|
||||||
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
CATALOG_SIZE,
|
|
||||||
checkNames,
|
|
||||||
lookupStencil,
|
|
||||||
resolveStyle,
|
|
||||||
searchStencils,
|
|
||||||
} from "@/lib/diagram-engine/catalog"
|
|
||||||
|
|
||||||
describe("catalog contents", () => {
|
|
||||||
it("carries the full AWS stencil set", () => {
|
|
||||||
expect(CATALOG_SIZE.icons).toBe(983)
|
|
||||||
expect(CATALOG_SIZE.groups).toBe(19)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("lookupStencil", () => {
|
|
||||||
it("returns the verbatim draw.io style, not a reconstruction", () => {
|
|
||||||
const s3 = lookupStencil("s3")
|
|
||||||
expect(s3?.style).toContain("shape=mxgraph.aws4.resourceIcon")
|
|
||||||
expect(s3?.style).toContain("resIcon=mxgraph.aws4.s3")
|
|
||||||
expect(s3?.style).toContain("aspect=fixed")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("carries the official category colour", () => {
|
|
||||||
// Storage is #7AA116, Compute #ED7100 — from AWS's own palette.
|
|
||||||
expect(lookupStencil("s3")?.color).toBe("#7AA116")
|
|
||||||
expect(lookupStencil("ec2")?.color).toBe("#ED7100")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("finds an icon whose stencil has no resIcon token", () => {
|
|
||||||
// 429 of the 983 AWS icons use a bare shape= instead of resourceIcon+resIcon.
|
|
||||||
const a1 = lookupStencil("a1_instance")
|
|
||||||
expect(a1).not.toBeNull()
|
|
||||||
expect(a1?.style).toContain("shape=mxgraph.aws4.a1_instance")
|
|
||||||
expect(a1?.style).not.toContain("resIcon=")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("finds group stencils", () => {
|
|
||||||
for (const g of [
|
|
||||||
"group_vpc",
|
|
||||||
"group_region",
|
|
||||||
"group_subnet",
|
|
||||||
"group_availability_zone",
|
|
||||||
"group_account",
|
|
||||||
])
|
|
||||||
expect(lookupStencil(g, "group")?.kind).toBe("group")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns null for a name the model invented, rather than guessing", () => {
|
|
||||||
expect(lookupStencil("s3_bucket_thing")).toBeNull()
|
|
||||||
expect(lookupStencil("totally_made_up_service")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("respects the kind filter", () => {
|
|
||||||
expect(lookupStencil("group_vpc", "icon")).toBeNull()
|
|
||||||
expect(lookupStencil("s3", "group")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("resolveStyle", () => {
|
|
||||||
it("hands the renderer a style for a known name", () => {
|
|
||||||
expect(resolveStyle("ec2", "icon")).toContain("mxgraph.aws4")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns null for an unknown name so the caller can decide", () => {
|
|
||||||
expect(resolveStyle("nope", "icon")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("searchStencils", () => {
|
|
||||||
it("ranks the plain service above its longer variants", () => {
|
|
||||||
// Without a length penalty, "backup_aws_backup_support_for_amazon_s3"
|
|
||||||
// scores the same as "s3" and can win by iteration order.
|
|
||||||
expect(searchStencils("s3")[0].name).toBe("s3")
|
|
||||||
expect(searchStencils("ec2")[0].name).toBe("ec2")
|
|
||||||
expect(searchStencils("lambda")[0].name).toBe("lambda")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("finds a multi-word service", () => {
|
|
||||||
const hits = searchStencils("nat gateway").map((h) => h.name)
|
|
||||||
expect(hits).toContain("nat_gateway")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("maps shorthand onto tokens the catalog actually uses", () => {
|
|
||||||
// AWS's stencil names are already abbreviated: EKS is "eks", and no name in the
|
|
||||||
// catalog contains the word "kubernetes". So the alias has to resolve TO the
|
|
||||||
// catalog's token, not to the spelled-out product name.
|
|
||||||
expect(searchStencils("k8s")[0].name).toBe("eks")
|
|
||||||
expect(searchStencils("kubernetes")[0].name).toBe("eks")
|
|
||||||
expect(searchStencils("alb").map((h) => h.name)).toContain(
|
|
||||||
"application_load_balancer",
|
|
||||||
)
|
|
||||||
expect(searchStencils("ddb")[0].name).toBe("dynamodb")
|
|
||||||
expect(searchStencils("bucket")[0].name).toBe("s3")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns colours so the model can see what it is getting", () => {
|
|
||||||
expect(searchStencils("s3")[0].color).toBe("#7AA116")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does NOT return styles — they would be pure context burn", () => {
|
|
||||||
const hit = searchStencils("s3")[0] as unknown as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>
|
|
||||||
expect(hit.style).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("honours the limit", () => {
|
|
||||||
expect(searchStencils("aws", { limit: 3 })).toHaveLength(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("can search groups only", () => {
|
|
||||||
const hits = searchStencils("vpc", { kind: "group" })
|
|
||||||
expect(hits.length).toBeGreaterThan(0)
|
|
||||||
expect(hits.every((h) => h.kind === "group")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns nothing for an empty query rather than the whole catalog", () => {
|
|
||||||
expect(searchStencils("")).toEqual([])
|
|
||||||
expect(searchStencils(" ")).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns nothing for a query that matches no stencil", () => {
|
|
||||||
expect(searchStencils("zzzznotathing")).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("is case- and separator-insensitive", () => {
|
|
||||||
const a = searchStencils("NAT_GATEWAY")[0].name
|
|
||||||
const b = searchStencils("nat gateway")[0].name
|
|
||||||
expect(a).toBe(b)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("checkNames", () => {
|
|
||||||
it("passes a tree whose names are all real", () => {
|
|
||||||
expect(
|
|
||||||
checkNames([
|
|
||||||
{ id: "a", name: "s3", kind: "icon" },
|
|
||||||
{ id: "b", name: "ec2", kind: "icon" },
|
|
||||||
{ id: "c", name: "group_vpc", kind: "group" },
|
|
||||||
]),
|
|
||||||
).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("catches an invented name and suggests real ones", () => {
|
|
||||||
const bad = checkNames([
|
|
||||||
{ id: "x", name: "s3_bucket_storage", kind: "icon" },
|
|
||||||
])
|
|
||||||
expect(bad).toHaveLength(1)
|
|
||||||
expect(bad[0].id).toBe("x")
|
|
||||||
expect(bad[0].suggestions.length).toBeGreaterThan(0)
|
|
||||||
expect(bad[0].suggestions).toContain("s3")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("ignores a node with no name — a box, not an icon", () => {
|
|
||||||
expect(checkNames([{ id: "b", name: "", kind: "icon" }])).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports every bad name, not just the first", () => {
|
|
||||||
const bad = checkNames([
|
|
||||||
{ id: "x", name: "fake_one", kind: "icon" },
|
|
||||||
{ id: "y", name: "s3", kind: "icon" },
|
|
||||||
{ id: "z", name: "fake_two", kind: "icon" },
|
|
||||||
])
|
|
||||||
expect(bad.map((b) => b.id)).toEqual(["x", "z"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("catches a group name used where a group is expected", () => {
|
|
||||||
const bad = checkNames([
|
|
||||||
{ id: "g", name: "group_nonexistent", kind: "group" },
|
|
||||||
])
|
|
||||||
expect(bad).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("catalog styles are usable as-is", () => {
|
|
||||||
it("every icon style names a shape draw.io can render", () => {
|
|
||||||
// Spot-check a spread of names rather than all 983 — a systematic problem would
|
|
||||||
// show up in any of them.
|
|
||||||
for (const n of [
|
|
||||||
"s3",
|
|
||||||
"ec2",
|
|
||||||
"lambda",
|
|
||||||
"rds",
|
|
||||||
"dynamodb",
|
|
||||||
"a1_instance",
|
|
||||||
"nat_gateway",
|
|
||||||
]) {
|
|
||||||
const st = lookupStencil(n)?.style ?? ""
|
|
||||||
expect(st).toMatch(/shape=mxgraph\.aws4\./)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("every group style carries grIcon and a container declaration", () => {
|
|
||||||
for (const g of ["group_vpc", "group_region", "group_account"]) {
|
|
||||||
const st = lookupStencil(g, "group")?.style ?? ""
|
|
||||||
expect(st).toContain(`grIcon=mxgraph.aws4.${g}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* add_graph: arrow-driven layout as a CONTAINER, not just a page-level tool.
|
|
||||||
*
|
|
||||||
* D2/TALA's core claim is that containers are first-class at every layout stage —
|
|
||||||
* hierarchical zones and arrow-ordered graphs mix in one diagram. Before this, the
|
|
||||||
* engine was split: draw_graph did whole-page flowcharts, containers did nesting, and
|
|
||||||
* "a poster column with a small flowchart in it" was inexpressible.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const rectOf = (xml: string, id: string) => {
|
|
||||||
const m = xml.match(
|
|
||||||
new RegExp(
|
|
||||||
`id="${id}"[^>]*>\\s*<mxGeometry[^>]*width="([\\d.]+)" height="([\\d.]+)"`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!m) throw new Error(`no geometry for ${id}`)
|
|
||||||
return { w: Number(m[1]), h: Number(m[2]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const FLOW = {
|
|
||||||
nodes: [
|
|
||||||
{ id: "a", label: "request" },
|
|
||||||
{ id: "b", label: "validate", shape: "decision" },
|
|
||||||
{ id: "c", label: "process" },
|
|
||||||
{ id: "d", label: "reject" },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ source: "a", target: "b" },
|
|
||||||
{ source: "b", target: "c", label: "ok" },
|
|
||||||
{ source: "b", target: "d", label: "no" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("add_graph", () => {
|
|
||||||
it("embeds an arrow-ordered graph inside a flexbox column", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "page", label: "", dir: "row", gap: 20 },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "left",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "intro",
|
|
||||||
parent: "left",
|
|
||||||
label: "How requests flow:",
|
|
||||||
},
|
|
||||||
{ op: "add_graph", id: "flow", parent: "left", ...FLOW },
|
|
||||||
{ op: "add_box", id: "right", parent: "page", label: "Notes" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
// The decision's two branches share a layer — arrow-driven, not declaration order.
|
|
||||||
const c = rectOf(xml, "c")
|
|
||||||
const d = rectOf(xml, "d")
|
|
||||||
expect(c).toBeTruthy()
|
|
||||||
expect(d).toBeTruthy()
|
|
||||||
// And the graph sits inside the column: the outline nests flow under left.
|
|
||||||
expect(r.outline).toMatch(/left:[\s\S]*flow:/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("two graphs on one page do not collide on synthetic layer ids", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_graph", id: "g1", ...FLOW },
|
|
||||||
{
|
|
||||||
op: "add_graph",
|
|
||||||
id: "g2",
|
|
||||||
nodes: [
|
|
||||||
{ id: "x", label: "start" },
|
|
||||||
{ id: "y", label: "end" },
|
|
||||||
],
|
|
||||||
edges: [{ source: "x", target: "y" }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.xml).toContain('id="g1__layer')
|
|
||||||
expect(r.xml).toContain('id="g2"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("dir=row transposes the flow", () => {
|
|
||||||
const make = (dir: "col" | "row") =>
|
|
||||||
restructureDiagram("", [{ op: "add_graph", id: "g", dir, ...FLOW }])
|
|
||||||
.xml as string
|
|
||||||
const down = rectOf(make("col"), "g")
|
|
||||||
const right = rectOf(make("row"), "g")
|
|
||||||
// Four layers tall vs four layers wide.
|
|
||||||
expect(down.h).toBeGreaterThan(down.w * 0.8)
|
|
||||||
expect(right.w).toBeGreaterThan(right.h)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws the rest of the graph and warns about an unknown edge endpoint", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_graph",
|
|
||||||
id: "g",
|
|
||||||
nodes: [{ id: "a", label: "a" }],
|
|
||||||
edges: [{ source: "a", target: "ghost" }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
// A warning, not an error: node "a" is perfectly drawable, and rejecting the whole
|
|
||||||
// call would cost a turn to arrive back at the same diagram.
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.xml).toBeTruthy()
|
|
||||||
expect(r.warnings.join(" ")).toContain("ghost")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { autoBoxSize } from "@/lib/diagram-engine/layout"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The flex knobs: grow, align, pad — TeX's glue and CSS's align-items, as node fields.
|
|
||||||
*
|
|
||||||
* These are what make nested containers expressive enough for card-like content.
|
|
||||||
* Graphviz (HTML-like table labels), D2 (grid containers) and TeX (box+glue) all
|
|
||||||
* converged on the same primitives: nested boxes, proportional space distribution,
|
|
||||||
* per-cell alignment. Without grow, two columns cannot split a page 2:1; without
|
|
||||||
* stretch, a verdict bar cannot span its card; without pad, a tight card and a roomy
|
|
||||||
* section cannot coexist on one page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const rectOf = (xml: string, id: string): { w: number; h: number } => {
|
|
||||||
const m = xml.match(
|
|
||||||
new RegExp(
|
|
||||||
`id="${id}"[^>]*>\\s*<mxGeometry[^>]*width="([\\d.]+)" height="([\\d.]+)"`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!m) throw new Error(`no geometry for ${id}`)
|
|
||||||
return { w: Number(m[1]), h: Number(m[2]) }
|
|
||||||
}
|
|
||||||
const xyOf = (xml: string, id: string): { x: number; y: number } => {
|
|
||||||
const m = xml.match(
|
|
||||||
new RegExp(
|
|
||||||
`id="${id}"[^>]*>\\s*<mxGeometry x="([\\d.-]+)" y="([\\d.-]+)"`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!m) throw new Error(`no position for ${id}`)
|
|
||||||
return { x: Number(m[1]), y: Number(m[2]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("grow", () => {
|
|
||||||
it("splits a row's leftover space by weight", () => {
|
|
||||||
// A wide banner forces the page wider than the two columns need; grow 2:1
|
|
||||||
// must hand the columns that slack in proportion.
|
|
||||||
const make = (withGrow: boolean) =>
|
|
||||||
restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "A very wide masthead banner that stretches the page out",
|
|
||||||
role: "banner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "cols",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "main",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 8,
|
|
||||||
...(withGrow ? { grow: 2 } : {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "side",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 8,
|
|
||||||
...(withGrow ? { grow: 1 } : {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "a",
|
|
||||||
parent: "main",
|
|
||||||
label: "main content",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "b", parent: "side", label: "aside" },
|
|
||||||
]).xml as string
|
|
||||||
|
|
||||||
const flat = make(false)
|
|
||||||
const grown = make(true)
|
|
||||||
|
|
||||||
// The weights make main wider than it would be on its own content...
|
|
||||||
expect(rectOf(grown, "main").w).toBeGreaterThan(rectOf(flat, "main").w)
|
|
||||||
|
|
||||||
// ...but NOT the full 2:1, and that is correct rather than a shortfall. `side`
|
|
||||||
// will not shrink below the width of its own text, so the ratio settles wherever
|
|
||||||
// that floor allows. A browser does exactly the same: `min-width` defaults to
|
|
||||||
// `auto`, so a `flex: 2` column stops shrinking at its content too.
|
|
||||||
const ratio = rectOf(grown, "main").w / rectOf(grown, "side").w
|
|
||||||
expect(ratio).toBeGreaterThan(1.3)
|
|
||||||
expect(ratio).toBeLessThan(2.1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("min-w-0 lets the weights win over the content width", () => {
|
|
||||||
// The CSS escape hatch, same spelling: with min-w-0 the narrow column may be
|
|
||||||
// squeezed under its own text, so a declared 2:1 really comes out 2:1.
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "A very wide masthead banner that stretches the page out",
|
|
||||||
role: "banner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "cols",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "main",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-2 min-w-0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "side",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-1 min-w-0",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "a", parent: "main", label: "main content" },
|
|
||||||
{ op: "add_box", id: "b", parent: "side", label: "aside" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const ratio = rectOf(xml, "main").w / rectOf(xml, "side").w
|
|
||||||
expect(ratio).toBeCloseTo(2, 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("align", () => {
|
|
||||||
it("stretch spans the cross axis; start pins to the edge", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "card", label: "", dir: "col", gap: 8 },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "wide",
|
|
||||||
parent: "card",
|
|
||||||
label: "This is a long question line that sets the card width",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "bar",
|
|
||||||
parent: "card",
|
|
||||||
label: "A: 11",
|
|
||||||
align: "stretch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "verdict",
|
|
||||||
parent: "card",
|
|
||||||
label: "✗ Wrong",
|
|
||||||
align: "start",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
// The bar fills the card's interior width, like the wide line does.
|
|
||||||
expect(rectOf(xml, "bar").w).toBe(rectOf(xml, "wide").w)
|
|
||||||
// start pins to the interior's left edge — same x as the widest child.
|
|
||||||
expect(xyOf(xml, "verdict").x).toBe(xyOf(xml, "wide").x)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pad", () => {
|
|
||||||
it("a tight card is smaller than the default for the same content", () => {
|
|
||||||
const make = (pad?: number) =>
|
|
||||||
restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "c",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 8,
|
|
||||||
...(pad != null ? { pad } : {}),
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "x", parent: "c", label: "content" },
|
|
||||||
]).xml as string
|
|
||||||
const tight = rectOf(make(8), "c")
|
|
||||||
const roomy = rectOf(make(), "c")
|
|
||||||
expect(roomy.w - tight.w).toBe(32) // (24-8)*2
|
|
||||||
expect(roomy.h - tight.h).toBe(32)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("round trip", () => {
|
|
||||||
it("grow, align and pad survive parse and re-render", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "row",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
gap: 10,
|
|
||||||
pad: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "left",
|
|
||||||
parent: "row",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 6,
|
|
||||||
grow: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "a",
|
|
||||||
parent: "left",
|
|
||||||
label: "hello",
|
|
||||||
align: "stretch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "b",
|
|
||||||
parent: "row",
|
|
||||||
label: "side",
|
|
||||||
grow: 1,
|
|
||||||
align: "end",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const back = parseDiagram(r.xml as string)
|
|
||||||
const row = back.tree.roots.find((n) => n.id === "row")
|
|
||||||
expect(row?.kind).toBe("group")
|
|
||||||
if (row?.kind !== "group") return
|
|
||||||
expect(row.pad).toBe(10)
|
|
||||||
const left = row.children.find((n) => n.id === "left")
|
|
||||||
expect(left?.kind === "group" && left.grow).toBe(3)
|
|
||||||
const a = left?.kind === "group" && left.children[0]
|
|
||||||
expect(a && a.kind === "box" && a.align).toBe("stretch")
|
|
||||||
const b = row.children.find((n) => n.id === "b")
|
|
||||||
expect(b?.kind === "box" && b.grow).toBe(1)
|
|
||||||
expect(b?.kind === "box" && b.align).toBe("end")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("rich text measurement", () => {
|
|
||||||
it("does not count markup as text, and counts <br> as a line", () => {
|
|
||||||
const plain = autoBoxSize("Often Wrong")
|
|
||||||
const rich = autoBoxSize(
|
|
||||||
'<font color="#B85450"><b>Often Wrong</b></font>',
|
|
||||||
)
|
|
||||||
expect(rich.w).toBe(plain.w)
|
|
||||||
expect(rich.h).toBe(plain.h)
|
|
||||||
const twoLines = autoBoxSize("first line<br>second line")
|
|
||||||
const oneLine = autoBoxSize("first line")
|
|
||||||
expect(twoLines.h).toBeGreaterThan(oneLine.h)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,701 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
type GraphEdge,
|
|
||||||
type GraphNode,
|
|
||||||
graphToOperations,
|
|
||||||
type Operation,
|
|
||||||
restructureDiagram,
|
|
||||||
} from "@/lib/diagram-engine"
|
|
||||||
import {
|
|
||||||
absoluteRects,
|
|
||||||
edgePaths,
|
|
||||||
nodeCollisions,
|
|
||||||
rectOf,
|
|
||||||
} from "./fixtures/geometry"
|
|
||||||
|
|
||||||
const n = (id: string, label = id): GraphNode => ({ id, label })
|
|
||||||
const e = (source: string, target: string, label?: string): GraphEdge => ({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
...(label ? { label } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw a whole-page graph the way the model does: one `add_graph` with no parent.
|
|
||||||
*
|
|
||||||
* There used to be a `drawGraph` entry point that did only this. It was removed once
|
|
||||||
* `add_graph` covered the same ground with a `parent` argument — one code path instead of
|
|
||||||
* two overlapping ones — and these tests kept their assertions by going through this.
|
|
||||||
*/
|
|
||||||
function drawGraph(
|
|
||||||
nodes: GraphNode[],
|
|
||||||
edges: GraphEdge[],
|
|
||||||
opts: { title?: string; flow?: "col" | "row" } = {},
|
|
||||||
) {
|
|
||||||
const ops: Operation[] = [
|
|
||||||
{
|
|
||||||
op: "add_graph",
|
|
||||||
id: "g",
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
...(opts.flow ? { dir: opts.flow } : {}),
|
|
||||||
} as Operation,
|
|
||||||
]
|
|
||||||
if (opts.title) ops.unshift({ op: "set_title", title: opts.title })
|
|
||||||
return restructureDiagram("", ops)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("graphToOperations: layering", () => {
|
|
||||||
it("puts a chain in one node per layer", () => {
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("a"), n("b"), n("c")],
|
|
||||||
[e("a", "b"), e("b", "c")],
|
|
||||||
)
|
|
||||||
expect(layers).toEqual([["a"], ["b"], ["c"]])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts the branches of a decision in the same layer", () => {
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("q"), n("yes"), n("no")],
|
|
||||||
[e("q", "yes"), e("q", "no")],
|
|
||||||
)
|
|
||||||
expect(layers[0]).toEqual(["q"])
|
|
||||||
expect(layers[1].sort()).toEqual(["no", "yes"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses the LONGEST path, so no arrow points sideways", () => {
|
|
||||||
// a→b, a→c, c→b. The shortest path would put b in layer 1 beside c, leaving c→b
|
|
||||||
// pointing sideways. b has to come after c.
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("a"), n("b"), n("c")],
|
|
||||||
[e("a", "b"), e("a", "c"), e("c", "b")],
|
|
||||||
)
|
|
||||||
expect(layers).toEqual([["a"], ["c"], ["b"]])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a node with no arrows at all", () => {
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("a"), n("b"), n("island")],
|
|
||||||
[e("a", "b")],
|
|
||||||
)
|
|
||||||
expect(layers.flat().sort()).toEqual(["a", "b", "island"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws a loop but does not let it set the layering", () => {
|
|
||||||
const r = graphToOperations(
|
|
||||||
[n("a"), n("b"), n("c")],
|
|
||||||
[e("a", "b"), e("b", "c"), e("c", "a")],
|
|
||||||
)
|
|
||||||
expect(r.layers).toEqual([["a"], ["b"], ["c"]])
|
|
||||||
expect(r.backEdges).toEqual([{ source: "c", target: "a" }])
|
|
||||||
// The loop is still drawn.
|
|
||||||
expect(r.operations.filter((o) => o.op === "link").length).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws a self-loop and keeps it out of the layering", () => {
|
|
||||||
const r = graphToOperations(
|
|
||||||
[n("a"), n("b")],
|
|
||||||
[e("a", "b"), e("a", "a")],
|
|
||||||
)
|
|
||||||
expect(r.layers).toEqual([["a"], ["b"]])
|
|
||||||
expect(r.operations.filter((o) => o.op === "link").length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports an edge naming a node that does not exist", () => {
|
|
||||||
const r = graphToOperations([n("a")], [e("a", "ghost")])
|
|
||||||
expect(r.unknownEndpoints).toEqual(["ghost"])
|
|
||||||
expect(r.operations.filter((o) => o.op === "link")).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives a graph that is nothing but a cycle", () => {
|
|
||||||
const r = graphToOperations(
|
|
||||||
[n("a"), n("b")],
|
|
||||||
[e("a", "b"), e("b", "a")],
|
|
||||||
)
|
|
||||||
expect(r.layers.flat().sort()).toEqual(["a", "b"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("graphToOperations: within-layer ordering", () => {
|
|
||||||
it("reverses a layer when that removes the crossings", () => {
|
|
||||||
// a→z, b→y, c→x. Declared order would make all three cross.
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("a"), n("b"), n("c"), n("x"), n("y"), n("z")],
|
|
||||||
[e("a", "z"), e("b", "y"), e("c", "x")],
|
|
||||||
)
|
|
||||||
expect(layers[0]).toEqual(["a", "b", "c"])
|
|
||||||
expect(layers[1]).toEqual(["z", "y", "x"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves an already-good order alone", () => {
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("a"), n("b"), n("x"), n("y")],
|
|
||||||
[e("a", "x"), e("b", "y")],
|
|
||||||
)
|
|
||||||
expect(layers[1]).toEqual(["x", "y"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("graphToOperations: emitted operations", () => {
|
|
||||||
it("does not wrap a layer holding one node", () => {
|
|
||||||
const { operations } = graphToOperations(
|
|
||||||
[n("a"), n("b")],
|
|
||||||
[e("a", "b")],
|
|
||||||
)
|
|
||||||
const containers = operations.filter((o) => o.op === "add_container")
|
|
||||||
// Only the outer flow container: neither single-node layer needs a wrapper.
|
|
||||||
expect(containers.length).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("wraps a layer holding several nodes", () => {
|
|
||||||
const { operations } = graphToOperations(
|
|
||||||
[n("q"), n("yes"), n("no")],
|
|
||||||
[e("q", "yes"), e("q", "no")],
|
|
||||||
)
|
|
||||||
const containers = operations.filter((o) => o.op === "add_container")
|
|
||||||
expect(containers.length).toBe(2)
|
|
||||||
// The layer band runs ACROSS the flow.
|
|
||||||
const band = containers.find((c) => c.id !== "__layers")
|
|
||||||
expect(band?.dir).toBe("row")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("flips both axes when the flow runs left to right", () => {
|
|
||||||
const { operations } = graphToOperations(
|
|
||||||
[n("q"), n("yes"), n("no")],
|
|
||||||
[e("q", "yes"), e("q", "no")],
|
|
||||||
{ flow: "row" },
|
|
||||||
)
|
|
||||||
const containers = operations.filter((o) => o.op === "add_container")
|
|
||||||
expect(containers.find((c) => c.id === "__layers")?.dir).toBe("row")
|
|
||||||
expect(containers.find((c) => c.id !== "__layers")?.dir).toBe("col")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("carries shapes and labels through", () => {
|
|
||||||
const { operations } = graphToOperations(
|
|
||||||
[
|
|
||||||
{ id: "s", label: "Start", shape: "terminator" },
|
|
||||||
{ id: "q", label: "OK?", shape: "decision" },
|
|
||||||
],
|
|
||||||
[e("s", "q", "go")],
|
|
||||||
)
|
|
||||||
const boxes = operations.filter((o) => o.op === "add_box")
|
|
||||||
expect(boxes.map((b) => b.shape)).toEqual(["terminator", "decision"])
|
|
||||||
expect(operations.find((o) => o.op === "link")?.label).toBe("go")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("emits an icon node as an icon", () => {
|
|
||||||
const { operations } = graphToOperations(
|
|
||||||
[{ id: "s3", label: "Bucket", icon: "s3" }],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
const icon = operations.find((o) => o.op === "add_icon")
|
|
||||||
expect(icon).toMatchObject({ id: "s3", name: "s3", label: "Bucket" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not emit a plain box shape as an explicit shape", () => {
|
|
||||||
const { operations } = graphToOperations(
|
|
||||||
[{ id: "a", label: "A", shape: "box" }],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
expect(operations.find((o) => o.op === "add_box")).not.toHaveProperty(
|
|
||||||
"shape",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("drawGraph: the whole pipeline", () => {
|
|
||||||
it("draws a decision flow with no arrow hitting an unrelated box", () => {
|
|
||||||
const ids = ["start", "check", "mgr", "auto", "ship", "reject"]
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{ id: "start", label: "Order received", shape: "terminator" },
|
|
||||||
{ id: "check", label: "Amount > $1000?", shape: "decision" },
|
|
||||||
n("mgr", "Manager approval"),
|
|
||||||
n("auto", "Auto-approve"),
|
|
||||||
n("ship", "Ship order"),
|
|
||||||
{ id: "reject", label: "Reject", shape: "terminator" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
e("start", "check"),
|
|
||||||
e("check", "mgr", "yes"),
|
|
||||||
e("check", "auto", "no"),
|
|
||||||
e("mgr", "ship", "approved"),
|
|
||||||
e("mgr", "reject", "denied"),
|
|
||||||
e("auto", "ship"),
|
|
||||||
],
|
|
||||||
{ title: "Order Approval" },
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
expect(nodeCollisions(edgePaths(xml, rects), rects, ids)).toEqual([])
|
|
||||||
|
|
||||||
// Layers descend in flow order.
|
|
||||||
expect(rectOf(rects, "start").y).toBeLessThan(rectOf(rects, "check").y)
|
|
||||||
expect(rectOf(rects, "check").y).toBeLessThan(rectOf(rects, "mgr").y)
|
|
||||||
expect(rectOf(rects, "mgr").y).toBe(rectOf(rects, "auto").y)
|
|
||||||
|
|
||||||
expect(xml).toContain("Order Approval")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders a decision as a diamond and a terminator as a stadium", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{ id: "s", label: "Start", shape: "terminator" },
|
|
||||||
{ id: "q", label: "OK?", shape: "decision" },
|
|
||||||
{ id: "d", label: "Report", shape: "document" },
|
|
||||||
{ id: "i", label: "Input", shape: "data" },
|
|
||||||
],
|
|
||||||
[e("s", "q"), e("q", "d"), e("q", "i")],
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
expect(xml).toMatch(/id="q"[^>]*rhombus/)
|
|
||||||
expect(xml).toMatch(/id="s"[^>]*arcSize=50/)
|
|
||||||
expect(xml).toMatch(/id="d"[^>]*shape=document/)
|
|
||||||
expect(xml).toMatch(/id="i"[^>]*shape=parallelogram/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a 14-node pipeline free of arrows through boxes", () => {
|
|
||||||
const ids = [
|
|
||||||
"commit",
|
|
||||||
"lint",
|
|
||||||
"unit",
|
|
||||||
"build",
|
|
||||||
"itest",
|
|
||||||
"sec",
|
|
||||||
"stage",
|
|
||||||
"smoke",
|
|
||||||
"approve",
|
|
||||||
"prod",
|
|
||||||
"canary",
|
|
||||||
"monitor",
|
|
||||||
"alert",
|
|
||||||
"rollback",
|
|
||||||
]
|
|
||||||
const r = drawGraph(
|
|
||||||
ids.map((id) => n(id)),
|
|
||||||
[
|
|
||||||
e("commit", "lint"),
|
|
||||||
e("commit", "unit"),
|
|
||||||
e("lint", "build"),
|
|
||||||
e("unit", "build"),
|
|
||||||
e("build", "itest"),
|
|
||||||
e("build", "sec"),
|
|
||||||
e("itest", "stage"),
|
|
||||||
e("sec", "stage"),
|
|
||||||
e("stage", "smoke"),
|
|
||||||
e("smoke", "approve"),
|
|
||||||
e("approve", "prod"),
|
|
||||||
e("prod", "canary"),
|
|
||||||
e("canary", "monitor"),
|
|
||||||
e("monitor", "alert"),
|
|
||||||
e("alert", "rollback"),
|
|
||||||
e("rollback", "stage"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const rects = absoluteRects(r.xml as string)
|
|
||||||
expect(
|
|
||||||
nodeCollisions(edgePaths(r.xml as string, rects), rects, ids),
|
|
||||||
).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects an empty node list rather than drawing nothing", () => {
|
|
||||||
const r = drawGraph([], [])
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
expect(r.errors[0]).toContain("no nodes")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a duplicate id instead of silently dropping one", () => {
|
|
||||||
const r = drawGraph([n("a"), n("a")], [])
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
expect(r.errors[0]).toContain("duplicate")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("warns about an edge naming a node that is not there", () => {
|
|
||||||
const r = drawGraph([n("a")], [e("a", "ghost")])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.warnings.join(" ")).toContain("ghost")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("warns which arrows were treated as loops", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[n("a"), n("b")],
|
|
||||||
[e("a", "b"), e("b", "a", "retry")],
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.warnings.join(" ")).toContain("b→a")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Two defects that both showed up as an arrow drawn over a box, on a flowchart with a
|
|
||||||
* "send it back" loop in it. Neither was visible on the smaller flowcharts above: one
|
|
||||||
* needs a back edge whose return path is lined with other steps, the other needs an edge
|
|
||||||
* that skips over a layer holding more than one node.
|
|
||||||
*/
|
|
||||||
describe("graphToOperations: arrows do not run through boxes", () => {
|
|
||||||
/** Ids of the real steps, excluding the engine's own layout cells. */
|
|
||||||
const steps = (xml: string): string[] =>
|
|
||||||
[...xml.matchAll(/<mxCell id="([^"]+)"[^>]*dai_kind=(?:box|icon);/g)]
|
|
||||||
.map((m) => m[1])
|
|
||||||
.filter((id) => !id.startsWith("__"))
|
|
||||||
|
|
||||||
it("routes a back edge around the steps it returns past", () => {
|
|
||||||
// The reported case: "return for correction" goes back to "submit", and the two
|
|
||||||
// steps between them are directly in the way. The engine wraps each layer in an
|
|
||||||
// INVISIBLE container, and those used to be handed to the router as frames — so
|
|
||||||
// every clean return path was rejected for "trespassing" on a frame that is not
|
|
||||||
// drawn, and the fallback cut straight through both steps.
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{ id: "start", label: "Start", shape: "terminator" },
|
|
||||||
{ id: "submit", label: "Submit Request", shape: "data" },
|
|
||||||
n("validate", "Validate Input"),
|
|
||||||
{ id: "valid", label: "Valid?", shape: "decision" },
|
|
||||||
n("fix", "Return for Correction"),
|
|
||||||
n("review", "Manager Review"),
|
|
||||||
{ id: "end", label: "End", shape: "terminator" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
e("start", "submit"),
|
|
||||||
e("submit", "validate"),
|
|
||||||
e("validate", "valid"),
|
|
||||||
e("valid", "fix", "No"),
|
|
||||||
e("fix", "submit"),
|
|
||||||
e("valid", "review", "Yes"),
|
|
||||||
e("review", "end"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
expect(
|
|
||||||
nodeCollisions(edgePaths(xml, rects), rects, steps(xml)),
|
|
||||||
).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the placeholders out of the reported layers", () => {
|
|
||||||
// They are an internal device for reserving space; a caller asked about its own nodes.
|
|
||||||
const { layers } = graphToOperations(
|
|
||||||
[n("a"), n("b1"), n("b2"), n("z")],
|
|
||||||
[
|
|
||||||
e("a", "b1"),
|
|
||||||
e("a", "b2"),
|
|
||||||
e("b1", "z"),
|
|
||||||
e("b2", "z"),
|
|
||||||
e("a", "z"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
expect(layers.flat().filter((id) => id.startsWith("__"))).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An arrow that leaves its own shape and immediately turns back across it.
|
|
||||||
*
|
|
||||||
* Reported on a request-handling flowchart: a short hook came out of the right side of
|
|
||||||
* "Manager approval" and bent straight back over the box's own edge. The ordinary obstacle
|
|
||||||
* test cannot see this — an edge is exempt from its own two endpoints, and it has to be,
|
|
||||||
* since the line must touch them — so it is checked separately here.
|
|
||||||
*/
|
|
||||||
describe("graphToOperations: arrows leave their own shape cleanly", () => {
|
|
||||||
/** Waypoints that land inside any box, which is what draws the hook. */
|
|
||||||
const waypointsInsideBoxes = (xml: string): string[] => {
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
const boxes = [
|
|
||||||
...xml.matchAll(/<mxCell id="([^"]+)"[^>]*dai_kind=(?:box|icon);/g),
|
|
||||||
]
|
|
||||||
.map((m) => m[1])
|
|
||||||
.filter((id) => !id.startsWith("__"))
|
|
||||||
const bad: string[] = []
|
|
||||||
for (const p of edgePaths(xml, rects))
|
|
||||||
for (const w of p.points.slice(1, -1))
|
|
||||||
for (const id of boxes) {
|
|
||||||
const r = rects.get(id)
|
|
||||||
if (!r) continue
|
|
||||||
if (
|
|
||||||
w.x > r.x + 1 &&
|
|
||||||
w.x < r.x + r.w - 1 &&
|
|
||||||
w.y > r.y + 1 &&
|
|
||||||
w.y < r.y + r.h - 1
|
|
||||||
)
|
|
||||||
bad.push(`${p.source}→${p.target} bends inside ${id}`)
|
|
||||||
}
|
|
||||||
return [...new Set(bad)]
|
|
||||||
}
|
|
||||||
|
|
||||||
it("does not bend an arrow back over the box it just left", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: "start",
|
|
||||||
label: "Start: Request received",
|
|
||||||
shape: "terminator",
|
|
||||||
},
|
|
||||||
n("intake", "Log request in system"),
|
|
||||||
n("review", "Review details"),
|
|
||||||
{ id: "complete", label: "Info complete?", shape: "decision" },
|
|
||||||
{ id: "askinfo", label: "Request missing info", shape: "data" },
|
|
||||||
{ id: "approve", label: "Approval needed?", shape: "decision" },
|
|
||||||
n("manager", "Manager approval"),
|
|
||||||
n("process", "Process request"),
|
|
||||||
{ id: "report", label: "Generate report", shape: "document" },
|
|
||||||
n("notify", "Notify requester"),
|
|
||||||
{ id: "end", label: "End", shape: "terminator" },
|
|
||||||
{ id: "reject", label: "Reject & close", shape: "round" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
e("start", "intake"),
|
|
||||||
e("intake", "review"),
|
|
||||||
e("review", "complete"),
|
|
||||||
e("complete", "askinfo", "no"),
|
|
||||||
e("askinfo", "review", "resubmit"),
|
|
||||||
e("complete", "approve", "yes"),
|
|
||||||
e("approve", "manager", "yes"),
|
|
||||||
e("approve", "process", "no"),
|
|
||||||
e("manager", "process", "approved"),
|
|
||||||
e("manager", "reject", "declined"),
|
|
||||||
e("process", "report"),
|
|
||||||
e("report", "notify"),
|
|
||||||
e("reject", "notify"),
|
|
||||||
e("notify", "end"),
|
|
||||||
],
|
|
||||||
{ title: "Sample Request Handling Workflow" },
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(waypointsInsideBoxes(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edge labels must not sit on boxes or on each other.
|
|
||||||
*
|
|
||||||
* The router keeps LINES off the boxes, but a label renders at its edge's midpoint — and on
|
|
||||||
* a long edge that midpoint is beside exactly the things the line was routed around. On the
|
|
||||||
* reported git-workflow diagram, four labels sat on unrelated boxes or on other labels.
|
|
||||||
* `placeLabels` slides each label along its own edge to a clear spot, written as the
|
|
||||||
* geometry's relative x.
|
|
||||||
*/
|
|
||||||
describe("edge labels avoid boxes and each other", () => {
|
|
||||||
it("places every git-workflow label on empty space", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: "remote",
|
|
||||||
label: "Remote Repository (origin / GitHub)",
|
|
||||||
shape: "data",
|
|
||||||
},
|
|
||||||
n("work", "Working Directory (edited files)"),
|
|
||||||
n("stage", "Staging Area (index)"),
|
|
||||||
{
|
|
||||||
id: "stash",
|
|
||||||
label: "Stash (temporary shelf)",
|
|
||||||
shape: "round",
|
|
||||||
},
|
|
||||||
n("local", "Local Repository (.git commits)"),
|
|
||||||
{
|
|
||||||
id: "branch",
|
|
||||||
label: "Branch / Merge (feature -> main)",
|
|
||||||
shape: "decision",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
e("remote", "work", "git clone"),
|
|
||||||
e("work", "stage", "git add"),
|
|
||||||
{
|
|
||||||
source: "stage",
|
|
||||||
target: "work",
|
|
||||||
label: "git reset",
|
|
||||||
dashed: true,
|
|
||||||
},
|
|
||||||
e("work", "stash", "git stash"),
|
|
||||||
e("stash", "work", "git stash pop"),
|
|
||||||
e("stage", "local", "git commit"),
|
|
||||||
{
|
|
||||||
source: "local",
|
|
||||||
target: "stage",
|
|
||||||
label: "git checkout",
|
|
||||||
dashed: true,
|
|
||||||
},
|
|
||||||
e("local", "work", "git pull / merge"),
|
|
||||||
{
|
|
||||||
source: "remote",
|
|
||||||
target: "local",
|
|
||||||
label: "git fetch",
|
|
||||||
dashed: true,
|
|
||||||
},
|
|
||||||
e("local", "branch", "git branch / checkout -b"),
|
|
||||||
e("branch", "remote", "git push"),
|
|
||||||
],
|
|
||||||
{ title: "Git Operations Overview" },
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
const ids = ["remote", "work", "stage", "stash", "local", "branch"]
|
|
||||||
|
|
||||||
// Recompute each label's rectangle the way draw.io places it: at the geometry's
|
|
||||||
// relative x along the path (−1 source, 0 middle, +1 target).
|
|
||||||
const labels: {
|
|
||||||
id: string
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}[] = []
|
|
||||||
for (const p of edgePaths(xml, rects)) {
|
|
||||||
if (!p.label) continue
|
|
||||||
const m = xml.match(
|
|
||||||
new RegExp(
|
|
||||||
`<mxCell id="${p.id}"[^>]*>\\s*<mxGeometry x="(-?[\\d.]+)" relative="1"`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const t = ((m ? Number(m[1]) : 0) + 1) / 2
|
|
||||||
let total = 0
|
|
||||||
const segs = p.points.slice(0, -1).map((pt, i) => {
|
|
||||||
const len =
|
|
||||||
Math.abs(p.points[i + 1].x - pt.x) +
|
|
||||||
Math.abs(p.points[i + 1].y - pt.y)
|
|
||||||
total += len
|
|
||||||
return { a: pt, b: p.points[i + 1], len }
|
|
||||||
})
|
|
||||||
let at = total * t
|
|
||||||
let pos = p.points[0]
|
|
||||||
for (const s of segs) {
|
|
||||||
if (at <= s.len || s === segs[segs.length - 1]) {
|
|
||||||
const f = s.len ? Math.min(1, at / s.len) : 0
|
|
||||||
pos = {
|
|
||||||
x: s.a.x + (s.b.x - s.a.x) * f,
|
|
||||||
y: s.a.y + (s.b.y - s.a.y) * f,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
at -= s.len
|
|
||||||
}
|
|
||||||
const w = Math.min(160, p.label.length * 7 + 8)
|
|
||||||
labels.push({ id: p.id, x: pos.x - w / 2, y: pos.y - 8, w, h: 16 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const bad: string[] = []
|
|
||||||
for (const l of labels) {
|
|
||||||
for (const id of ids) {
|
|
||||||
const b = rectOf(rects, id)
|
|
||||||
if (
|
|
||||||
l.x < b.x + b.w &&
|
|
||||||
l.x + l.w > b.x &&
|
|
||||||
l.y < b.y + b.h &&
|
|
||||||
l.y + l.h > b.y
|
|
||||||
)
|
|
||||||
bad.push(`${l.id} label sits on ${id}`)
|
|
||||||
}
|
|
||||||
for (const m of labels)
|
|
||||||
if (
|
|
||||||
m !== l &&
|
|
||||||
l.id < m.id &&
|
|
||||||
l.x < m.x + m.w &&
|
|
||||||
l.x + l.w > m.x &&
|
|
||||||
l.y < m.y + m.h &&
|
|
||||||
l.y + l.h > m.y
|
|
||||||
)
|
|
||||||
bad.push(`${l.id} label sits on ${m.id} label`)
|
|
||||||
}
|
|
||||||
expect(bad).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A→B and B→A are one relationship drawn as two arrows, and a reader expects a matched
|
|
||||||
* pair: two parallel lines a constant gap apart. Routed independently they land on port
|
|
||||||
* positions chosen for unrelated reasons, so one line runs straight while its partner
|
|
||||||
* wanders through a different corridor with a kink in it.
|
|
||||||
*/
|
|
||||||
describe("opposite edges are drawn as a parallel pair", () => {
|
|
||||||
it("gives git add / git reset two straight tracks a constant gap apart", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[n("work", "Working Directory"), n("stage", "Staging Area")],
|
|
||||||
[
|
|
||||||
e("work", "stage", "git add"),
|
|
||||||
{
|
|
||||||
source: "stage",
|
|
||||||
target: "work",
|
|
||||||
label: "git reset",
|
|
||||||
dashed: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
const paths = edgePaths(xml, rects)
|
|
||||||
const fwd = paths.find((p) => p.source === "work")
|
|
||||||
const rev = paths.find((p) => p.source === "stage")
|
|
||||||
if (!fwd || !rev) throw new Error("both edges must render")
|
|
||||||
|
|
||||||
// Both straight: two points, no waypoints.
|
|
||||||
expect(fwd.points).toHaveLength(2)
|
|
||||||
expect(rev.points).toHaveLength(2)
|
|
||||||
// Parallel vertical tracks a constant gap apart.
|
|
||||||
expect(Math.abs(fwd.points[0].x - fwd.points[1].x)).toBeLessThan(1)
|
|
||||||
expect(Math.abs(rev.points[0].x - rev.points[1].x)).toBeLessThan(1)
|
|
||||||
const gap = Math.abs(fwd.points[0].x - rev.points[0].x)
|
|
||||||
expect(gap).toBeGreaterThan(12)
|
|
||||||
expect(gap).toBeLessThan(40)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic groups: the caller names zones, the engine colours them.
|
|
||||||
*
|
|
||||||
* The model is good at judging which nodes belong together and bad at picking hex colours
|
|
||||||
* that match; letting it choose produced mismatched saturations and a different look per
|
|
||||||
* diagram. So `group` maps to a fixed engine palette in order of first appearance, and the
|
|
||||||
* same grouping always produces the same colours.
|
|
||||||
*/
|
|
||||||
describe("draw_graph: semantic groups", () => {
|
|
||||||
it("colours nodes by group, consistently, without the caller naming a colour", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{ id: "a", label: "A", group: "remote" },
|
|
||||||
{ id: "b", label: "B", group: "local" },
|
|
||||||
{ id: "c", label: "C", group: "local" },
|
|
||||||
n("d", "D"),
|
|
||||||
],
|
|
||||||
[e("a", "b"), e("b", "c"), e("c", "d")],
|
|
||||||
)
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const fillOf = (id: string) => {
|
|
||||||
const m = xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))
|
|
||||||
return [...(m?.[1] ?? "").matchAll(/fillColor=([^;]*)/g)].pop()?.[1]
|
|
||||||
}
|
|
||||||
// First group named gets the first palette slot; same group, same colour.
|
|
||||||
expect(fillOf("a")).toBe("#DAE8FC")
|
|
||||||
expect(fillOf("b")).toBe("#D5E8D4")
|
|
||||||
expect(fillOf("c")).toBe("#D5E8D4")
|
|
||||||
// No group: the plain white fallback.
|
|
||||||
expect(fillOf("d")).toBe("#FFFFFF")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("group colours survive a round trip through the canvas", () => {
|
|
||||||
const r = drawGraph(
|
|
||||||
[
|
|
||||||
{ id: "a", label: "A", group: "g1" },
|
|
||||||
{ id: "b", label: "B", group: "g2" },
|
|
||||||
],
|
|
||||||
[e("a", "b")],
|
|
||||||
)
|
|
||||||
const again = restructureDiagram(r.xml as string, [])
|
|
||||||
const fillOf = (xml: string, id: string) => {
|
|
||||||
const m = xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))
|
|
||||||
return [...(m?.[1] ?? "").matchAll(/fillColor=([^;]*)/g)].pop()?.[1]
|
|
||||||
}
|
|
||||||
expect(fillOf(again.xml as string, "a")).toBe(
|
|
||||||
fillOf(r.xml as string, "a"),
|
|
||||||
)
|
|
||||||
expect(fillOf(again.xml as string, "b")).toBe(
|
|
||||||
fillOf(r.xml as string, "b"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import { countTokens } from "@anthropic-ai/tokenizer"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
describeDiagram,
|
|
||||||
type Operation,
|
|
||||||
restructureDiagram,
|
|
||||||
} from "@/lib/diagram-engine"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import { findNode, findParent } from "@/lib/diagram-engine/types"
|
|
||||||
|
|
||||||
/** The operations that build a 3-tier VPC diagram from nothing. */
|
|
||||||
const BUILD_3TIER: Operation[] = [
|
|
||||||
{ op: "set_title", title: "VPC Multi-AZ 3-tier" },
|
|
||||||
{ op: "add_box", id: "users", label: "Users / Internet" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "region",
|
|
||||||
label: "Region (ap-southeast-1)",
|
|
||||||
dir: "row",
|
|
||||||
gname: "group_region",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "vpc",
|
|
||||||
parent: "region",
|
|
||||||
label: "VPC 10.0.0.0/16",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_vpc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "igw",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "internet_gateway",
|
|
||||||
label: "Internet Gateway",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "alb",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "application_load_balancer",
|
|
||||||
label: "ALB",
|
|
||||||
},
|
|
||||||
{ op: "add_container", id: "azs", parent: "vpc", label: "", dir: "row" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "az_a",
|
|
||||||
parent: "azs",
|
|
||||||
label: "AZ-a",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_availability_zone",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "pub_a",
|
|
||||||
parent: "az_a",
|
|
||||||
label: "Public Subnet",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_subnet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "nat_a",
|
|
||||||
parent: "pub_a",
|
|
||||||
name: "nat_gateway",
|
|
||||||
label: "NAT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "app_a",
|
|
||||||
parent: "az_a",
|
|
||||||
label: "Private Subnet (App)",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_subnet",
|
|
||||||
},
|
|
||||||
{ op: "add_icon", id: "ec2_a", parent: "app_a", name: "ec2", label: "EC2" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "db_a",
|
|
||||||
parent: "az_a",
|
|
||||||
label: "Private Subnet (Data)",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_subnet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "rds_a",
|
|
||||||
parent: "db_a",
|
|
||||||
name: "rds",
|
|
||||||
label: "RDS (Primary)",
|
|
||||||
},
|
|
||||||
{ op: "link", source: "users", target: "igw", label: "HTTPS", step: 1 },
|
|
||||||
{ op: "link", source: "igw", target: "alb", label: "forward", step: 2 },
|
|
||||||
{ op: "link", source: "alb", target: "ec2_a", label: "route", step: 3 },
|
|
||||||
{ op: "link", source: "ec2_a", target: "rds_a", label: "query", step: 4 },
|
|
||||||
]
|
|
||||||
|
|
||||||
describe("restructureDiagram builds from an empty canvas", () => {
|
|
||||||
const r = restructureDiagram("", BUILD_3TIER)
|
|
||||||
|
|
||||||
it("succeeds", () => {
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.xml).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("produces XML draw.io can load", () => {
|
|
||||||
expect(r.xml).toContain("<mxfile")
|
|
||||||
expect(r.xml).toContain('<mxCell id="0"/>')
|
|
||||||
expect(r.xml).toContain('<mxCell id="1" parent="0"/>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("resolves catalog names to verbatim stencil styles, with official colours", () => {
|
|
||||||
// #ED7100 is AWS Compute orange; nothing here is hand-assembled.
|
|
||||||
expect(r.xml).toContain("resIcon=mxgraph.aws4.ec2")
|
|
||||||
expect(r.xml).toContain("#ED7100")
|
|
||||||
expect(r.xml).toContain("grIcon=mxgraph.aws4.group_vpc")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("stamps container=1 so a user can drag shapes between frames", () => {
|
|
||||||
const vpcStyle =
|
|
||||||
r.xml?.match(/<mxCell id="vpc"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
||||||
expect(vpcStyle).toContain("container=1")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads back the structure it was asked to build", () => {
|
|
||||||
const { tree } = parseDiagram(r.xml as string)
|
|
||||||
expect(findParent(tree, "vpc")?.id).toBe("region")
|
|
||||||
expect(findParent(tree, "az_a")?.id).toBe("azs")
|
|
||||||
expect(findParent(tree, "nat_a")?.id).toBe("pub_a")
|
|
||||||
expect(tree.links).toHaveLength(4)
|
|
||||||
expect(tree.title).toBe("VPC Multi-AZ 3-tier")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("restructureDiagram edits an existing canvas", () => {
|
|
||||||
const built = restructureDiagram("", BUILD_3TIER).xml as string
|
|
||||||
|
|
||||||
it("adds one node with one small operation", () => {
|
|
||||||
const r = restructureDiagram(built, [
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "cache_a",
|
|
||||||
parent: "app_a",
|
|
||||||
name: "elasticache",
|
|
||||||
label: "Redis",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const { tree } = parseDiagram(r.xml as string)
|
|
||||||
expect(findParent(tree, "cache_a")?.id).toBe("app_a")
|
|
||||||
// everything else is still there
|
|
||||||
expect(findNode(tree, "rds_a")).not.toBeNull()
|
|
||||||
expect(tree.links).toHaveLength(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-orients a container without touching anything else", () => {
|
|
||||||
const r = restructureDiagram(built, [
|
|
||||||
{ op: "set_dir", id: "vpc", dir: "row" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const { tree } = parseDiagram(r.xml as string)
|
|
||||||
expect((findNode(tree, "vpc") as { dir: string }).dir).toBe("row")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("removes a subtree and the edges that pointed into it", () => {
|
|
||||||
const r = restructureDiagram(built, [{ op: "remove", id: "db_a" }])
|
|
||||||
const { tree } = parseDiagram(r.xml as string)
|
|
||||||
expect(findNode(tree, "db_a")).toBeNull()
|
|
||||||
expect(findNode(tree, "rds_a")).toBeNull()
|
|
||||||
// the ec2 → rds edge went with it
|
|
||||||
expect(tree.links).toHaveLength(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a colour the user changed by hand", () => {
|
|
||||||
const edited = built.replace(
|
|
||||||
/(<mxCell id="users"[^>]*style="[^"]*)"/,
|
|
||||||
'$1fillColor=#FF0000;"',
|
|
||||||
)
|
|
||||||
const r = restructureDiagram(edited, [
|
|
||||||
{ op: "set_label", id: "users", label: "Clients" },
|
|
||||||
])
|
|
||||||
expect(r.xml).toContain("fillColor=#FF0000")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a shape the user added by hand", () => {
|
|
||||||
const edited = built.replace(
|
|
||||||
"</root>",
|
|
||||||
'<mxCell id="mynote" value="Note" style="shape=note;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="1200" y="60" width="140" height="80" as="geometry"/></mxCell></root>',
|
|
||||||
)
|
|
||||||
const r = restructureDiagram(edited, [
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "s3",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "s3",
|
|
||||||
label: "S3",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.xml).toContain('id="mynote"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("respects where the user dragged a node to", () => {
|
|
||||||
// The user moved the EC2 icon from the app subnet into the public subnet.
|
|
||||||
const moved = built.replace(
|
|
||||||
/(<mxCell id="ec2_a"[^>]*)parent="app_a"/,
|
|
||||||
'$1parent="pub_a"',
|
|
||||||
)
|
|
||||||
const r = restructureDiagram(moved, [
|
|
||||||
{ op: "set_label", id: "ec2_a", label: "EC2 (moved)" },
|
|
||||||
])
|
|
||||||
const { tree } = parseDiagram(r.xml as string)
|
|
||||||
expect(findParent(tree, "ec2_a")?.id).toBe("pub_a")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("invented stencil names are rejected, not rendered blank", () => {
|
|
||||||
it("fails the whole call and suggests real names", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "x",
|
|
||||||
name: "s3_bucket_storage",
|
|
||||||
label: "Bucket",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
expect(r.errors[0]).toContain("not in the stencil catalog")
|
|
||||||
expect(r.errors[0]).toContain("s3")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects an invented group stencil too", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "g",
|
|
||||||
label: "X",
|
|
||||||
dir: "row",
|
|
||||||
gname: "group_made_up",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
expect(r.errors[0]).toContain("not in the stencil catalog")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still returns the outline so the model can see what it built", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_icon", id: "x", name: "nope_not_real" },
|
|
||||||
])
|
|
||||||
expect(r.outline).toContain("x: icon nope_not_real")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports a failed operation without rendering a half-built diagram", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_icon", id: "a", name: "s3" },
|
|
||||||
{ op: "move", id: "ghost", parent: "a" },
|
|
||||||
])
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
expect(r.errors.some((e) => e.includes("ghost"))).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("describeDiagram", () => {
|
|
||||||
it("reports an empty canvas plainly", () => {
|
|
||||||
expect(describeDiagram("").outline).toBe("(empty canvas)")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("outlines what is on the canvas without changing it", () => {
|
|
||||||
const built = restructureDiagram("", BUILD_3TIER).xml as string
|
|
||||||
const d = describeDiagram(built)
|
|
||||||
expect(d.outline).toContain("vpc: col")
|
|
||||||
expect(d.outline).toContain("ec2_a: icon ec2")
|
|
||||||
expect(d.outline).toContain("link users -> igw")
|
|
||||||
expect(d.needsAdoption).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("flags a diagram that did not come from the engine", () => {
|
|
||||||
const foreign = `<mxfile><diagram name="Page-1" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="X" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
expect(describeDiagram(foreign).needsAdoption).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("token cost: operations versus raw XML", () => {
|
|
||||||
// The reason for the whole exercise. Measured with Claude's own tokenizer, which the
|
|
||||||
// repo already depends on, rather than a characters/4 estimate.
|
|
||||||
const built = restructureDiagram("", BUILD_3TIER).xml as string
|
|
||||||
|
|
||||||
it("building a diagram costs far fewer tokens as operations than as XML", () => {
|
|
||||||
const asOps = countTokens(JSON.stringify(BUILD_3TIER))
|
|
||||||
const asXml = countTokens(built)
|
|
||||||
console.log(
|
|
||||||
`build: ${asOps} tok as operations vs ${asXml} tok as XML (${(asXml / asOps).toFixed(1)}x)`,
|
|
||||||
)
|
|
||||||
expect(asOps).toBeLessThan(asXml / 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adding one icon costs a fraction of re-emitting the diagram", () => {
|
|
||||||
const oneOp: Operation[] = [
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "cache",
|
|
||||||
parent: "app_a",
|
|
||||||
name: "elasticache",
|
|
||||||
label: "Redis",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const opTokens = countTokens(JSON.stringify(oneOp))
|
|
||||||
const xmlTokens = countTokens(
|
|
||||||
restructureDiagram(built, oneOp).xml as string,
|
|
||||||
)
|
|
||||||
console.log(
|
|
||||||
`add one icon: ${opTokens} tok as an operation vs ${xmlTokens} tok re-emitting the XML (${Math.round(xmlTokens / opTokens)}x)`,
|
|
||||||
)
|
|
||||||
expect(opTokens).toBeLessThan(60)
|
|
||||||
expect(opTokens).toBeLessThan(xmlTokens / 20)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("the outline the model reads back is much cheaper than the XML", () => {
|
|
||||||
const outlineTokens = countTokens(describeDiagram(built).outline)
|
|
||||||
const xmlTokens = countTokens(built)
|
|
||||||
console.log(
|
|
||||||
`read current state: ${outlineTokens} tok as an outline vs ${xmlTokens} tok as XML (${(xmlTokens / outlineTokens).toFixed(1)}x)`,
|
|
||||||
)
|
|
||||||
expect(outlineTokens).toBeLessThan(xmlTokens / 3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
autoBoxSize,
|
|
||||||
flatten,
|
|
||||||
layoutForest,
|
|
||||||
type Placed,
|
|
||||||
} from "@/lib/diagram-engine/layout"
|
|
||||||
import type {
|
|
||||||
BoxNode,
|
|
||||||
DiagramNode,
|
|
||||||
GridNode,
|
|
||||||
GroupNode,
|
|
||||||
IconNode,
|
|
||||||
Rect,
|
|
||||||
} from "@/lib/diagram-engine/types"
|
|
||||||
|
|
||||||
const icon = (id: string, label = "", size?: number): IconNode => ({
|
|
||||||
kind: "icon",
|
|
||||||
id,
|
|
||||||
name: "ec2",
|
|
||||||
label,
|
|
||||||
size,
|
|
||||||
})
|
|
||||||
const box = (id: string, label = "", w?: number, h?: number): BoxNode => ({
|
|
||||||
kind: "box",
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
})
|
|
||||||
const group = (
|
|
||||||
id: string,
|
|
||||||
dir: "row" | "col",
|
|
||||||
children: DiagramNode[],
|
|
||||||
label = "",
|
|
||||||
gap = 20,
|
|
||||||
): GroupNode => ({ kind: "group", id, gname: null, label, dir, gap, children })
|
|
||||||
const grid = (
|
|
||||||
id: string,
|
|
||||||
cols: number,
|
|
||||||
children: DiagramNode[],
|
|
||||||
label = "",
|
|
||||||
gap = 20,
|
|
||||||
): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children })
|
|
||||||
|
|
||||||
/** Look a rect up by node id in a placed forest. */
|
|
||||||
function rects(roots: Placed[]): Map<string, Rect> {
|
|
||||||
const m = new Map<string, Rect>()
|
|
||||||
for (const f of flatten(roots)) m.set(f.node.id, f.rect)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
const contains = (outer: Rect, inner: Rect) =>
|
|
||||||
inner.x >= outer.x &&
|
|
||||||
inner.y >= outer.y &&
|
|
||||||
inner.x + inner.w <= outer.x + outer.w &&
|
|
||||||
inner.y + inner.h <= outer.y + outer.h
|
|
||||||
const overlaps = (a: Rect, b: Rect) =>
|
|
||||||
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0 &&
|
|
||||||
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0
|
|
||||||
|
|
||||||
describe("autoBoxSize", () => {
|
|
||||||
it("widens with the label but stops at a maximum", () => {
|
|
||||||
expect(autoBoxSize("hi").w).toBe(120) // floor
|
|
||||||
expect(autoBoxSize("x".repeat(200)).w).toBe(260) // ceiling
|
|
||||||
expect(autoBoxSize("a medium length label").w).toBeGreaterThan(120)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("grows taller with each line", () => {
|
|
||||||
expect(autoBoxSize("one\ntwo\nthree").h).toBeGreaterThan(
|
|
||||||
autoBoxSize("one").h,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("never returns a degenerate size for an empty label", () => {
|
|
||||||
const s = autoBoxSize("")
|
|
||||||
expect(s.w).toBeGreaterThan(0)
|
|
||||||
expect(s.h).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("containers always fit their children", () => {
|
|
||||||
it("holds a row of icons", () => {
|
|
||||||
const tree = group(
|
|
||||||
"f",
|
|
||||||
"row",
|
|
||||||
[icon("a"), icon("b"), icon("c")],
|
|
||||||
"Frame",
|
|
||||||
)
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
const frame = r.get("f") as Rect
|
|
||||||
for (const id of ["a", "b", "c"])
|
|
||||||
expect(contains(frame, r.get(id) as Rect)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("holds a column of icons", () => {
|
|
||||||
const tree = group("f", "col", [icon("a"), icon("b")], "Frame")
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
for (const id of ["a", "b"])
|
|
||||||
expect(contains(r.get("f") as Rect, r.get(id) as Rect)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("holds a grid", () => {
|
|
||||||
const tree = grid(
|
|
||||||
"g",
|
|
||||||
3,
|
|
||||||
[icon("a"), icon("b"), icon("c"), icon("d")],
|
|
||||||
"G",
|
|
||||||
)
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
for (const id of ["a", "b", "c", "d"])
|
|
||||||
expect(contains(r.get("g") as Rect, r.get(id) as Rect)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("holds a deeply nested structure at every level", () => {
|
|
||||||
// Region → VPC → AZ → Subnet → icon, the shape of a real cloud diagram
|
|
||||||
const tree = group(
|
|
||||||
"region",
|
|
||||||
"row",
|
|
||||||
[
|
|
||||||
group(
|
|
||||||
"vpc",
|
|
||||||
"col",
|
|
||||||
[
|
|
||||||
group(
|
|
||||||
"az",
|
|
||||||
"col",
|
|
||||||
[group("subnet", "col", [icon("ec2")], "Subnet")],
|
|
||||||
"AZ",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"VPC",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"Region",
|
|
||||||
)
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
const chain = ["region", "vpc", "az", "subnet", "ec2"]
|
|
||||||
for (let i = 1; i < chain.length; i++)
|
|
||||||
expect(
|
|
||||||
contains(r.get(chain[i - 1]) as Rect, r.get(chain[i]) as Rect),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("holds a child whose label is far wider than the frame's own", () => {
|
|
||||||
const tree = group(
|
|
||||||
"f",
|
|
||||||
"col",
|
|
||||||
[box("wide", "a considerably longer label than the frame title")],
|
|
||||||
"F",
|
|
||||||
)
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
expect(contains(r.get("f") as Rect, r.get("wide") as Rect)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("siblings never overlap", () => {
|
|
||||||
it("keeps a row of icons apart", () => {
|
|
||||||
const tree = group("f", "row", [icon("a"), icon("b"), icon("c")])
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
expect(overlaps(r.get("a") as Rect, r.get("b") as Rect)).toBe(false)
|
|
||||||
expect(overlaps(r.get("b") as Rect, r.get("c") as Rect)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a column of frames apart", () => {
|
|
||||||
const tree = group("f", "col", [
|
|
||||||
group("s1", "row", [icon("a")], "Public"),
|
|
||||||
group("s2", "row", [icon("b")], "Private"),
|
|
||||||
group("s3", "row", [icon("c")], "Data"),
|
|
||||||
])
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
expect(overlaps(r.get("s1") as Rect, r.get("s2") as Rect)).toBe(false)
|
|
||||||
expect(overlaps(r.get("s2") as Rect, r.get("s3") as Rect)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps grid cells apart", () => {
|
|
||||||
const tree = grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")])
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
const ids = ["a", "b", "c", "d"]
|
|
||||||
for (let i = 0; i < ids.length; i++)
|
|
||||||
for (let j = i + 1; j < ids.length; j++)
|
|
||||||
expect(
|
|
||||||
overlaps(r.get(ids[i]) as Rect, r.get(ids[j]) as Rect),
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps separate roots apart", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([
|
|
||||||
box("users", "Users"),
|
|
||||||
group("region", "row", [icon("a")]),
|
|
||||||
]).roots,
|
|
||||||
)
|
|
||||||
expect(overlaps(r.get("users") as Rect, r.get("region") as Rect)).toBe(
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps 30 siblings apart — no accumulation error", () => {
|
|
||||||
const kids = Array.from({ length: 30 }, (_, i) =>
|
|
||||||
icon(`i${i}`, `n${i}`),
|
|
||||||
)
|
|
||||||
const r = rects(layoutForest([group("f", "row", kids)]).roots)
|
|
||||||
for (let i = 1; i < 30; i++) {
|
|
||||||
const prev = r.get(`i${i - 1}`) as Rect
|
|
||||||
const cur = r.get(`i${i}`) as Rect
|
|
||||||
expect(cur.x).toBeGreaterThanOrEqual(prev.x + prev.w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("direction", () => {
|
|
||||||
it("advances along x in a row and keeps y aligned", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([group("f", "row", [icon("a"), icon("b")])]).roots,
|
|
||||||
)
|
|
||||||
const a = r.get("a") as Rect
|
|
||||||
const b = r.get("b") as Rect
|
|
||||||
expect(b.x).toBeGreaterThan(a.x)
|
|
||||||
expect(b.y).toBe(a.y)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("advances along y in a column and keeps x aligned", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([group("f", "col", [icon("a"), icon("b")])]).roots,
|
|
||||||
)
|
|
||||||
const a = r.get("a") as Rect
|
|
||||||
const b = r.get("b") as Rect
|
|
||||||
expect(b.y).toBeGreaterThan(a.y)
|
|
||||||
expect(b.x).toBe(a.x)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("wraps a grid at the column count", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([
|
|
||||||
grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")]),
|
|
||||||
]).roots,
|
|
||||||
)
|
|
||||||
const a = r.get("a") as Rect
|
|
||||||
const b = r.get("b") as Rect
|
|
||||||
const c = r.get("c") as Rect
|
|
||||||
expect(b.y).toBe(a.y) // same row
|
|
||||||
expect(c.y).toBeGreaterThan(a.y) // wrapped
|
|
||||||
expect(c.x).toBe(a.x) // back to the first column
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("sibling equalisation", () => {
|
|
||||||
it("gives frames in a row a shared height", () => {
|
|
||||||
const tree = group("f", "row", [
|
|
||||||
group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"),
|
|
||||||
group("short", "col", [icon("d")], "Short"),
|
|
||||||
])
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
expect((r.get("short") as Rect).h).toBe((r.get("tall") as Rect).h)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gives frames in a column a shared width", () => {
|
|
||||||
const tree = group("f", "col", [
|
|
||||||
group("wide", "row", [icon("a"), icon("b"), icon("c")], "Wide"),
|
|
||||||
group("narrow", "row", [icon("d")], "Narrow"),
|
|
||||||
])
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
expect((r.get("narrow") as Rect).w).toBe((r.get("wide") as Rect).w)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not stretch a leaf icon — that would distort the glyph", () => {
|
|
||||||
const tree = group("f", "row", [
|
|
||||||
group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"),
|
|
||||||
icon("lone", "Lone"),
|
|
||||||
])
|
|
||||||
const r = rects(layoutForest([tree]).roots)
|
|
||||||
expect((r.get("lone") as Rect).h).toBeLessThan(
|
|
||||||
(r.get("tall") as Rect).h,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("title strip", () => {
|
|
||||||
it("reserves space above the children when a container is labelled", () => {
|
|
||||||
const withLabel = rects(
|
|
||||||
layoutForest([group("f", "row", [icon("a")], "Titled")]).roots,
|
|
||||||
)
|
|
||||||
const withoutLabel = rects(
|
|
||||||
layoutForest([group("f", "row", [icon("a")], "")]).roots,
|
|
||||||
)
|
|
||||||
expect((withLabel.get("f") as Rect).h).toBeGreaterThan(
|
|
||||||
(withoutLabel.get("f") as Rect).h,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("pushes children below the strip so the label is not covered", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([group("f", "col", [icon("a")], "Titled")]).roots,
|
|
||||||
)
|
|
||||||
const f = r.get("f") as Rect
|
|
||||||
const a = r.get("a") as Rect
|
|
||||||
expect(a.y).toBeGreaterThanOrEqual(f.y + 36)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("widens a frame whose title is longer than its contents", () => {
|
|
||||||
const longTitle =
|
|
||||||
"A Very Long Container Title That Exceeds Its Single Child"
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([group("f", "row", [icon("a")], longTitle)]).roots,
|
|
||||||
)
|
|
||||||
expect((r.get("f") as Rect).w).toBeGreaterThan(longTitle.length * 5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("page size", () => {
|
|
||||||
it("covers every node plus a margin", () => {
|
|
||||||
const { roots, page } = layoutForest([
|
|
||||||
group("f", "row", [icon("a"), icon("b")], "F"),
|
|
||||||
])
|
|
||||||
const all = flatten(roots)
|
|
||||||
const maxX = Math.max(...all.map((n) => n.rect.x + n.rect.w))
|
|
||||||
const maxY = Math.max(...all.map((n) => n.rect.y + n.rect.h))
|
|
||||||
expect(page.w).toBeGreaterThan(maxX)
|
|
||||||
expect(page.h).toBeGreaterThan(maxY)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("grows with the content", () => {
|
|
||||||
const small = layoutForest([group("f", "row", [icon("a")])]).page
|
|
||||||
const big = layoutForest([
|
|
||||||
group(
|
|
||||||
"f",
|
|
||||||
"row",
|
|
||||||
Array.from({ length: 10 }, (_, i) => icon(`i${i}`)),
|
|
||||||
),
|
|
||||||
]).page
|
|
||||||
expect(big.w).toBeGreaterThan(small.w)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pinned nodes", () => {
|
|
||||||
it("keeps a pinned root where the user left it", () => {
|
|
||||||
const pinned: GroupNode = {
|
|
||||||
...group("f", "row", [icon("a")], "F"),
|
|
||||||
pinned: true,
|
|
||||||
rect: { x: 777, y: 555, w: 100, h: 100 },
|
|
||||||
}
|
|
||||||
const r = rects(layoutForest([pinned]).roots)
|
|
||||||
expect((r.get("f") as Rect).x).toBe(777)
|
|
||||||
expect((r.get("f") as Rect).y).toBe(555)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still lays the pinned node's children out inside it", () => {
|
|
||||||
const pinned: GroupNode = {
|
|
||||||
...group("f", "row", [icon("a")], "F"),
|
|
||||||
pinned: true,
|
|
||||||
rect: { x: 300, y: 300, w: 100, h: 100 },
|
|
||||||
}
|
|
||||||
const r = rects(layoutForest([pinned]).roots)
|
|
||||||
expect(contains(r.get("f") as Rect, r.get("a") as Rect)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not let a pinned root consume flow space from the others", () => {
|
|
||||||
const pinned: BoxNode = {
|
|
||||||
...box("pin", "Pinned"),
|
|
||||||
pinned: true,
|
|
||||||
rect: { x: 900, y: 900, w: 120, h: 60 },
|
|
||||||
}
|
|
||||||
const r = rects(layoutForest([pinned, box("flow", "Flow")]).roots)
|
|
||||||
// the un-pinned root starts at the normal origin, not offset past the pinned one
|
|
||||||
expect((r.get("flow") as Rect).x).toBe(40)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("icon sizing", () => {
|
|
||||||
it("applies the diagram-wide glyph size", () => {
|
|
||||||
const small = rects(layoutForest([icon("a")], { iconSize: 48 }).roots)
|
|
||||||
const large = rects(layoutForest([icon("a")], { iconSize: 96 }).roots)
|
|
||||||
expect((large.get("a") as Rect).h).toBeGreaterThan(
|
|
||||||
(small.get("a") as Rect).h,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("lets a per-icon size override the diagram default", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([group("f", "row", [icon("a"), icon("b", "", 96)])], {
|
|
||||||
iconSize: 48,
|
|
||||||
}).roots,
|
|
||||||
)
|
|
||||||
expect((r.get("b") as Rect).h).toBeGreaterThan((r.get("a") as Rect).h)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("widens the cell for a long label so it does not overflow", () => {
|
|
||||||
const r = rects(
|
|
||||||
layoutForest([
|
|
||||||
group("f", "row", [
|
|
||||||
icon("a", "x"),
|
|
||||||
icon("b", "a much longer label"),
|
|
||||||
]),
|
|
||||||
]).roots,
|
|
||||||
)
|
|
||||||
expect((r.get("b") as Rect).w).toBeGreaterThan((r.get("a") as Rect).w)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("degenerate input", () => {
|
|
||||||
it("handles an empty forest", () => {
|
|
||||||
const { roots, page } = layoutForest([])
|
|
||||||
expect(roots).toEqual([])
|
|
||||||
expect(page.w).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles an empty container without producing a negative size", () => {
|
|
||||||
const r = rects(layoutForest([group("f", "row", [], "Empty")]).roots)
|
|
||||||
const f = r.get("f") as Rect
|
|
||||||
expect(f.w).toBeGreaterThan(0)
|
|
||||||
expect(f.h).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles a grid with fewer children than columns", () => {
|
|
||||||
const r = rects(layoutForest([grid("g", 5, [icon("a")], "G")]).roots)
|
|
||||||
expect(contains(r.get("g") as Rect, r.get("a") as Rect)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rounds every coordinate to an integer — draw.io renders half-pixels blurry", () => {
|
|
||||||
const tree = group("f", "row", [
|
|
||||||
group("a", "col", [icon("x"), icon("y"), icon("z")], "A"),
|
|
||||||
icon("b"),
|
|
||||||
])
|
|
||||||
for (const f of flatten(layoutForest([tree]).roots)) {
|
|
||||||
expect(Number.isInteger(f.rect.x)).toBe(true)
|
|
||||||
expect(Number.isInteger(f.rect.y)).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("determinism", () => {
|
|
||||||
it("produces identical geometry for the same tree twice", () => {
|
|
||||||
const build = () =>
|
|
||||||
group("f", "row", [
|
|
||||||
group("a", "col", [icon("x"), icon("y")], "A"),
|
|
||||||
grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"),
|
|
||||||
])
|
|
||||||
const first = flatten(layoutForest([build()]).roots).map((n) => [
|
|
||||||
n.node.id,
|
|
||||||
n.rect,
|
|
||||||
])
|
|
||||||
const second = flatten(layoutForest([build()]).roots).map((n) => [
|
|
||||||
n.node.id,
|
|
||||||
n.rect,
|
|
||||||
])
|
|
||||||
expect(second).toEqual(first)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("flatten", () => {
|
|
||||||
it("reports the real parent id for a nested node and the layer for a root", () => {
|
|
||||||
const tree = group("f", "row", [group("inner", "row", [icon("leaf")])])
|
|
||||||
const flat = flatten(layoutForest([tree]).roots)
|
|
||||||
const by = new Map(flat.map((n) => [n.node.id, n.parent]))
|
|
||||||
expect(by.get("f")).toBe("1")
|
|
||||||
expect(by.get("inner")).toBe("f")
|
|
||||||
expect(by.get("leaf")).toBe("inner")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("emits a parent before its children, so XML order is valid", () => {
|
|
||||||
const tree = group("f", "row", [group("inner", "row", [icon("leaf")])])
|
|
||||||
const ids = flatten(layoutForest([tree]).roots).map((n) => n.node.id)
|
|
||||||
expect(ids.indexOf("f")).toBeLessThan(ids.indexOf("inner"))
|
|
||||||
expect(ids.indexOf("inner")).toBeLessThan(ids.indexOf("leaf"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The connection vocabulary: arrowheads carry meaning. A crow's foot IS "many", a
|
|
||||||
* hollow diamond IS aggregation — notation, not decoration. The engine passes the
|
|
||||||
* head/tail tokens through to draw.io and writes fill explicitly, because UML
|
|
||||||
* composition and aggregation differ ONLY by fill.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const styleOfEdge = (xml: string, source: string, target: string): string => {
|
|
||||||
const m = xml.match(
|
|
||||||
new RegExp(
|
|
||||||
`<mxCell [^>]*style="([^"]*)"[^>]*source="${source}" target="${target}"`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return m?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const two = [
|
|
||||||
{ op: "add_box" as const, id: "a", label: "customer" },
|
|
||||||
{ op: "add_box" as const, id: "b", label: "order" },
|
|
||||||
]
|
|
||||||
|
|
||||||
describe("arrowheads", () => {
|
|
||||||
it("passes ER crow's foot through with explicit fill", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
...two,
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "a",
|
|
||||||
target: "b",
|
|
||||||
tail: "ERone",
|
|
||||||
head: "ERoneToMany",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const s = styleOfEdge(r.xml as string, "a", "b")
|
|
||||||
expect(s).toContain("endArrow=ERoneToMany")
|
|
||||||
expect(s).toContain("endFill=0")
|
|
||||||
expect(s).toContain("startArrow=ERone")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("UML: hollow vs filled diamond survive the round trip distinctly", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
...two,
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "a",
|
|
||||||
target: "b",
|
|
||||||
head: "diamondThin",
|
|
||||||
headFill: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const back = parseDiagram(r.xml as string)
|
|
||||||
const l = back.tree.links.find(
|
|
||||||
(x) => x.source === "a" && x.target === "b",
|
|
||||||
)
|
|
||||||
expect(l?.head).toBe("diamondThin")
|
|
||||||
expect(l?.headFill).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects an injection-capable arrowhead token", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
...two,
|
|
||||||
{ op: "link", source: "a", target: "b", head: "block;dashed=1" },
|
|
||||||
])
|
|
||||||
expect(r.errors.join(" ")).toContain("not allowed")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("parallel edges", () => {
|
|
||||||
it("a second edge between the same pair needs an id, then both render", () => {
|
|
||||||
const rejected = restructureDiagram("", [
|
|
||||||
...two,
|
|
||||||
{ op: "link", source: "a", target: "b", label: "places" },
|
|
||||||
{ op: "link", source: "a", target: "b", label: "cancels" },
|
|
||||||
])
|
|
||||||
expect(rejected.errors.join(" ")).toContain("give this one an id")
|
|
||||||
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
...two,
|
|
||||||
{ op: "link", source: "a", target: "b", label: "places" },
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
id: "e2",
|
|
||||||
source: "a",
|
|
||||||
target: "b",
|
|
||||||
label: "cancels",
|
|
||||||
dashed: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
expect(xml).toContain('value="places"')
|
|
||||||
expect(xml).toContain('value="cancels"')
|
|
||||||
// and both come back as separate links
|
|
||||||
const back = parseDiagram(xml)
|
|
||||||
expect(
|
|
||||||
back.tree.links.filter((l) => l.source === "a" && l.target === "b"),
|
|
||||||
).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("bold renders thick and survives the round trip", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
...two,
|
|
||||||
{ op: "link", source: "a", target: "b", bold: true },
|
|
||||||
])
|
|
||||||
expect(styleOfEdge(r.xml as string, "a", "b")).toContain(
|
|
||||||
"strokeWidth=4",
|
|
||||||
)
|
|
||||||
const back = parseDiagram(r.xml as string)
|
|
||||||
expect(back.tree.links[0]?.bold).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
hasMarkers,
|
|
||||||
isLaneChrome,
|
|
||||||
isPinned,
|
|
||||||
MARKER,
|
|
||||||
readCell,
|
|
||||||
readDir,
|
|
||||||
readIntMarker,
|
|
||||||
readKind,
|
|
||||||
readList,
|
|
||||||
readMarker,
|
|
||||||
stampCell,
|
|
||||||
stampContainer,
|
|
||||||
stampLane,
|
|
||||||
stampLeaf,
|
|
||||||
stampPool,
|
|
||||||
stampPoolDecoration,
|
|
||||||
stampRadial,
|
|
||||||
stampSequence,
|
|
||||||
stripMarkers,
|
|
||||||
} from "@/lib/diagram-engine/markers"
|
|
||||||
|
|
||||||
// Real catalog styles from drawio-ai-kit's catalog/aws.json, verbatim. group_vpc
|
|
||||||
// ships WITHOUT container=1; group_account ships WITH it. The engine has to handle both.
|
|
||||||
const VPC_STYLE =
|
|
||||||
"sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_vpc;strokeColor=#879196;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#879196;dashed=0;"
|
|
||||||
const ACCOUNT_STYLE =
|
|
||||||
"points=[[0,0],[0.25,0],[0.5,0]];outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_account;strokeColor=#CD2264;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#CD2264;dashed=0;"
|
|
||||||
|
|
||||||
describe("stampContainer", () => {
|
|
||||||
it("adds container tokens to a catalog style that lacks them", () => {
|
|
||||||
const s = stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 22,
|
|
||||||
})
|
|
||||||
expect(s).toContain("container=1;")
|
|
||||||
expect(s).toContain("pointerEvents=0;")
|
|
||||||
expect(s).toContain("collapsible=0;")
|
|
||||||
expect(s).toContain("recursiveResize=0;")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("is safe on a style that already declares container=1", () => {
|
|
||||||
const s = stampContainer(ACCOUNT_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 30,
|
|
||||||
})
|
|
||||||
// Stated exactly once. Duplicate keys are legal in draw.io and the last one wins
|
|
||||||
// (verified in-browser), so a second copy was harmless to render — but a container
|
|
||||||
// is re-stamped on EVERY layout, so appending unconditionally grew the style by
|
|
||||||
// another copy per round-trip and the XML never settled.
|
|
||||||
expect(s.match(/container=1/g)?.length).toBe(1)
|
|
||||||
expect(readDir(s)).toBe("row")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-stamping is idempotent, so a round-trip settles", () => {
|
|
||||||
const once = stampContainer(ACCOUNT_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 30,
|
|
||||||
})
|
|
||||||
const twice = stampContainer(once, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 30,
|
|
||||||
})
|
|
||||||
expect(twice).toBe(once)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("corrects a catalog stencil that declares container=0", () => {
|
|
||||||
const s = stampContainer("rounded=0;container=0;fillColor=none;", {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 12,
|
|
||||||
})
|
|
||||||
expect(s).not.toContain("container=0")
|
|
||||||
expect(s.match(/container=1/g)?.length).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("records kind, dir and gap so the parser need not guess", () => {
|
|
||||||
const s = stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 22,
|
|
||||||
})
|
|
||||||
expect(readKind(s)).toBe("group")
|
|
||||||
expect(readDir(s)).toBe("col")
|
|
||||||
expect(readIntMarker(s, "dai_gap")).toBe(22)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("records cols only for a grid", () => {
|
|
||||||
const grid = stampContainer(VPC_STYLE, {
|
|
||||||
kind: "grid",
|
|
||||||
dir: "grid",
|
|
||||||
gap: 14,
|
|
||||||
cols: 3,
|
|
||||||
})
|
|
||||||
expect(readIntMarker(grid, "dai_cols")).toBe(3)
|
|
||||||
|
|
||||||
const group = stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 14,
|
|
||||||
cols: 3,
|
|
||||||
})
|
|
||||||
expect(readIntMarker(group, "dai_cols")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rounds a fractional gap", () => {
|
|
||||||
const s = stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 21.6,
|
|
||||||
})
|
|
||||||
expect(readIntMarker(s, "dai_gap")).toBe(22)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds the missing separator when a style does not end in ;", () => {
|
|
||||||
const s = stampContainer("rounded=0;fillColor=#FFF", {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 10,
|
|
||||||
})
|
|
||||||
expect(s).not.toContain("#FFFcontainer")
|
|
||||||
expect(s).toContain("#FFF;container=1;")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("readMarker duplicate handling", () => {
|
|
||||||
it("returns the LAST value, mirroring how draw.io resolves duplicate keys", () => {
|
|
||||||
// Verified in-browser: container=0;...;container=1; behaves as a container.
|
|
||||||
expect(readMarker("a=1;dai_dir=row;b=2;dai_dir=col;", "dai_dir")).toBe(
|
|
||||||
"col",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not match a key that is only a suffix of another key", () => {
|
|
||||||
expect(readMarker("xdai_dir=row;", "dai_dir")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns null for an absent key", () => {
|
|
||||||
expect(readMarker(VPC_STYLE, "dai_dir")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads a marker at the very start of the style", () => {
|
|
||||||
expect(readMarker("dai_kind=box;rounded=0;", "dai_kind")).toBe("box")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("readKind / readDir reject unknown values", () => {
|
|
||||||
it("rejects a kind that is not in the union", () => {
|
|
||||||
expect(readKind("dai_kind=wormhole;")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a direction that is not in the union", () => {
|
|
||||||
expect(readDir("dai_dir=diagonal;")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("readIntMarker", () => {
|
|
||||||
it("rejects a non-numeric value rather than returning NaN", () => {
|
|
||||||
expect(readIntMarker("dai_gap=wide;", "dai_gap")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a negative value", () => {
|
|
||||||
expect(readIntMarker("dai_gap=-5;", "dai_gap")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts zero", () => {
|
|
||||||
expect(readIntMarker("dai_gap=0;", "dai_gap")).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("isPinned", () => {
|
|
||||||
it("is false when the marker is absent", () => {
|
|
||||||
expect(isPinned(VPC_STYLE)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("is true for the value the engine writes", () => {
|
|
||||||
expect(isPinned("dai_pin=1;")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts what a user might hand-type in draw.io's Edit Style dialog", () => {
|
|
||||||
expect(isPinned("dai_pin=yes;")).toBe(true)
|
|
||||||
expect(isPinned("dai_pin=true;")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats 0 / false / empty as not pinned, so a user can unpin by editing", () => {
|
|
||||||
expect(isPinned("dai_pin=0;")).toBe(false)
|
|
||||||
expect(isPinned("dai_pin=false;")).toBe(false)
|
|
||||||
expect(isPinned("dai_pin=;")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("stampLeaf", () => {
|
|
||||||
it("marks an icon", () => {
|
|
||||||
expect(readKind(stampLeaf("shape=mxgraph.aws4.ec2;", "icon"))).toBe(
|
|
||||||
"icon",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("marks a box", () => {
|
|
||||||
expect(readKind(stampLeaf("rounded=0;", "box"))).toBe("box")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("stripMarkers", () => {
|
|
||||||
it("removes every dai_ token and keeps the rest intact", () => {
|
|
||||||
const stamped = stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 22,
|
|
||||||
})
|
|
||||||
const stripped = stripMarkers(stamped)
|
|
||||||
expect(stripped).not.toContain("dai_")
|
|
||||||
// the real style tokens survive
|
|
||||||
expect(stripped).toContain("grIcon=mxgraph.aws4.group_vpc")
|
|
||||||
expect(stripped).toContain("container=1")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves a marker-free style semantically unchanged", () => {
|
|
||||||
expect(stripMarkers(VPC_STYLE)).toBe(VPC_STYLE)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("hasMarkers", () => {
|
|
||||||
it("is false for a plain catalog style", () => {
|
|
||||||
expect(hasMarkers(VPC_STYLE)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("is true for engine output", () => {
|
|
||||||
expect(
|
|
||||||
hasMarkers(
|
|
||||||
stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 8,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("is not fooled by a key that merely contains dai_", () => {
|
|
||||||
expect(hasMarkers("mydai_dir=row;")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pool, sequence and radial markers", () => {
|
|
||||||
it("records a pool's lanes, phases and orientation", () => {
|
|
||||||
const s = stampPool("", {
|
|
||||||
lanes: ["Employee", "Manager"],
|
|
||||||
phases: ["Submit", "Pay"],
|
|
||||||
orientation: "horizontal",
|
|
||||||
gap: 40,
|
|
||||||
})
|
|
||||||
expect(readKind(s)).toBe("pool")
|
|
||||||
expect(readList(s, MARKER.lanes)).toEqual(["Employee", "Manager"])
|
|
||||||
expect(readList(s, MARKER.phases)).toEqual(["Submit", "Pay"])
|
|
||||||
expect(readMarker(s, MARKER.orient)).toBe("h")
|
|
||||||
expect(readIntMarker(s, MARKER.gap)).toBe(40)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("marks a vertical pool", () => {
|
|
||||||
const s = stampPool("", {
|
|
||||||
lanes: ["A"],
|
|
||||||
phases: [],
|
|
||||||
orientation: "vertical",
|
|
||||||
gap: 30,
|
|
||||||
})
|
|
||||||
expect(readMarker(s, MARKER.orient)).toBe("v")
|
|
||||||
expect(readList(s, MARKER.phases)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives a lane name holding a semicolon or an equals sign", () => {
|
|
||||||
// Both delimit a draw.io style string, so an unencoded label would break the cell.
|
|
||||||
const s = stampPool("", {
|
|
||||||
lanes: ["a;b", "c=d", "e\tf"],
|
|
||||||
phases: [],
|
|
||||||
orientation: "horizontal",
|
|
||||||
gap: 10,
|
|
||||||
})
|
|
||||||
expect(readList(s, MARKER.lanes)).toEqual(["a;b", "c=d", "e\tf"])
|
|
||||||
// The style itself must still be one flat token list.
|
|
||||||
expect(s.split(";").filter((t) => t.includes("dai_lanes")).length).toBe(
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not confuse an empty lane list with no marker at all", () => {
|
|
||||||
const s = stampPool("", {
|
|
||||||
lanes: [],
|
|
||||||
phases: [],
|
|
||||||
orientation: "horizontal",
|
|
||||||
gap: 10,
|
|
||||||
})
|
|
||||||
expect(readList(s, MARKER.lanes)).toEqual([])
|
|
||||||
expect(readList(s, "dai_absent")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("records a sequence's participant and message spacing", () => {
|
|
||||||
const s = stampSequence("", { gap: 60, step: 44 })
|
|
||||||
expect(readKind(s)).toBe("sequence")
|
|
||||||
expect(readIntMarker(s, MARKER.gap)).toBe(60)
|
|
||||||
expect(readIntMarker(s, MARKER.step)).toBe(44)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("records how a radial container spreads its branches", () => {
|
|
||||||
expect(
|
|
||||||
readMarker(
|
|
||||||
stampRadial("", { spread: "down", gap: 40 }),
|
|
||||||
MARKER.spread,
|
|
||||||
),
|
|
||||||
).toBe("down")
|
|
||||||
expect(
|
|
||||||
readMarker(
|
|
||||||
stampRadial("", { spread: "radial", gap: 40 }),
|
|
||||||
MARKER.spread,
|
|
||||||
),
|
|
||||||
).toBe("radial")
|
|
||||||
expect(readKind(stampRadial("", { spread: "down", gap: 40 }))).toBe(
|
|
||||||
"radial",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("records which pool cell a node sits in", () => {
|
|
||||||
expect(readCell(stampCell("", { lane: 2, col: 5 }))).toEqual({
|
|
||||||
lane: 2,
|
|
||||||
col: 5,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clamps a negative cell index rather than writing it", () => {
|
|
||||||
expect(readCell(stampCell("", { lane: -1, col: -3 }))).toEqual({
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads no cell from a style that has none, or a malformed one", () => {
|
|
||||||
expect(readCell(VPC_STYLE)).toBeNull()
|
|
||||||
expect(readCell("dai_cell=notacell;")).toBeNull()
|
|
||||||
expect(readCell("dai_cell=1;")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("makes a lane band a container so a dragged step reparents into it", () => {
|
|
||||||
const s = stampLane("", 1)
|
|
||||||
expect(s).toContain("container=1")
|
|
||||||
expect(isLaneChrome(s)).toBe(true)
|
|
||||||
expect(readIntMarker(s, MARKER.lane)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("marks a pool's label strip as chrome that claims no lane", () => {
|
|
||||||
const s = stampPoolDecoration("")
|
|
||||||
expect(isLaneChrome(s)).toBe(true)
|
|
||||||
expect(readIntMarker(s, MARKER.lane)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not mistake an ordinary cell for pool chrome", () => {
|
|
||||||
expect(isLaneChrome(VPC_STYLE)).toBe(false)
|
|
||||||
expect(
|
|
||||||
isLaneChrome(
|
|
||||||
stampContainer(VPC_STYLE, {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 8,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import {
|
|
||||||
applyOperations,
|
|
||||||
collectNames,
|
|
||||||
type Operation,
|
|
||||||
OperationSchema,
|
|
||||||
outline,
|
|
||||||
} from "@/lib/diagram-engine/operations"
|
|
||||||
import {
|
|
||||||
type ContainerNode,
|
|
||||||
type DiagramNode,
|
|
||||||
type DiagramTree,
|
|
||||||
findNode,
|
|
||||||
findParent,
|
|
||||||
type GroupNode,
|
|
||||||
walkTree,
|
|
||||||
} from "@/lib/diagram-engine/types"
|
|
||||||
|
|
||||||
const empty = (): DiagramTree => ({ roots: [], links: [], foreign: [] })
|
|
||||||
|
|
||||||
/** A small starting diagram: one frame holding two icons. */
|
|
||||||
function base(): DiagramTree {
|
|
||||||
return {
|
|
||||||
roots: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "vpc",
|
|
||||||
gname: "group_vpc",
|
|
||||||
label: "VPC",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
kind: "icon",
|
|
||||||
id: "alb",
|
|
||||||
name: "application_load_balancer",
|
|
||||||
label: "ALB",
|
|
||||||
},
|
|
||||||
{ kind: "icon", id: "ec2", name: "ec2", label: "EC2" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [{ source: "alb", target: "ec2" }],
|
|
||||||
foreign: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apply = (t: DiagramTree, ...ops: Operation[]) => applyOperations(t, ops)
|
|
||||||
|
|
||||||
describe("add operations", () => {
|
|
||||||
it("adds an icon into a container", () => {
|
|
||||||
const { tree, errors } = apply(base(), {
|
|
||||||
op: "add_icon",
|
|
||||||
id: "rds",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "rds",
|
|
||||||
label: "RDS",
|
|
||||||
})
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(findParent(tree, "rds")?.id).toBe("vpc")
|
|
||||||
expect((findNode(tree, "rds") as { name: string }).name).toBe("rds")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds at the top level when no parent is given", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "add_box",
|
|
||||||
id: "users",
|
|
||||||
label: "Users",
|
|
||||||
})
|
|
||||||
expect(tree.roots.map((r) => r.id)).toContain("users")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("inserts after a named sibling", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "add_icon",
|
|
||||||
id: "mid",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "s3",
|
|
||||||
after: "alb",
|
|
||||||
})
|
|
||||||
const kids = (findNode(tree, "vpc") as ContainerNode).children.map(
|
|
||||||
(c) => c.id,
|
|
||||||
)
|
|
||||||
expect(kids).toEqual(["alb", "mid", "ec2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("appends when the named sibling does not exist", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "add_icon",
|
|
||||||
id: "last",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "s3",
|
|
||||||
after: "ghost",
|
|
||||||
})
|
|
||||||
const kids = (findNode(tree, "vpc") as ContainerNode).children.map(
|
|
||||||
(c) => c.id,
|
|
||||||
)
|
|
||||||
expect(kids[kids.length - 1]).toBe("last")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds a container and lets a later op fill it, in one batch", () => {
|
|
||||||
const { tree, errors } = apply(
|
|
||||||
base(),
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "subnet",
|
|
||||||
parent: "vpc",
|
|
||||||
label: "Private Subnet",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_subnet",
|
|
||||||
},
|
|
||||||
{ op: "move", id: "ec2", parent: "subnet" },
|
|
||||||
)
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(findParent(tree, "ec2")?.id).toBe("subnet")
|
|
||||||
expect(findParent(tree, "subnet")?.id).toBe("vpc")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds a grid with its column count", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "add_grid",
|
|
||||||
id: "area",
|
|
||||||
parent: "vpc",
|
|
||||||
label: "Services",
|
|
||||||
cols: 3,
|
|
||||||
})
|
|
||||||
const g = findNode(tree, "area")
|
|
||||||
expect(g?.kind).toBe("grid")
|
|
||||||
expect((g as { cols: number }).cols).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clamps a nonsensical column count instead of producing a broken grid", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "add_grid",
|
|
||||||
id: "area",
|
|
||||||
label: "X",
|
|
||||||
cols: 0,
|
|
||||||
})
|
|
||||||
expect((findNode(tree, "area") as { cols: number }).cols).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a duplicate id — draw.io silently drops one of two cells sharing an id", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "add_icon",
|
|
||||||
id: "ec2",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "s3",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("already taken")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects adding into something that is not a container", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "add_icon",
|
|
||||||
id: "x",
|
|
||||||
parent: "ec2",
|
|
||||||
name: "s3",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("not a container")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects adding into a nonexistent parent", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "add_icon",
|
|
||||||
id: "x",
|
|
||||||
parent: "ghost",
|
|
||||||
name: "s3",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("ghost")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("remove", () => {
|
|
||||||
it("removes a leaf", () => {
|
|
||||||
const { tree, errors } = apply(base(), { op: "remove", id: "ec2" })
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(findNode(tree, "ec2")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("removes a container together with its descendants", () => {
|
|
||||||
const { tree } = apply(base(), { op: "remove", id: "vpc" })
|
|
||||||
expect(findNode(tree, "vpc")).toBeNull()
|
|
||||||
expect(findNode(tree, "alb")).toBeNull()
|
|
||||||
expect(findNode(tree, "ec2")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("removes edges that touched the deleted subtree", () => {
|
|
||||||
// Leaving them would render as an arrow pointing at nothing.
|
|
||||||
const { tree } = apply(base(), { op: "remove", id: "ec2" })
|
|
||||||
expect(tree.links).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("removes edges anchored deep inside a removed container", () => {
|
|
||||||
const t = base()
|
|
||||||
t.links.push({ source: "alb", target: "alb" })
|
|
||||||
const { tree } = apply(t, { op: "remove", id: "vpc" })
|
|
||||||
expect(tree.links).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports a removal of something that is not there", () => {
|
|
||||||
const { errors } = apply(base(), { op: "remove", id: "ghost" })
|
|
||||||
expect(errors[0]).toContain("ghost")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("move", () => {
|
|
||||||
it("moves a node between containers", () => {
|
|
||||||
const t = base()
|
|
||||||
;(t.roots[0] as GroupNode).children.push({
|
|
||||||
kind: "group",
|
|
||||||
id: "other",
|
|
||||||
gname: null,
|
|
||||||
label: "Other",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [],
|
|
||||||
})
|
|
||||||
const { tree, errors } = apply(t, {
|
|
||||||
op: "move",
|
|
||||||
id: "ec2",
|
|
||||||
parent: "other",
|
|
||||||
})
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(findParent(tree, "ec2")?.id).toBe("other")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("moves a node to the top level", () => {
|
|
||||||
const { tree } = apply(base(), { op: "move", id: "ec2" })
|
|
||||||
expect(tree.roots.map((r) => r.id)).toContain("ec2")
|
|
||||||
expect(findParent(tree, "ec2")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reorders within the same container", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "move",
|
|
||||||
id: "alb",
|
|
||||||
parent: "vpc",
|
|
||||||
after: "ec2",
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
(findNode(tree, "vpc") as ContainerNode).children.map((c) => c.id),
|
|
||||||
).toEqual(["ec2", "alb"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the moved node's own children with it", () => {
|
|
||||||
const t: DiagramTree = {
|
|
||||||
roots: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "a",
|
|
||||||
gname: null,
|
|
||||||
label: "A",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "sub",
|
|
||||||
gname: null,
|
|
||||||
label: "Sub",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
kind: "icon",
|
|
||||||
id: "leaf",
|
|
||||||
name: "s3",
|
|
||||||
label: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "b",
|
|
||||||
gname: null,
|
|
||||||
label: "B",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [],
|
|
||||||
foreign: [],
|
|
||||||
}
|
|
||||||
const { tree } = apply(t, { op: "move", id: "sub", parent: "b" })
|
|
||||||
expect(findParent(tree, "sub")?.id).toBe("b")
|
|
||||||
expect(findParent(tree, "leaf")?.id).toBe("sub")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses to move a container into itself", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "move",
|
|
||||||
id: "vpc",
|
|
||||||
parent: "vpc",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("inside itself")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses to move a container into its own descendant", () => {
|
|
||||||
const { errors, tree } = apply(base(), {
|
|
||||||
op: "move",
|
|
||||||
id: "vpc",
|
|
||||||
parent: "ec2",
|
|
||||||
})
|
|
||||||
// ec2 is a leaf, so this is caught as "not a container" — either way the tree
|
|
||||||
// must survive intact rather than losing the subtree into a cycle.
|
|
||||||
expect(errors).toHaveLength(1)
|
|
||||||
expect(findNode(tree, "vpc")).not.toBeNull()
|
|
||||||
expect(findNode(tree, "ec2")).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses to move a container into a nested descendant container", () => {
|
|
||||||
const t: DiagramTree = {
|
|
||||||
roots: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "outer",
|
|
||||||
gname: null,
|
|
||||||
label: "O",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "inner",
|
|
||||||
gname: null,
|
|
||||||
label: "I",
|
|
||||||
dir: "col",
|
|
||||||
gap: 20,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [],
|
|
||||||
foreign: [],
|
|
||||||
}
|
|
||||||
const { errors, tree } = apply(t, {
|
|
||||||
op: "move",
|
|
||||||
id: "outer",
|
|
||||||
parent: "inner",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("inside itself")
|
|
||||||
expect(findNode(tree, "outer")).not.toBeNull()
|
|
||||||
expect(findParent(tree, "inner")?.id).toBe("outer")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports a move of something that is not there", () => {
|
|
||||||
const { errors } = apply(base(), { op: "move", id: "ghost" })
|
|
||||||
expect(errors[0]).toContain("ghost")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("property setters", () => {
|
|
||||||
it("renames a node", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "set_label",
|
|
||||||
id: "vpc",
|
|
||||||
label: "Production VPC",
|
|
||||||
})
|
|
||||||
expect((findNode(tree, "vpc") as ContainerNode).label).toBe(
|
|
||||||
"Production VPC",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-orients a container", () => {
|
|
||||||
const { tree } = apply(base(), { op: "set_dir", id: "vpc", dir: "row" })
|
|
||||||
expect((findNode(tree, "vpc") as GroupNode).dir).toBe("row")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses set_dir on a grid, whose layout is driven by its column count", () => {
|
|
||||||
const t = apply(base(), {
|
|
||||||
op: "add_grid",
|
|
||||||
id: "g",
|
|
||||||
label: "G",
|
|
||||||
cols: 2,
|
|
||||||
}).tree
|
|
||||||
const { errors } = apply(t, { op: "set_dir", id: "g", dir: "row" })
|
|
||||||
expect(errors[0]).toContain("grid")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses set_dir on a leaf", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "set_dir",
|
|
||||||
id: "ec2",
|
|
||||||
dir: "row",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("not a container")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("changes a gap and refuses a negative one", () => {
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
apply(base(), { op: "set_gap", id: "vpc", gap: 40 }).tree
|
|
||||||
.roots[0] as GroupNode
|
|
||||||
).gap,
|
|
||||||
).toBe(40)
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
apply(base(), { op: "set_gap", id: "vpc", gap: -10 }).tree
|
|
||||||
.roots[0] as GroupNode
|
|
||||||
).gap,
|
|
||||||
).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("sets the page title", () => {
|
|
||||||
const { tree } = apply(base(), { op: "set_title", title: "My Diagram" })
|
|
||||||
expect(tree.title).toBe("My Diagram")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("links", () => {
|
|
||||||
it("adds an edge", () => {
|
|
||||||
const { tree, errors } = apply(base(), {
|
|
||||||
op: "link",
|
|
||||||
source: "ec2",
|
|
||||||
target: "alb",
|
|
||||||
label: "response",
|
|
||||||
})
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(tree.links).toHaveLength(2)
|
|
||||||
expect(tree.links[1].label).toBe("response")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("carries dashed and step through", () => {
|
|
||||||
const { tree } = apply(base(), {
|
|
||||||
op: "link",
|
|
||||||
source: "ec2",
|
|
||||||
target: "alb",
|
|
||||||
dashed: true,
|
|
||||||
step: 3,
|
|
||||||
})
|
|
||||||
const l = tree.links[1]
|
|
||||||
expect(l.dashed).toBe(true)
|
|
||||||
expect(l.step).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses an edge to a node that does not exist", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "link",
|
|
||||||
source: "ec2",
|
|
||||||
target: "ghost",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("ghost")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses a duplicate edge instead of drawing two arrows on top of each other", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "link",
|
|
||||||
source: "alb",
|
|
||||||
target: "ec2",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("already exists")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("removes an edge", () => {
|
|
||||||
const { tree, errors } = apply(base(), {
|
|
||||||
op: "unlink",
|
|
||||||
source: "alb",
|
|
||||||
target: "ec2",
|
|
||||||
})
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(tree.links).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports unlinking an edge that is not there", () => {
|
|
||||||
const { errors } = apply(base(), {
|
|
||||||
op: "unlink",
|
|
||||||
source: "ec2",
|
|
||||||
target: "alb",
|
|
||||||
})
|
|
||||||
expect(errors[0]).toContain("no edge")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("batch semantics", () => {
|
|
||||||
it("does not mutate the input tree", () => {
|
|
||||||
const original = base()
|
|
||||||
const snapshot = JSON.stringify(original)
|
|
||||||
apply(original, { op: "remove", id: "ec2" })
|
|
||||||
expect(JSON.stringify(original)).toBe(snapshot)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("applies later ops against the result of earlier ones", () => {
|
|
||||||
const { tree, errors } = apply(
|
|
||||||
base(),
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "az",
|
|
||||||
parent: "vpc",
|
|
||||||
label: "AZ",
|
|
||||||
dir: "col",
|
|
||||||
},
|
|
||||||
{ op: "add_icon", id: "nat", parent: "az", name: "nat_gateway" },
|
|
||||||
{ op: "link", source: "nat", target: "ec2" },
|
|
||||||
)
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(findParent(tree, "nat")?.id).toBe("az")
|
|
||||||
expect(tree.links).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps going after a failed op and reports each failure", () => {
|
|
||||||
const { tree, errors } = apply(
|
|
||||||
base(),
|
|
||||||
{ op: "remove", id: "ghost" },
|
|
||||||
{ op: "add_icon", id: "s3", parent: "vpc", name: "s3" },
|
|
||||||
{ op: "set_dir", id: "ec2", dir: "row" },
|
|
||||||
)
|
|
||||||
expect(errors).toHaveLength(2)
|
|
||||||
// the valid op in the middle still took effect
|
|
||||||
expect(findNode(tree, "s3")).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("builds a whole diagram from an empty canvas", () => {
|
|
||||||
const { tree, errors } = apply(
|
|
||||||
empty(),
|
|
||||||
{ op: "set_title", title: "Three Tier" },
|
|
||||||
{ op: "add_box", id: "users", label: "Users" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "region",
|
|
||||||
label: "Region",
|
|
||||||
dir: "row",
|
|
||||||
gname: "group_region",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "vpc",
|
|
||||||
parent: "region",
|
|
||||||
label: "VPC",
|
|
||||||
dir: "col",
|
|
||||||
gname: "group_vpc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "alb",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "application_load_balancer",
|
|
||||||
label: "ALB",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_icon",
|
|
||||||
id: "ec2",
|
|
||||||
parent: "vpc",
|
|
||||||
name: "ec2",
|
|
||||||
label: "EC2",
|
|
||||||
},
|
|
||||||
{ op: "link", source: "users", target: "alb", step: 1 },
|
|
||||||
{ op: "link", source: "alb", target: "ec2", step: 2 },
|
|
||||||
)
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(tree.title).toBe("Three Tier")
|
|
||||||
expect(tree.roots.map((r) => r.id)).toEqual(["users", "region"])
|
|
||||||
expect(findParent(tree, "vpc")?.id).toBe("region")
|
|
||||||
expect(tree.links).toHaveLength(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("OperationSchema", () => {
|
|
||||||
it("accepts a well-formed operation", () => {
|
|
||||||
expect(
|
|
||||||
OperationSchema.safeParse({
|
|
||||||
op: "add_icon",
|
|
||||||
id: "a",
|
|
||||||
name: "s3",
|
|
||||||
}).success,
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects an unknown op name", () => {
|
|
||||||
expect(
|
|
||||||
OperationSchema.safeParse({ op: "teleport", id: "a" }).success,
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a missing required field", () => {
|
|
||||||
expect(
|
|
||||||
OperationSchema.safeParse({ op: "add_icon", id: "a" }).success,
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a direction outside the union", () => {
|
|
||||||
expect(
|
|
||||||
OperationSchema.safeParse({
|
|
||||||
op: "set_dir",
|
|
||||||
id: "a",
|
|
||||||
dir: "diagonal",
|
|
||||||
}).success,
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("collectNames", () => {
|
|
||||||
it("returns every icon and group name for catalog checking", () => {
|
|
||||||
const names = collectNames(base())
|
|
||||||
expect(names).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
{ id: "vpc", name: "group_vpc", kind: "group" },
|
|
||||||
{ id: "alb", name: "application_load_balancer", kind: "icon" },
|
|
||||||
{ id: "ec2", name: "ec2", kind: "icon" },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("skips a plain frame, which has no stencil to check", () => {
|
|
||||||
const t = apply(base(), {
|
|
||||||
op: "add_container",
|
|
||||||
id: "plain",
|
|
||||||
label: "Plain",
|
|
||||||
dir: "row",
|
|
||||||
}).tree
|
|
||||||
expect(collectNames(t).map((n) => n.id)).not.toContain("plain")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("skips a box", () => {
|
|
||||||
const t = apply(base(), { op: "add_box", id: "b", label: "B" }).tree
|
|
||||||
expect(collectNames(t).map((n) => n.id)).not.toContain("b")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("outline", () => {
|
|
||||||
it("shows nesting, kinds and links compactly", () => {
|
|
||||||
const text = outline(base())
|
|
||||||
expect(text).toContain("vpc: col")
|
|
||||||
expect(text).toContain("alb: icon application_load_balancer")
|
|
||||||
expect(text).toContain("link alb -> ec2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("includes the title", () => {
|
|
||||||
const t = apply(base(), { op: "set_title", title: "T" }).tree
|
|
||||||
expect(outline(t)).toContain("title: T")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("marks an unlabelled container as a wrapper so its purpose is clear", () => {
|
|
||||||
const t = apply(base(), {
|
|
||||||
op: "add_container",
|
|
||||||
id: "w",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
}).tree
|
|
||||||
expect(outline(t)).toContain("w: row (wrapper)")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("notes cells kept verbatim, so the model knows they exist but are not its to edit", () => {
|
|
||||||
const t = base()
|
|
||||||
t.foreign.push({ id: "note", xml: '<mxCell id="note"/>', parent: "1" })
|
|
||||||
expect(outline(t)).toContain("1 cell(s) kept as-is: note")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("is far more compact than the JSON tree", () => {
|
|
||||||
const t = base()
|
|
||||||
expect(outline(t).length).toBeLessThan(JSON.stringify(t).length / 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("shows every node exactly once", () => {
|
|
||||||
const t = base()
|
|
||||||
const text = outline(t)
|
|
||||||
for (const n of walkTree(t)) {
|
|
||||||
const hits = text.split("\n").filter((l) => l.includes(`${n.id}:`))
|
|
||||||
expect(hits).toHaveLength(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { stampContainer } from "@/lib/diagram-engine/markers"
|
|
||||||
import {
|
|
||||||
countPages,
|
|
||||||
extractPage,
|
|
||||||
parseDiagram,
|
|
||||||
} from "@/lib/diagram-engine/parse"
|
|
||||||
import {
|
|
||||||
type ContainerNode,
|
|
||||||
type DiagramNode,
|
|
||||||
findNode,
|
|
||||||
findParent,
|
|
||||||
type GroupNode,
|
|
||||||
isContainer,
|
|
||||||
walkTree,
|
|
||||||
} from "@/lib/diagram-engine/types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Real output from drawio-ai-kit's examples/aws/build_vpc.mjs — the exact XML shape the
|
|
||||||
* parser has to handle. Structure declared by that script:
|
|
||||||
*
|
|
||||||
* root (phantom, row)
|
|
||||||
* ├── users (box)
|
|
||||||
* └── region (group_region, row)
|
|
||||||
* ├── vpc (group_vpc, col)
|
|
||||||
* │ ├── igw, alb (icons)
|
|
||||||
* │ └── azs (phantom, row)
|
|
||||||
* │ ├── az_a (group_availability_zone, col) → pub_a/app_a/db_a → nat_a/ec2_a/rds_a
|
|
||||||
* │ └── az_b (same, mirrored)
|
|
||||||
* └── reg_svc (plain frame, col) → waf, cw, s3
|
|
||||||
*
|
|
||||||
* The two phantoms emit no cell, so their children are reparented to the nearest
|
|
||||||
* visible ancestor: users/region become roots, az_a/az_b become children of vpc.
|
|
||||||
*/
|
|
||||||
const ENGINE_XML = readFileSync(
|
|
||||||
join(__dirname, "fixtures/engine-vpc-multiaz.drawio"),
|
|
||||||
"utf8",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Minimal page, built by hand so each test controls exactly one variable. */
|
|
||||||
function page(cells: string): string {
|
|
||||||
return `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="p1"><mxGraphModel dx="1400" dy="900" grid="0" pageWidth="1200" pageHeight="800"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
|
||||||
}
|
|
||||||
function vtx(
|
|
||||||
id: string,
|
|
||||||
style: string,
|
|
||||||
geo: { x: number; y: number; w: number; h: number },
|
|
||||||
opts: { parent?: string; value?: string } = {},
|
|
||||||
): string {
|
|
||||||
return `<mxCell id="${id}" value="${opts.value ?? ""}" style="${style}" vertex="1" parent="${opts.parent ?? "1"}"><mxGeometry x="${geo.x}" y="${geo.y}" width="${geo.w}" height="${geo.h}" as="geometry"/></mxCell>`
|
|
||||||
}
|
|
||||||
const AWS_GROUP = (name: string) =>
|
|
||||||
`sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};strokeColor=#879196;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;`
|
|
||||||
const RES_ICON = (name: string) =>
|
|
||||||
`sketch=0;outlineConnect=0;fillColor=#ED7100;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;aspect=fixed;shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.${name};`
|
|
||||||
const OWN_SHAPE_ICON = (name: string) =>
|
|
||||||
`sketch=0;outlineConnect=0;fillColor=#ED7100;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;html=1;aspect=fixed;shape=mxgraph.aws4.${name};`
|
|
||||||
const IMAGE_ICON =
|
|
||||||
"sketch=0;html=1;outlineConnect=0;verticalLabelPosition=bottom;verticalAlign=top;align=center;fontColor=#232F3E;aspect=fixed;shape=image;image=data:image/png,iVBORw0KGgoAAAANSUhEUg==;"
|
|
||||||
const PLAIN_BOX =
|
|
||||||
"rounded=0;whiteSpace=wrap;html=1;fillColor=#DAE8FC;strokeColor=#6C8EBF;"
|
|
||||||
|
|
||||||
describe("extractPage", () => {
|
|
||||||
it("pulls the model body out of an mxfile", () => {
|
|
||||||
const p = extractPage(ENGINE_XML)
|
|
||||||
expect(p).toContain("<mxCell")
|
|
||||||
expect(p).not.toContain("<mxfile")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts a bare mxGraphModel", () => {
|
|
||||||
const bare = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
|
||||||
expect(extractPage(bare)).toContain("<mxCell")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns null for a compressed page rather than pretending it is empty", () => {
|
|
||||||
const compressed = `<mxfile><diagram name="Page-1" id="p1">7VvbcuI4EP0aHmfL8oXLY0gyu1O1t</diagram></mxfile>`
|
|
||||||
expect(extractPage(compressed)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("selects the requested page and clamps an out-of-range index", () => {
|
|
||||||
const two = `<mxfile><diagram name="A" id="a"><mxGraphModel><root><mxCell id="0"/><mxCell id="onlyA"/></root></mxGraphModel></diagram><diagram name="B" id="b"><mxGraphModel><root><mxCell id="0"/><mxCell id="onlyB"/></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
expect(extractPage(two, 0)).toContain("onlyA")
|
|
||||||
expect(extractPage(two, 1)).toContain("onlyB")
|
|
||||||
expect(extractPage(two, 99)).toContain("onlyB")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("countPages", () => {
|
|
||||||
it("counts a multi-page deck", () => {
|
|
||||||
const two = `<mxfile><diagram name="A" id="a"></diagram><diagram name="B" id="b"></diagram></mxfile>`
|
|
||||||
expect(countPages(two)).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports 1 for a single page", () => {
|
|
||||||
expect(countPages(ENGINE_XML)).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("parseDiagram on real engine output", () => {
|
|
||||||
const { tree, needsAdoption, warnings } = parseDiagram(ENGINE_XML)
|
|
||||||
|
|
||||||
it("recovers the nesting the build script declared", () => {
|
|
||||||
// az_a is a child of vpc (the `azs` phantom emitted no cell)
|
|
||||||
expect(findParent(tree, "az_a")?.id).toBe("vpc")
|
|
||||||
expect(findParent(tree, "vpc")?.id).toBe("region")
|
|
||||||
expect(findParent(tree, "ec2_a")?.id).toBe("app_a")
|
|
||||||
expect(findParent(tree, "app_a")?.id).toBe("az_a")
|
|
||||||
expect(findParent(tree, "rds_b")?.id).toBe("db_b")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts users and region at the top level", () => {
|
|
||||||
const rootIds = tree.roots.map((r) => r.id).sort()
|
|
||||||
expect(rootIds).toEqual(["region", "users"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("classifies AWS group stencils as containers and keeps their stencil name", () => {
|
|
||||||
const vpc = findNode(tree, "vpc")
|
|
||||||
expect(isContainer(vpc as DiagramNode)).toBe(true)
|
|
||||||
expect((vpc as GroupNode).gname).toBe("group_vpc")
|
|
||||||
expect((findNode(tree, "az_a") as GroupNode).gname).toBe(
|
|
||||||
"group_availability_zone",
|
|
||||||
)
|
|
||||||
expect((findNode(tree, "pub_a") as GroupNode).gname).toBe(
|
|
||||||
"group_subnet",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("cannot recover a direction the phantom erased — the case that rules phantoms out", () => {
|
|
||||||
// The build script declared vpc as dir:"col" holding igw, alb, and a phantom
|
|
||||||
// wrapping the two AZ columns. A phantom emits NO cell, so its children were
|
|
||||||
// reparented onto vpc: igw(260,60) alb(260,164) az_a(24,268) az_b(309,268).
|
|
||||||
// That is a 2-D arrangement, so inference correctly reads "grid" — the original
|
|
||||||
// column structure is simply not in the XML any more.
|
|
||||||
//
|
|
||||||
// This is why our engine must not have phantoms: a wrapper that emits no cell
|
|
||||||
// makes the round-trip lossy by construction. See task #5.
|
|
||||||
//
|
|
||||||
// The parser does not guess a direction here. It keeps the container and warns
|
|
||||||
// that the arrangement is two-dimensional, so the caller knows a re-layout will
|
|
||||||
// move these children rather than discovering it afterwards.
|
|
||||||
expect(findParent(tree, "az_a")?.id).toBe("vpc")
|
|
||||||
expect(findNode(tree, "azs")).toBeNull()
|
|
||||||
expect(
|
|
||||||
warnings.some(
|
|
||||||
(w) => w.includes("vpc") && w.includes("two dimensions"),
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("classifies resourceIcon cells as icons and recovers their catalog name", () => {
|
|
||||||
const ec2 = findNode(tree, "ec2_a")
|
|
||||||
expect(ec2?.kind).toBe("icon")
|
|
||||||
expect(ec2 && "name" in ec2 ? ec2.name : null).toBe("ec2")
|
|
||||||
const nat = findNode(tree, "nat_a")
|
|
||||||
expect(nat && "name" in nat ? nat.name : null).toBe("nat_gateway")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("classifies a plain rectangle as a box", () => {
|
|
||||||
expect(findNode(tree, "users")?.kind).toBe("box")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("lifts the title out of the flow instead of leaving it as a node", () => {
|
|
||||||
expect(tree.title).toContain("VPC Multi-AZ 3-tier")
|
|
||||||
expect(findNode(tree, "__title")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers every edge with its endpoints", () => {
|
|
||||||
expect(tree.links).toHaveLength(7)
|
|
||||||
const pairs = tree.links.map((l) => `${l.source}->${l.target}`)
|
|
||||||
expect(pairs).toContain("users->igw")
|
|
||||||
expect(pairs).toContain("alb->ec2_a")
|
|
||||||
expect(pairs).toContain("rds_a->rds_b")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("splits a step number off the edge label", () => {
|
|
||||||
const first = tree.links.find(
|
|
||||||
(l) => l.source === "users" && l.target === "igw",
|
|
||||||
)
|
|
||||||
expect(first?.step).toBe(1)
|
|
||||||
expect(first?.label).toBe("HTTPS")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("marks a dashed edge", () => {
|
|
||||||
const repl = tree.links.find(
|
|
||||||
(l) => l.source === "rds_a" && l.target === "rds_b",
|
|
||||||
)
|
|
||||||
expect(repl?.dashed).toBe(true)
|
|
||||||
expect(repl?.label).toBe("Multi-AZ replication")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("flags the diagram as needing adoption — it carries no markers yet", () => {
|
|
||||||
// This fixture predates the marker scheme, so direction had to be inferred.
|
|
||||||
expect(needsAdoption).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("warns only about the phantom-flattened container, nothing else", () => {
|
|
||||||
// The single warning is the 2-D arrangement the phantom left behind; a
|
|
||||||
// well-formed page produces no other complaint.
|
|
||||||
expect(warnings).toHaveLength(1)
|
|
||||||
expect(warnings[0]).toContain("two dimensions")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("assigns every cell exactly once — no duplicates, nothing lost", () => {
|
|
||||||
const ids = [...walkTree(tree)].map((n) => n.id)
|
|
||||||
expect(new Set(ids).size).toBe(ids.length)
|
|
||||||
// 24 vertex cells in the fixture, minus __title which is lifted out of the flow
|
|
||||||
expect(ids).toHaveLength(23)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("layout inference (no markers present)", () => {
|
|
||||||
it("reads a row from children spread along x", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 400, h: 120 }) +
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 20, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
RES_ICON("ec2"),
|
|
||||||
{ x: 120, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"c",
|
|
||||||
RES_ICON("rds"),
|
|
||||||
{ x: 220, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
|
|
||||||
expect(f.kind).toBe("group")
|
|
||||||
expect((f as { dir?: string }).dir).toBe("row")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads a column from children spread along y", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 120, h: 400 }) +
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 30, y: 20, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
RES_ICON("ec2"),
|
|
||||||
{ x: 30, y: 120, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"c",
|
|
||||||
RES_ICON("rds"),
|
|
||||||
{ x: 30, y: 220, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
|
|
||||||
expect((f as { dir?: string }).dir).toBe("col")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recognises a 2-D arrangement as a grid rather than forcing row or column", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 300, h: 300 }) +
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 20, y: 20, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
RES_ICON("ec2"),
|
|
||||||
{ x: 140, y: 20, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"c",
|
|
||||||
RES_ICON("rds"),
|
|
||||||
{ x: 20, y: 140, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"d",
|
|
||||||
RES_ICON("sqs"),
|
|
||||||
{ x: 140, y: 140, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const f = findNode(parseDiagram(xml).tree, "f")
|
|
||||||
expect(f?.kind).toBe("grid")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("measures the gap between neighbours, not between their origins", () => {
|
|
||||||
// icons 48 wide at x=20,120,220 → edge-to-edge gap is 52
|
|
||||||
const xml = page(
|
|
||||||
vtx("f", AWS_GROUP("group_vpc"), { x: 0, y: 0, w: 400, h: 120 }) +
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 20, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
RES_ICON("ec2"),
|
|
||||||
{ x: 120, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"c",
|
|
||||||
RES_ICON("rds"),
|
|
||||||
{ x: 220, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
|
|
||||||
expect((f as { gap?: number }).gap).toBe(52)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("markers take precedence over inference", () => {
|
|
||||||
it("believes dai_dir even when the geometry suggests otherwise", () => {
|
|
||||||
// children are laid out in a row, but the marker says col
|
|
||||||
const marked = stampContainer(AWS_GROUP("group_vpc"), {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 33,
|
|
||||||
})
|
|
||||||
const xml = page(
|
|
||||||
vtx("f", marked, { x: 0, y: 0, w: 400, h: 120 }) +
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 20, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
RES_ICON("ec2"),
|
|
||||||
{ x: 120, y: 40, w: 48, h: 48 },
|
|
||||||
{ parent: "f" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const f = findNode(parseDiagram(xml).tree, "f") as ContainerNode
|
|
||||||
expect((f as { dir?: string }).dir).toBe("col")
|
|
||||||
expect((f as { gap?: number }).gap).toBe(33)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clears needsAdoption once any cell carries a marker", () => {
|
|
||||||
const marked = stampContainer(AWS_GROUP("group_vpc"), {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 20,
|
|
||||||
})
|
|
||||||
const xml = page(vtx("f", marked, { x: 0, y: 0, w: 200, h: 100 }))
|
|
||||||
expect(parseDiagram(xml).needsAdoption).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers grid columns from dai_cols", () => {
|
|
||||||
const marked = stampContainer("rounded=0;html=1;", {
|
|
||||||
kind: "grid",
|
|
||||||
dir: "grid",
|
|
||||||
gap: 14,
|
|
||||||
cols: 3,
|
|
||||||
})
|
|
||||||
const xml = page(
|
|
||||||
vtx("g", marked, { x: 0, y: 0, w: 300, h: 200 }) +
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 10, y: 10, w: 48, h: 48 },
|
|
||||||
{ parent: "g" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const g = findNode(parseDiagram(xml).tree, "g")
|
|
||||||
expect(g?.kind).toBe("grid")
|
|
||||||
expect((g as { cols?: number }).cols).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("carries a user pin through to the node", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("b", `${PLAIN_BOX}dai_kind=box;dai_pin=1;`, {
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
w: 120,
|
|
||||||
h: 60,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const b = findNode(parseDiagram(xml).tree, "b")
|
|
||||||
expect((b as { pinned?: boolean }).pinned).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("icon classification covers all four encodings", () => {
|
|
||||||
it("handles resIcon=, bare shape=, and shape=image", () => {
|
|
||||||
// 554 AWS icons use resIcon; 429 use their own shape=; Azure/GCP use shape=image.
|
|
||||||
const xml = page(
|
|
||||||
vtx("res", RES_ICON("ec2"), { x: 0, y: 0, w: 48, h: 48 }) +
|
|
||||||
vtx("own", OWN_SHAPE_ICON("a1_instance"), {
|
|
||||||
x: 100,
|
|
||||||
y: 0,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
}) +
|
|
||||||
vtx("img", IMAGE_ICON, { x: 200, y: 0, w: 48, h: 48 }),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(findNode(tree, "res")?.kind).toBe("icon")
|
|
||||||
expect(findNode(tree, "own")?.kind).toBe("icon")
|
|
||||||
expect(findNode(tree, "img")?.kind).toBe("icon")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers the catalog name from a bare shape= icon", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("own", OWN_SHAPE_ICON("a1_instance"), {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const n = findNode(parseDiagram(xml).tree, "own")
|
|
||||||
expect(n && "name" in n ? n.name : null).toBe("a1_instance")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves the name empty for an embedded-image icon instead of inventing one", () => {
|
|
||||||
const xml = page(vtx("img", IMAGE_ICON, { x: 0, y: 0, w: 48, h: 48 }))
|
|
||||||
const n = findNode(parseDiagram(xml).tree, "img")
|
|
||||||
expect(n && "name" in n ? n.name : null).toBe("")
|
|
||||||
// ...but the verbatim style is kept, so it can be re-emitted unchanged
|
|
||||||
expect(n && "style" in n ? n.style : "").toContain("data:image/png")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("nesting when a frame lacks container=1", () => {
|
|
||||||
it("re-homes a cell that visually sits inside a loose frame", () => {
|
|
||||||
// draw.io will NOT reparent into a frame without container=1 (verified
|
|
||||||
// in-browser), so the user's drop leaves parent="1" while the shape is
|
|
||||||
// visually inside. Geometry is the truth here.
|
|
||||||
const xml = page(
|
|
||||||
vtx("loose", AWS_GROUP("group_vpc"), {
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
w: 400,
|
|
||||||
h: 300,
|
|
||||||
}) +
|
|
||||||
vtx("inside", RES_ICON("ec2"), {
|
|
||||||
x: 200,
|
|
||||||
y: 200,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(findParent(tree, "inside")?.id).toBe("loose")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("picks the innermost frame when loose frames nest", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("outer", AWS_GROUP("group_region"), {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: 800,
|
|
||||||
h: 600,
|
|
||||||
}) +
|
|
||||||
vtx("inner", AWS_GROUP("group_vpc"), {
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
w: 300,
|
|
||||||
h: 200,
|
|
||||||
}) +
|
|
||||||
vtx("leaf", RES_ICON("ec2"), { x: 150, y: 150, w: 48, h: 48 }),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(findParent(tree, "leaf")?.id).toBe("inner")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves a cell alone when it is outside every loose frame", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("loose", AWS_GROUP("group_vpc"), {
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
w: 200,
|
|
||||||
h: 200,
|
|
||||||
}) +
|
|
||||||
vtx("outside", RES_ICON("ec2"), {
|
|
||||||
x: 600,
|
|
||||||
y: 600,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(findParent(tree, "outside")).toBeNull()
|
|
||||||
expect(tree.roots.map((r) => r.id)).toContain("outside")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does NOT override an explicit parent that draw.io maintained", () => {
|
|
||||||
// With container=1 the parent attribute is authoritative — even if a stale
|
|
||||||
// geometry would place the child inside a different frame.
|
|
||||||
const withContainer = stampContainer(AWS_GROUP("group_vpc"), {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 20,
|
|
||||||
})
|
|
||||||
const xml = page(
|
|
||||||
vtx("real", withContainer, { x: 500, y: 0, w: 300, h: 200 }) +
|
|
||||||
vtx("loose", AWS_GROUP("group_region"), {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: 400,
|
|
||||||
h: 400,
|
|
||||||
}) +
|
|
||||||
// parent says "real"; geometry (relative to real) lands it at 520,20
|
|
||||||
vtx(
|
|
||||||
"kid",
|
|
||||||
RES_ICON("ec2"),
|
|
||||||
{ x: 20, y: 20, w: 48, h: 48 },
|
|
||||||
{ parent: "real" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(findParent(tree, "kid")?.id).toBe("real")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("robustness", () => {
|
|
||||||
it("returns an empty tree with a warning for a compressed file", () => {
|
|
||||||
const compressed = `<mxfile><diagram name="Page-1" id="p1">7VvbcuI4EP0aHmfLGHN5DCQzu1O1t</diagram></mxfile>`
|
|
||||||
const r = parseDiagram(compressed)
|
|
||||||
expect(r.tree.roots).toEqual([])
|
|
||||||
expect(r.warnings[0]).toContain("compressed")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("warns when it silently parsed only the first page of a deck", () => {
|
|
||||||
const two = `<mxfile><diagram name="A" id="a"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 })}</root></mxGraphModel></diagram><diagram name="B" id="b"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
const r = parseDiagram(two)
|
|
||||||
expect(r.warnings.some((w) => w.includes("2 pages"))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats a cell whose parent does not exist as a root instead of dropping it", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx(
|
|
||||||
"orphan",
|
|
||||||
PLAIN_BOX,
|
|
||||||
{ x: 0, y: 0, w: 100, h: 50 },
|
|
||||||
{ parent: "ghost" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(tree.roots.map((r) => r.id)).toContain("orphan")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not hang on a parent cycle", () => {
|
|
||||||
const cyclic = page(
|
|
||||||
vtx(
|
|
||||||
"a",
|
|
||||||
PLAIN_BOX,
|
|
||||||
{ x: 0, y: 0, w: 100, h: 50 },
|
|
||||||
{ parent: "b" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
PLAIN_BOX,
|
|
||||||
{ x: 0, y: 0, w: 100, h: 50 },
|
|
||||||
{ parent: "a" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const r = parseDiagram(cyclic)
|
|
||||||
expect(r.warnings.some((w) => w.includes("cycle"))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("unescapes entities in labels", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx(
|
|
||||||
"b",
|
|
||||||
PLAIN_BOX,
|
|
||||||
{ x: 0, y: 0, w: 100, h: 50 },
|
|
||||||
{
|
|
||||||
value: "A & B <tag> "q"",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const b = findNode(parseDiagram(xml).tree, "b")
|
|
||||||
expect((b as { label?: string }).label).toBe('A & B <tag> "q"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps cells on the boundaries layer verbatim instead of restructuring them", () => {
|
|
||||||
const xml = page(
|
|
||||||
`<mxCell id="boundaries" value="Stack boundaries (locked)" parent="0" style="locked=1;"/>` +
|
|
||||||
vtx(
|
|
||||||
"cluster",
|
|
||||||
"rounded=0;dashed=1;fillColor=none;strokeColor=#ED7100;",
|
|
||||||
{
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
w: 200,
|
|
||||||
h: 100,
|
|
||||||
},
|
|
||||||
{ parent: "boundaries" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(tree.foreign.map((f) => f.id)).toContain("cluster")
|
|
||||||
expect(findNode(tree, "cluster")).toBeNull()
|
|
||||||
expect(tree.foreign[0].xml).toContain("strokeColor=#ED7100")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("ignores an edge that is missing an endpoint", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("a", PLAIN_BOX, { x: 0, y: 0, w: 100, h: 50 }) +
|
|
||||||
`<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="a"><mxGeometry relative="1" as="geometry"/></mxCell>`,
|
|
||||||
)
|
|
||||||
expect(parseDiagram(xml).tree.links).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles a self-closing mxCell", () => {
|
|
||||||
const xml = page(
|
|
||||||
`<mxCell id="bare" value="x" style="${PLAIN_BOX}" vertex="1" parent="1"/>`,
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(tree.roots.map((r) => r.id)).toContain("bare")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("container classification edge cases", () => {
|
|
||||||
it("treats a plain box that has children as a container", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("frame", PLAIN_BOX, { x: 0, y: 0, w: 300, h: 200 }) +
|
|
||||||
vtx(
|
|
||||||
"kid",
|
|
||||||
RES_ICON("s3"),
|
|
||||||
{ x: 20, y: 20, w: 48, h: 48 },
|
|
||||||
{ parent: "frame" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const f = findNode(parseDiagram(xml).tree, "frame")
|
|
||||||
expect(f?.kind).toBe("group")
|
|
||||||
expect(isContainer(f as DiagramNode)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats a childless container=1 frame as a container, not a box", () => {
|
|
||||||
const xml = page(
|
|
||||||
vtx("empty", `${PLAIN_BOX}container=1;`, {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: 200,
|
|
||||||
h: 100,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(findNode(parseDiagram(xml).tree, "empty")?.kind).toBe("group")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("honours the last container= value when the key is duplicated", () => {
|
|
||||||
// Verified in-browser: draw.io resolves duplicate keys last-wins.
|
|
||||||
const xml = page(
|
|
||||||
vtx("c", `${PLAIN_BOX}container=1;container=0;`, {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: 200,
|
|
||||||
h: 100,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
// last value is 0 → not a container, and it has no children → a box
|
|
||||||
expect(findNode(parseDiagram(xml).tree, "c")?.kind).toBe("box")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recognises a text cell as the title even without the __title id", () => {
|
|
||||||
const xml = page(
|
|
||||||
`<mxCell id="t9" value="My Diagram" style="text;html=1;align=center;fontSize=14;" vertex="1" parent="1"><mxGeometry x="0" y="24" width="800" height="30" as="geometry"/></mxCell>`,
|
|
||||||
)
|
|
||||||
const { tree } = parseDiagram(xml)
|
|
||||||
expect(tree.title).toBe("My Diagram")
|
|
||||||
expect(findNode(tree, "t9")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the first title when a diagram somehow has two", () => {
|
|
||||||
const xml = page(
|
|
||||||
`<mxCell id="t1" value="First" style="text;html=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="30" as="geometry"/></mxCell>` +
|
|
||||||
`<mxCell id="t2" value="Second" style="text;html=1;" vertex="1" parent="1"><mxGeometry x="0" y="40" width="100" height="30" as="geometry"/></mxCell>`,
|
|
||||||
)
|
|
||||||
expect(parseDiagram(xml).tree.title).toBe("First")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("geometry resolution", () => {
|
|
||||||
it("resolves a nested cell's position to page coordinates", () => {
|
|
||||||
const withContainer = stampContainer(AWS_GROUP("group_vpc"), {
|
|
||||||
kind: "group",
|
|
||||||
dir: "row",
|
|
||||||
gap: 20,
|
|
||||||
})
|
|
||||||
const xml = page(
|
|
||||||
vtx("outer", withContainer, { x: 100, y: 200, w: 400, h: 300 }) +
|
|
||||||
vtx(
|
|
||||||
"kid",
|
|
||||||
`${RES_ICON("ec2")}dai_kind=icon;dai_pin=1;`,
|
|
||||||
{
|
|
||||||
x: 30,
|
|
||||||
y: 40,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
},
|
|
||||||
{ parent: "outer" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const kid = findNode(parseDiagram(xml).tree, "kid")
|
|
||||||
// 100+30, 200+40
|
|
||||||
expect((kid as { rect?: { x: number; y: number } }).rect).toEqual({
|
|
||||||
x: 130,
|
|
||||||
y: 240,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("resolves through two levels of nesting", () => {
|
|
||||||
const c = stampContainer(AWS_GROUP("group_vpc"), {
|
|
||||||
kind: "group",
|
|
||||||
dir: "col",
|
|
||||||
gap: 10,
|
|
||||||
})
|
|
||||||
const xml = page(
|
|
||||||
vtx("l1", c, { x: 100, y: 100, w: 500, h: 400 }) +
|
|
||||||
vtx(
|
|
||||||
"l2",
|
|
||||||
c,
|
|
||||||
{ x: 50, y: 60, w: 300, h: 200 },
|
|
||||||
{ parent: "l1" },
|
|
||||||
) +
|
|
||||||
vtx(
|
|
||||||
"leaf",
|
|
||||||
`${RES_ICON("s3")}dai_kind=icon;dai_pin=1;`,
|
|
||||||
{
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
w: 48,
|
|
||||||
h: 48,
|
|
||||||
},
|
|
||||||
{ parent: "l2" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const leaf = findNode(parseDiagram(xml).tree, "leaf")
|
|
||||||
// 100+50+10, 100+60+20
|
|
||||||
expect((leaf as { rect?: { x: number; y: number } }).rect?.x).toBe(160)
|
|
||||||
expect((leaf as { rect?: { x: number; y: number } }).rect?.y).toBe(180)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import type { PoolNode } from "@/lib/diagram-engine/types"
|
|
||||||
import { findNode } from "@/lib/diagram-engine/types"
|
|
||||||
import {
|
|
||||||
absoluteRects,
|
|
||||||
escapesParent,
|
|
||||||
outsidePage,
|
|
||||||
overlaps,
|
|
||||||
parentOf,
|
|
||||||
rectOf,
|
|
||||||
} from "./fixtures/geometry"
|
|
||||||
|
|
||||||
/** An expense-approval swimlane: three roles, five steps, three milestone labels. */
|
|
||||||
const EXPENSE: Operation[] = [
|
|
||||||
{ op: "set_title", title: "Expense Approval" },
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "Expense claim",
|
|
||||||
lanes: ["Employee", "Manager", "Finance"],
|
|
||||||
phases: ["Submit", "Review", "Pay"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "fill",
|
|
||||||
parent: "p",
|
|
||||||
label: "Fill form",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
shape: "terminator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "send",
|
|
||||||
parent: "p",
|
|
||||||
label: "Submit claim",
|
|
||||||
lane: 0,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "rev", parent: "p", label: "Review", lane: 1, col: 2 },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "ok",
|
|
||||||
parent: "p",
|
|
||||||
label: "Approved?",
|
|
||||||
lane: 1,
|
|
||||||
col: 3,
|
|
||||||
shape: "decision",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "pay",
|
|
||||||
parent: "p",
|
|
||||||
label: "Pay out",
|
|
||||||
lane: 2,
|
|
||||||
col: 4,
|
|
||||||
},
|
|
||||||
{ op: "link", source: "fill", target: "send" },
|
|
||||||
{ op: "link", source: "send", target: "rev" },
|
|
||||||
{ op: "link", source: "rev", target: "ok" },
|
|
||||||
{ op: "link", source: "ok", target: "pay", label: "yes" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STEPS = ["fill", "send", "rev", "ok", "pay"]
|
|
||||||
|
|
||||||
describe("swimlane pool: layout", () => {
|
|
||||||
const result = restructureDiagram("", EXPENSE)
|
|
||||||
const xml = result.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
|
|
||||||
it("builds without errors", () => {
|
|
||||||
expect(result.errors).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("stacks the lanes in the order they were declared", () => {
|
|
||||||
expect(rectOf(rects, "fill").y).toBeLessThan(rectOf(rects, "rev").y)
|
|
||||||
expect(rectOf(rects, "rev").y).toBeLessThan(rectOf(rects, "pay").y)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("advances the columns left to right", () => {
|
|
||||||
expect(rectOf(rects, "fill").x).toBeLessThan(rectOf(rects, "send").x)
|
|
||||||
expect(rectOf(rects, "send").x).toBeLessThan(rectOf(rects, "rev").x)
|
|
||||||
expect(rectOf(rects, "rev").x).toBeLessThan(rectOf(rects, "ok").x)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts two steps with the same column at the same x", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_pool", id: "p", label: "", lanes: ["A", "B"] },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 0,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "y",
|
|
||||||
parent: "p",
|
|
||||||
label: "Y",
|
|
||||||
lane: 1,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const rr = absoluteRects(r.xml as string)
|
|
||||||
expect(rectOf(rr, "x").x).toBe(rectOf(rr, "y").x)
|
|
||||||
expect(rectOf(rr, "x").y).not.toBe(rectOf(rr, "y").y)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws one band per lane, with the role names beside them", () => {
|
|
||||||
expect([...xml.matchAll(/id="p__band(\d)"/g)].map((m) => m[1])).toEqual(
|
|
||||||
["0", "1", "2"],
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
[...xml.matchAll(/id="p__lane\d" value="([^"]*)"/g)].map(
|
|
||||||
(m) => m[1],
|
|
||||||
),
|
|
||||||
).toEqual(["Employee", "Manager", "Finance"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws the milestone labels", () => {
|
|
||||||
expect(
|
|
||||||
[...xml.matchAll(/id="p__phase\d" value="([^"]*)"/g)].map(
|
|
||||||
(m) => m[1],
|
|
||||||
),
|
|
||||||
).toEqual(["Submit", "Review", "Pay"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("omits the milestone band when no phases were given", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_pool", id: "p", label: "", lanes: ["A"] },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.xml).not.toContain("p__phase")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("parents each step to its lane band, not to the pool", () => {
|
|
||||||
// This is what records a role change when the user drags a step to another lane:
|
|
||||||
// draw.io rewrites `parent` to the band it was dropped on.
|
|
||||||
expect(parentOf(xml, "fill")).toBe("p__band0")
|
|
||||||
expect(parentOf(xml, "rev")).toBe("p__band1")
|
|
||||||
expect(parentOf(xml, "pay")).toBe("p__band2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps everything inside the page and inside its parent", () => {
|
|
||||||
expect(outsidePage(xml, ["__title"])).toEqual([])
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
expect(overlaps(rects, STEPS)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("clamps a lane index past the last lane instead of drawing off the pool", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_pool", id: "p", label: "", lanes: ["only"] },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 9,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("lays a vertical pool out with the lanes as columns", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "",
|
|
||||||
lanes: ["A", "B"],
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "y",
|
|
||||||
parent: "p",
|
|
||||||
label: "Y",
|
|
||||||
lane: 1,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "z",
|
|
||||||
parent: "p",
|
|
||||||
label: "Z",
|
|
||||||
lane: 0,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const rr = absoluteRects(r.xml as string)
|
|
||||||
// Lanes side by side, the flow running downwards.
|
|
||||||
expect(rectOf(rr, "x").x).toBeLessThan(rectOf(rr, "y").x)
|
|
||||||
expect(rectOf(rr, "x").y).toBeLessThan(rectOf(rr, "z").y)
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the milestone strip inside a VERTICAL pool", () => {
|
|
||||||
// The measure pass reserves the pool's width as padding + content + strip, with no
|
|
||||||
// gap between content and strip. Rendering the strip one gap further out put it
|
|
||||||
// outside the frame — and no earlier test caught it, because every vertical case
|
|
||||||
// omitted phases and every phases case was horizontal.
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "V",
|
|
||||||
lanes: ["A", "B"],
|
|
||||||
phases: ["P1", "P2"],
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "y",
|
|
||||||
parent: "p",
|
|
||||||
label: "Y",
|
|
||||||
lane: 1,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "z",
|
|
||||||
parent: "p",
|
|
||||||
label: "Z",
|
|
||||||
lane: 0,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the milestone strip inside a HORIZONTAL pool", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "H",
|
|
||||||
lanes: ["A", "B"],
|
|
||||||
phases: ["P1", "P2", "P3"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "y",
|
|
||||||
parent: "p",
|
|
||||||
label: "Y",
|
|
||||||
lane: 1,
|
|
||||||
col: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "z",
|
|
||||||
parent: "p",
|
|
||||||
label: "Z",
|
|
||||||
lane: 0,
|
|
||||||
col: 2,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refuses a pool with no lanes", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_pool", id: "p", label: "x", lanes: [] },
|
|
||||||
])
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
expect(r.errors[0]).toContain("at least one lane")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("swimlane pool: round-trip", () => {
|
|
||||||
it("comes back with the same lanes, phases and cells", () => {
|
|
||||||
const first = restructureDiagram("", EXPENSE)
|
|
||||||
const { tree, warnings } = parseDiagram(first.xml as string)
|
|
||||||
expect(warnings).toEqual([])
|
|
||||||
const pool = findNode(tree, "p") as PoolNode
|
|
||||||
expect(pool.kind).toBe("pool")
|
|
||||||
expect(pool.lanes).toEqual(["Employee", "Manager", "Finance"])
|
|
||||||
expect(pool.phases).toEqual(["Submit", "Review", "Pay"])
|
|
||||||
expect(pool.orientation).toBe("horizontal")
|
|
||||||
expect(pool.children.map((c) => c.id)).toEqual(STEPS)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not move anything on a re-layout", () => {
|
|
||||||
const first = restructureDiagram("", EXPENSE)
|
|
||||||
const second = restructureDiagram(first.xml as string, [])
|
|
||||||
expect(second.errors).toEqual([])
|
|
||||||
expect(second.warnings).toEqual([])
|
|
||||||
const a = absoluteRects(first.xml as string)
|
|
||||||
const b = absoluteRects(second.xml as string)
|
|
||||||
for (const id of STEPS) expect(b.get(id)).toEqual(a.get(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not accumulate lane bands across round-trips", () => {
|
|
||||||
// The bands are chrome the renderer rebuilds. Preserving them verbatim would leave a
|
|
||||||
// stale set behind the new ones on every pass.
|
|
||||||
const first = restructureDiagram("", EXPENSE)
|
|
||||||
const second = restructureDiagram(first.xml as string, [])
|
|
||||||
const third = restructureDiagram(second.xml as string, [])
|
|
||||||
const count = (xml: string) => (xml.match(/__band\d/g) ?? []).length
|
|
||||||
expect(count(third.xml as string)).toBe(count(first.xml as string))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads a step's new lane from the band the user dropped it on", () => {
|
|
||||||
const first = restructureDiagram("", EXPENSE)
|
|
||||||
// Simulate the drag: draw.io rewrites the cell's parent to the new band.
|
|
||||||
const moved = (first.xml as string).replace(
|
|
||||||
/(<mxCell id="pay"[^>]*parent=")p__band2(")/,
|
|
||||||
"$1p__band0$2",
|
|
||||||
)
|
|
||||||
expect(moved).not.toBe(first.xml)
|
|
||||||
const pool = findNode(parseDiagram(moved).tree, "p") as PoolNode
|
|
||||||
const pay = pool.children.find((c) => c.id === "pay")
|
|
||||||
expect(pay).toBeDefined()
|
|
||||||
// The band wins over the stale marker still on the cell.
|
|
||||||
expect((pay as { cell?: { lane: number } }).cell?.lane).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a vertical pool vertical", () => {
|
|
||||||
const first = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "V",
|
|
||||||
lanes: ["A", "B"],
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 1,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const pool = findNode(
|
|
||||||
parseDiagram(first.xml as string).tree,
|
|
||||||
"p",
|
|
||||||
) as PoolNode
|
|
||||||
expect(pool.orientation).toBe("vertical")
|
|
||||||
expect(pool.lanes).toEqual(["A", "B"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives a lane name containing a semicolon or an equals sign", () => {
|
|
||||||
// Those two characters delimit a draw.io style string, so a naive marker would break
|
|
||||||
// the whole cell.
|
|
||||||
const first = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_pool",
|
|
||||||
id: "p",
|
|
||||||
label: "",
|
|
||||||
lanes: ["a;b", "c=d", "plain"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
parent: "p",
|
|
||||||
label: "X",
|
|
||||||
lane: 0,
|
|
||||||
col: 0,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(first.errors).toEqual([])
|
|
||||||
const pool = findNode(
|
|
||||||
parseDiagram(first.xml as string).tree,
|
|
||||||
"p",
|
|
||||||
) as PoolNode
|
|
||||||
expect(pool.lanes).toEqual(["a;b", "c=d", "plain"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import type { Operation } from "@/lib/diagram-engine"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import {
|
|
||||||
absoluteRects,
|
|
||||||
escapesParent,
|
|
||||||
outsidePage,
|
|
||||||
overlaps,
|
|
||||||
} from "./fixtures/geometry"
|
|
||||||
|
|
||||||
const page = (x: string) => {
|
|
||||||
const m = x.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)!
|
|
||||||
return { w: +m[1], h: +m[2], aspect: +m[1] / +m[2] }
|
|
||||||
}
|
|
||||||
const ink = (x: string) => {
|
|
||||||
const p = page(x)
|
|
||||||
let a = 0
|
|
||||||
for (const [, r] of absoluteRects(x)) a += r.w * r.h
|
|
||||||
return a / (p.w * p.h)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole pipeline on one realistic diagram, built exactly the way the tool description
|
|
||||||
* tells the model to build a poster.
|
|
||||||
*
|
|
||||||
* Every other test here checks one field in isolation, and each of those passed while the
|
|
||||||
* realistic case was still wrong: the declared 2:1 columns came out 1:1 because the row
|
|
||||||
* holding them was never given a width, and the declared portrait page came out landscape
|
|
||||||
* because widening the page rewraps the text and shortens it, which one pass cannot account
|
|
||||||
* for. Neither showed up until the pieces were used together.
|
|
||||||
*/
|
|
||||||
describe("a poster built exactly as the tool description now instructs", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "set_page", aspect: 0.8 },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "Chain-of-Thought Prompting",
|
|
||||||
role: "banner",
|
|
||||||
class: "self-stretch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "by",
|
|
||||||
parent: "page",
|
|
||||||
label: "Wei et al., 2022 · NeurIPS",
|
|
||||||
role: "muted",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "cols",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "left",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-2 min-w-0 gap-3 items-stretch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "right",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-1 min-w-0 gap-3 items-stretch justify-between",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "h1",
|
|
||||||
parent: "left",
|
|
||||||
label: "What it is",
|
|
||||||
role: "heading",
|
|
||||||
group: "idea",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p1",
|
|
||||||
parent: "left",
|
|
||||||
label: "Ask the model to lay out its intermediate steps before answering, instead of jumping straight to a result.",
|
|
||||||
group: "idea",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "h2",
|
|
||||||
parent: "left",
|
|
||||||
label: "Why it helps",
|
|
||||||
role: "heading",
|
|
||||||
group: "why",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p2",
|
|
||||||
parent: "left",
|
|
||||||
label: "Breaking a hard problem into easy sub-steps makes the reasoning visible, so it can be checked and debugged. The biggest gains show up on maths, logic and multi-hop questions.",
|
|
||||||
group: "why",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "h3",
|
|
||||||
parent: "left",
|
|
||||||
label: "Worked example",
|
|
||||||
role: "heading",
|
|
||||||
group: "eg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p3",
|
|
||||||
parent: "left",
|
|
||||||
label: "Roger has 5 tennis balls and buys 2 cans of 3 balls each. 2 x 3 = 6 new balls; 5 + 6 = 11 balls.",
|
|
||||||
group: "eg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "h4",
|
|
||||||
parent: "right",
|
|
||||||
label: "Costs & limits",
|
|
||||||
role: "heading",
|
|
||||||
group: "cost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p4",
|
|
||||||
parent: "right",
|
|
||||||
label: "More tokens, slower, pricier.",
|
|
||||||
group: "cost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p5",
|
|
||||||
parent: "right",
|
|
||||||
label: "Steps can look sound yet still be wrong.",
|
|
||||||
role: "bad",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "m1",
|
|
||||||
parent: "right",
|
|
||||||
label: "+40%",
|
|
||||||
role: "metric",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p6",
|
|
||||||
parent: "right",
|
|
||||||
label: "Mainly emerges in large models.",
|
|
||||||
role: "muted",
|
|
||||||
},
|
|
||||||
] as Operation[])
|
|
||||||
|
|
||||||
it("builds cleanly, portrait, in proportion, with nothing spilling", () => {
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const p = page(xml)
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
console.log(" page:", p, " ink:", (ink(xml) * 100).toFixed(0) + "%")
|
|
||||||
console.log(
|
|
||||||
" columns — left:",
|
|
||||||
rects.get("left")!.w,
|
|
||||||
"right:",
|
|
||||||
rects.get("right")!.w,
|
|
||||||
"ratio:",
|
|
||||||
(rects.get("left")!.w / rects.get("right")!.w).toFixed(2),
|
|
||||||
)
|
|
||||||
console.log(" warnings:", r.warnings.length ? r.warnings : "none")
|
|
||||||
|
|
||||||
// Portrait was asked for.
|
|
||||||
expect(p.aspect).toBeLessThan(1.0)
|
|
||||||
// 2:1 was asked for, and min-w-0 was given on both columns.
|
|
||||||
const ratio = rects.get("left")!.w / rects.get("right")!.w
|
|
||||||
expect(ratio).toBeGreaterThan(1.8)
|
|
||||||
expect(ratio).toBeLessThan(2.2)
|
|
||||||
// Nothing broken.
|
|
||||||
expect(overlaps(rects, ["left", "right"])).toEqual([])
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
expect(outsidePage(xml)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-reading the canvas gives the same diagram back", () => {
|
|
||||||
const again = restructureDiagram(r.xml as string, [])
|
|
||||||
expect(again.errors).toEqual([])
|
|
||||||
expect(page(again.xml as string)).toEqual(page(r.xml as string))
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import type { RadialNode } from "@/lib/diagram-engine/types"
|
|
||||||
import { findNode } from "@/lib/diagram-engine/types"
|
|
||||||
import {
|
|
||||||
absoluteRects,
|
|
||||||
escapesParent,
|
|
||||||
outsidePage,
|
|
||||||
overlaps,
|
|
||||||
rectOf,
|
|
||||||
} from "./fixtures/geometry"
|
|
||||||
|
|
||||||
/** A mind map. Children are a FLAT list; the links carry the hierarchy. */
|
|
||||||
const MINDMAP: Operation[] = [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
{ op: "add_box", id: "root", parent: "m", label: "Product Launch" },
|
|
||||||
{ op: "add_box", id: "eng", parent: "m", label: "Engineering" },
|
|
||||||
{ op: "add_box", id: "api", parent: "m", label: "API" },
|
|
||||||
{ op: "add_box", id: "ui", parent: "m", label: "UI" },
|
|
||||||
{ op: "add_box", id: "mkt", parent: "m", label: "Marketing" },
|
|
||||||
{ op: "add_box", id: "legal", parent: "m", label: "Legal" },
|
|
||||||
{ op: "add_box", id: "ops", parent: "m", label: "Ops" },
|
|
||||||
{ op: "add_box", id: "sre", parent: "m", label: "SRE" },
|
|
||||||
{ op: "link", source: "root", target: "eng" },
|
|
||||||
{ op: "link", source: "root", target: "mkt" },
|
|
||||||
{ op: "link", source: "root", target: "legal" },
|
|
||||||
{ op: "link", source: "root", target: "ops" },
|
|
||||||
{ op: "link", source: "eng", target: "api" },
|
|
||||||
{ op: "link", source: "eng", target: "ui" },
|
|
||||||
{ op: "link", source: "ops", target: "sre" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const MIND_IDS = ["root", "eng", "api", "ui", "mkt", "legal", "ops", "sre"]
|
|
||||||
|
|
||||||
const ORGCHART: Operation[] = [
|
|
||||||
{ op: "add_radial", id: "o", label: "", spread: "down" },
|
|
||||||
{ op: "add_box", id: "ceo", parent: "o", label: "CEO" },
|
|
||||||
{ op: "add_box", id: "cto", parent: "o", label: "CTO" },
|
|
||||||
{ op: "add_box", id: "eng1", parent: "o", label: "Platform Lead" },
|
|
||||||
{ op: "add_box", id: "eng2", parent: "o", label: "Mobile Lead" },
|
|
||||||
{ op: "add_box", id: "cfo", parent: "o", label: "CFO" },
|
|
||||||
{ op: "add_box", id: "acct", parent: "o", label: "Accounting" },
|
|
||||||
{ op: "link", source: "ceo", target: "cto" },
|
|
||||||
{ op: "link", source: "ceo", target: "cfo" },
|
|
||||||
{ op: "link", source: "cto", target: "eng1" },
|
|
||||||
{ op: "link", source: "cto", target: "eng2" },
|
|
||||||
{ op: "link", source: "cfo", target: "acct" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const ORG_IDS = ["ceo", "cto", "eng1", "eng2", "cfo", "acct"]
|
|
||||||
|
|
||||||
describe("mind map: radial spread", () => {
|
|
||||||
const result = restructureDiagram("", MINDMAP)
|
|
||||||
const xml = result.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
|
|
||||||
it("builds without errors", () => {
|
|
||||||
expect(result.errors).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("makes the node nothing points at the centre", () => {
|
|
||||||
const centre = rectOf(rects, "root")
|
|
||||||
const mid = centre.x + centre.w / 2
|
|
||||||
const sides = ["eng", "mkt", "legal", "ops"].map((id) =>
|
|
||||||
rectOf(rects, id).x > mid ? "right" : "left",
|
|
||||||
)
|
|
||||||
// Branches on both sides, which is what keeps a mind map compact.
|
|
||||||
expect(sides).toContain("right")
|
|
||||||
expect(sides).toContain("left")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts a sub-branch further from the centre than its parent", () => {
|
|
||||||
const centre = rectOf(rects, "root")
|
|
||||||
const mid = centre.x + centre.w / 2
|
|
||||||
const far = (id: string) =>
|
|
||||||
Math.abs(rectOf(rects, id).x + rectOf(rects, id).w / 2 - mid)
|
|
||||||
expect(far("api")).toBeGreaterThan(far("eng"))
|
|
||||||
expect(far("ui")).toBeGreaterThan(far("eng"))
|
|
||||||
expect(far("sre")).toBeGreaterThan(far("ops"))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts siblings of the same generation at the same distance out", () => {
|
|
||||||
expect(rectOf(rects, "api").x).toBe(rectOf(rects, "ui").x)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws nothing on top of anything else", () => {
|
|
||||||
expect(overlaps(rects, MIND_IDS)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps everything inside the page and inside the frame", () => {
|
|
||||||
expect(outsidePage(xml, ["__title"])).toEqual([])
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("fits a map whose two sides are different depths", () => {
|
|
||||||
// Reserving the same room on both sides would push the deeper side off the page.
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
{ op: "add_box", id: "root", parent: "m", label: "Root" },
|
|
||||||
{ op: "add_box", id: "shallow", parent: "m", label: "A" },
|
|
||||||
{ op: "add_box", id: "b", parent: "m", label: "B" },
|
|
||||||
{ op: "add_box", id: "b1", parent: "m", label: "B1" },
|
|
||||||
{ op: "add_box", id: "b2", parent: "m", label: "B2" },
|
|
||||||
{ op: "add_box", id: "b3", parent: "m", label: "B3" },
|
|
||||||
{ op: "link", source: "root", target: "shallow" },
|
|
||||||
{ op: "link", source: "root", target: "b" },
|
|
||||||
{ op: "link", source: "b", target: "b1" },
|
|
||||||
{ op: "link", source: "b1", target: "b2" },
|
|
||||||
{ op: "link", source: "b2", target: "b3" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still draws a node no arrow reaches", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
{ op: "add_box", id: "root", parent: "m", label: "Root" },
|
|
||||||
{ op: "add_box", id: "a", parent: "m", label: "A" },
|
|
||||||
{ op: "add_box", id: "orphan", parent: "m", label: "Orphan" },
|
|
||||||
{ op: "link", source: "root", target: "a" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const rr = absoluteRects(r.xml as string)
|
|
||||||
expect(rr.has("orphan")).toBe(true)
|
|
||||||
expect(overlaps(rr, ["root", "a", "orphan"])).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives arrows that form a cycle", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
{ op: "add_box", id: "x", parent: "m", label: "X" },
|
|
||||||
{ op: "add_box", id: "y", parent: "m", label: "Y" },
|
|
||||||
{ op: "add_box", id: "z", parent: "m", label: "Z" },
|
|
||||||
{ op: "link", source: "x", target: "y" },
|
|
||||||
{ op: "link", source: "y", target: "z" },
|
|
||||||
{ op: "link", source: "z", target: "x" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(
|
|
||||||
overlaps(absoluteRects(r.xml as string), ["x", "y", "z"]),
|
|
||||||
).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles a radial container holding one node", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
{ op: "add_box", id: "only", parent: "m", label: "Only" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles an empty radial container", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_radial", id: "m", label: "", spread: "radial" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.xml).toContain('id="m"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("org chart: downward spread", () => {
|
|
||||||
const result = restructureDiagram("", ORGCHART)
|
|
||||||
const xml = result.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
|
|
||||||
it("builds without errors", () => {
|
|
||||||
expect(result.errors).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("hangs every level strictly below the one above", () => {
|
|
||||||
// A reporting line only reads correctly downwards, which is the whole reason this
|
|
||||||
// spread exists separately from the radial one.
|
|
||||||
expect(rectOf(rects, "ceo").y).toBeLessThan(rectOf(rects, "cto").y)
|
|
||||||
expect(rectOf(rects, "cto").y).toBeLessThan(rectOf(rects, "eng1").y)
|
|
||||||
expect(rectOf(rects, "cfo").y).toBeLessThan(rectOf(rects, "acct").y)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts peers on the same line", () => {
|
|
||||||
expect(rectOf(rects, "cto").y).toBe(rectOf(rects, "cfo").y)
|
|
||||||
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "eng2").y)
|
|
||||||
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "acct").y)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps one manager's reports clear of another's", () => {
|
|
||||||
// Sizing each slice by its whole subtree, not by the number of direct reports, is what
|
|
||||||
// stops a manager with two reports from overrunning the next manager's column.
|
|
||||||
expect(overlaps(rects, ORG_IDS)).toEqual([])
|
|
||||||
const eng2 = rectOf(rects, "eng2")
|
|
||||||
expect(eng2.x + eng2.w).toBeLessThanOrEqual(rectOf(rects, "acct").x)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps everything inside the page and inside the frame", () => {
|
|
||||||
expect(outsidePage(xml, ["__title"])).toEqual([])
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("fits a chain five levels deep", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_radial", id: "o", label: "", spread: "down" },
|
|
||||||
...["a", "b", "c", "d", "e"].map(
|
|
||||||
(id) =>
|
|
||||||
({
|
|
||||||
op: "add_box",
|
|
||||||
id,
|
|
||||||
parent: "o",
|
|
||||||
label: id.toUpperCase(),
|
|
||||||
}) as Operation,
|
|
||||||
),
|
|
||||||
{ op: "link", source: "a", target: "b" },
|
|
||||||
{ op: "link", source: "b", target: "c" },
|
|
||||||
{ op: "link", source: "c", target: "d" },
|
|
||||||
{ op: "link", source: "d", target: "e" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const rr = absoluteRects(r.xml as string)
|
|
||||||
const ys = ["a", "b", "c", "d", "e"].map((id) => rectOf(rr, id).y)
|
|
||||||
expect(ys).toEqual([...ys].sort((x, y) => x - y))
|
|
||||||
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("radial: round-trip", () => {
|
|
||||||
it("comes back as a radial container with the same spread", () => {
|
|
||||||
const first = restructureDiagram("", MINDMAP)
|
|
||||||
const { tree, warnings } = parseDiagram(first.xml as string)
|
|
||||||
expect(warnings).toEqual([])
|
|
||||||
const radial = findNode(tree, "m") as RadialNode
|
|
||||||
expect(radial.kind).toBe("radial")
|
|
||||||
expect(radial.spread).toBe("radial")
|
|
||||||
expect(radial.children.map((c) => c.id).sort()).toEqual(
|
|
||||||
[...MIND_IDS].sort(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps an org chart pointing downwards", () => {
|
|
||||||
const first = restructureDiagram("", ORGCHART)
|
|
||||||
const radial = findNode(
|
|
||||||
parseDiagram(first.xml as string).tree,
|
|
||||||
"o",
|
|
||||||
) as RadialNode
|
|
||||||
expect(radial.spread).toBe("down")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reaches a fixed point: a mind map does not drift on re-layout", () => {
|
|
||||||
const first = restructureDiagram("", MINDMAP)
|
|
||||||
const second = restructureDiagram(first.xml as string, [])
|
|
||||||
const third = restructureDiagram(second.xml as string, [])
|
|
||||||
expect(second.errors).toEqual([])
|
|
||||||
expect(second.warnings).toEqual([])
|
|
||||||
const a = absoluteRects(first.xml as string)
|
|
||||||
const b = absoluteRects(second.xml as string)
|
|
||||||
const c = absoluteRects(third.xml as string)
|
|
||||||
for (const id of MIND_IDS) {
|
|
||||||
expect(b.get(id)).toEqual(a.get(id))
|
|
||||||
expect(c.get(id)).toEqual(a.get(id))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reaches a fixed point: an org chart does not drift on re-layout", () => {
|
|
||||||
const first = restructureDiagram("", ORGCHART)
|
|
||||||
const second = restructureDiagram(first.xml as string, [])
|
|
||||||
const third = restructureDiagram(second.xml as string, [])
|
|
||||||
const a = absoluteRects(first.xml as string)
|
|
||||||
const c = absoluteRects(third.xml as string)
|
|
||||||
for (const id of ORG_IDS) expect(c.get(id)).toEqual(a.get(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds a branch to an existing map without redrawing it", () => {
|
|
||||||
const first = restructureDiagram("", MINDMAP)
|
|
||||||
const second = restructureDiagram(first.xml as string, [
|
|
||||||
{ op: "add_box", id: "docs", parent: "m", label: "Docs" },
|
|
||||||
{ op: "link", source: "root", target: "docs" },
|
|
||||||
])
|
|
||||||
expect(second.errors).toEqual([])
|
|
||||||
const rects = absoluteRects(second.xml as string)
|
|
||||||
expect(rects.has("docs")).toBe(true)
|
|
||||||
expect(overlaps(rects, [...MIND_IDS, "docs"])).toEqual([])
|
|
||||||
expect(escapesParent(second.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
/**
|
|
||||||
* Round-trip: tree → XML → tree.
|
|
||||||
*
|
|
||||||
* This is the test the whole design rests on. If structure does not survive a trip
|
|
||||||
* through draw.io XML, then the canvas cannot be the single source of truth and we are
|
|
||||||
* back to keeping a second copy of the state in sync.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import { renderDiagram } from "@/lib/diagram-engine/render"
|
|
||||||
import {
|
|
||||||
type BoxNode,
|
|
||||||
type ContainerNode,
|
|
||||||
type DiagramNode,
|
|
||||||
type DiagramTree,
|
|
||||||
findNode,
|
|
||||||
findParent,
|
|
||||||
type GridNode,
|
|
||||||
type GroupNode,
|
|
||||||
type IconNode,
|
|
||||||
isContainer,
|
|
||||||
walkTree,
|
|
||||||
} from "@/lib/diagram-engine/types"
|
|
||||||
|
|
||||||
const icon = (id: string, name = "ec2", label = ""): IconNode => ({
|
|
||||||
kind: "icon",
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
})
|
|
||||||
const box = (id: string, label = ""): BoxNode => ({ kind: "box", id, label })
|
|
||||||
const group = (
|
|
||||||
id: string,
|
|
||||||
dir: "row" | "col",
|
|
||||||
children: DiagramNode[],
|
|
||||||
label = "",
|
|
||||||
gname: string | null = null,
|
|
||||||
gap = 20,
|
|
||||||
): GroupNode => ({ kind: "group", id, gname, label, dir, gap, children })
|
|
||||||
const grid = (
|
|
||||||
id: string,
|
|
||||||
cols: number,
|
|
||||||
children: DiagramNode[],
|
|
||||||
label = "",
|
|
||||||
gap = 14,
|
|
||||||
): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children })
|
|
||||||
|
|
||||||
const tree = (
|
|
||||||
roots: DiagramNode[],
|
|
||||||
extra: Partial<DiagramTree> = {},
|
|
||||||
): DiagramTree => ({
|
|
||||||
roots,
|
|
||||||
links: [],
|
|
||||||
foreign: [],
|
|
||||||
...extra,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Structural signature: nesting, kinds, directions and order — everything but coordinates. */
|
|
||||||
function signature(t: DiagramTree): string {
|
|
||||||
const line = (n: DiagramNode, depth: number): string[] => {
|
|
||||||
const pad = " ".repeat(depth)
|
|
||||||
if (!isContainer(n)) return [`${pad}${n.kind} ${n.id}`]
|
|
||||||
const meta =
|
|
||||||
n.kind === "grid"
|
|
||||||
? `cols=${n.cols}`
|
|
||||||
: n.kind === "group"
|
|
||||||
? `dir=${n.dir}`
|
|
||||||
: n.kind
|
|
||||||
return [
|
|
||||||
`${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`,
|
|
||||||
...n.children.flatMap((c) => line(c, depth + 1)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return t.roots.flatMap((r) => line(r, 0)).join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render then parse, returning the recovered tree. */
|
|
||||||
function roundTrip(t: DiagramTree) {
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
return { xml, ...parseDiagram(xml) }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("structure survives a round-trip", () => {
|
|
||||||
it("recovers a flat row", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a"), icon("b")], "Frame")])
|
|
||||||
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a column", () => {
|
|
||||||
const t = tree([group("f", "col", [icon("a"), icon("b")], "Frame")])
|
|
||||||
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a grid with its column count", () => {
|
|
||||||
const t = tree([
|
|
||||||
grid("g", 3, [icon("a"), icon("b"), icon("c"), icon("d")], "G"),
|
|
||||||
])
|
|
||||||
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a deep cloud-architecture nesting", () => {
|
|
||||||
const t = tree([
|
|
||||||
group(
|
|
||||||
"region",
|
|
||||||
"row",
|
|
||||||
[
|
|
||||||
group(
|
|
||||||
"vpc",
|
|
||||||
"col",
|
|
||||||
[
|
|
||||||
icon("igw", "internet_gateway", "IGW"),
|
|
||||||
group(
|
|
||||||
"az_a",
|
|
||||||
"col",
|
|
||||||
[
|
|
||||||
group(
|
|
||||||
"pub_a",
|
|
||||||
"col",
|
|
||||||
[icon("nat_a", "nat_gateway", "NAT")],
|
|
||||||
"Public Subnet",
|
|
||||||
"group_subnet",
|
|
||||||
),
|
|
||||||
group(
|
|
||||||
"app_a",
|
|
||||||
"col",
|
|
||||||
[icon("ec2_a", "ec2", "EC2")],
|
|
||||||
"Private Subnet",
|
|
||||||
"group_subnet",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"AZ-a",
|
|
||||||
"group_availability_zone",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"VPC",
|
|
||||||
"group_vpc",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"Region",
|
|
||||||
"group_region",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers several roots in order", () => {
|
|
||||||
const t = tree([
|
|
||||||
box("users", "Users"),
|
|
||||||
group("cloud", "row", [icon("a")], "Cloud"),
|
|
||||||
box("consumers", "Consumers"),
|
|
||||||
])
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
expect(back.roots.map((r) => r.id)).toEqual([
|
|
||||||
"users",
|
|
||||||
"cloud",
|
|
||||||
"consumers",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers the direction of an UNLABELLED wrapper — the phantom problem, fixed", () => {
|
|
||||||
// The reference project would use a phantom here, which emits no cell: its two
|
|
||||||
// children would be reparented onto vpc, and the wrapper's "row" direction would
|
|
||||||
// be gone from the XML for good. We emit a real but invisible cell instead.
|
|
||||||
const t = tree([
|
|
||||||
group(
|
|
||||||
"vpc",
|
|
||||||
"col",
|
|
||||||
[
|
|
||||||
icon("igw", "internet_gateway", "IGW"),
|
|
||||||
group(
|
|
||||||
"azs",
|
|
||||||
"row",
|
|
||||||
[
|
|
||||||
group("az_a", "col", [icon("ec2_a")], "AZ-a"),
|
|
||||||
group("az_b", "col", [icon("ec2_b")], "AZ-b"),
|
|
||||||
],
|
|
||||||
"", // no label — a layout-only wrapper
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"VPC",
|
|
||||||
"group_vpc",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
// the wrapper is still there, still a row, still holding both AZs
|
|
||||||
const azs = findNode(back, "azs")
|
|
||||||
expect(azs).not.toBeNull()
|
|
||||||
expect((azs as GroupNode).dir).toBe("row")
|
|
||||||
expect(findParent(back, "az_a")?.id).toBe("azs")
|
|
||||||
expect(findParent(back, "azs")?.id).toBe("vpc")
|
|
||||||
// and vpc kept its own direction instead of collapsing into a grid
|
|
||||||
expect((findNode(back, "vpc") as GroupNode).dir).toBe("col")
|
|
||||||
expect(signature(back)).toBe(signature(t))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the wrapper invisible", () => {
|
|
||||||
const t = tree([
|
|
||||||
group("w", "row", [icon("a"), icon("b")], ""), // unlabelled → invisible
|
|
||||||
])
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
const cell = xml.match(/<mxCell id="w"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
||||||
expect(cell).toContain("fillColor=none")
|
|
||||||
expect(cell).toContain("strokeColor=none")
|
|
||||||
// ...but it is still a real container, so draw.io reparents into it
|
|
||||||
expect(cell).toContain("container=1")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps a labelled frame visible", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "Visible")])
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
const style = xml.match(/<mxCell id="f"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
||||||
expect(style).not.toContain("strokeColor=none")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives repeated round-trips without drift", () => {
|
|
||||||
const t = tree([
|
|
||||||
group(
|
|
||||||
"outer",
|
|
||||||
"row",
|
|
||||||
[
|
|
||||||
group("a", "col", [icon("x"), icon("y")], "A"),
|
|
||||||
grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"),
|
|
||||||
],
|
|
||||||
"Outer",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const once = roundTrip(t).tree
|
|
||||||
const twice = roundTrip(once).tree
|
|
||||||
const thrice = roundTrip(twice).tree
|
|
||||||
expect(signature(twice)).toBe(signature(once))
|
|
||||||
expect(signature(thrice)).toBe(signature(once))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps geometry stable across repeated round-trips", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")])
|
|
||||||
const first = renderDiagram(t)
|
|
||||||
const second = renderDiagram(parseDiagram(first.xml).tree)
|
|
||||||
expect(second.page).toEqual(first.page)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("labels and content survive", () => {
|
|
||||||
it("recovers labels on containers and leaves", () => {
|
|
||||||
const t = tree([
|
|
||||||
group(
|
|
||||||
"f",
|
|
||||||
"row",
|
|
||||||
[icon("a", "s3", "My Bucket"), box("b", "A Box")],
|
|
||||||
"My Frame",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
expect((findNode(back, "f") as ContainerNode).label).toBe("My Frame")
|
|
||||||
expect((findNode(back, "a") as IconNode).label).toBe("My Bucket")
|
|
||||||
expect((findNode(back, "b") as BoxNode).label).toBe("A Box")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a label containing XML metacharacters", () => {
|
|
||||||
const nasty = "A & B <tag> \"quoted\" 'apostrophe'"
|
|
||||||
const t = tree([group("f", "row", [box("b", nasty)], nasty)])
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
expect((findNode(back, "b") as BoxNode).label).toBe(nasty)
|
|
||||||
expect((findNode(back, "f") as ContainerNode).label).toBe(nasty)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers the page title", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
||||||
title: "My Architecture",
|
|
||||||
})
|
|
||||||
expect(roundTrip(t).tree.title).toBe("My Architecture")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a gap that is not the default", () => {
|
|
||||||
const t = tree([
|
|
||||||
group("f", "row", [icon("a"), icon("b")], "F", null, 47),
|
|
||||||
])
|
|
||||||
expect((findNode(roundTrip(t).tree, "f") as GroupNode).gap).toBe(47)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers an icon's catalog name", () => {
|
|
||||||
const t = tree([
|
|
||||||
group(
|
|
||||||
"f",
|
|
||||||
"row",
|
|
||||||
[icon("a", "nat_gateway"), icon("b", "rds")],
|
|
||||||
"F",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
expect((findNode(back, "a") as IconNode).name).toBe("nat_gateway")
|
|
||||||
expect((findNode(back, "b") as IconNode).name).toBe("rds")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a group stencil name", () => {
|
|
||||||
const t = tree([group("v", "col", [icon("a")], "VPC", "group_vpc")])
|
|
||||||
const { xml } = renderDiagram(t, {
|
|
||||||
resolveStyle: (name, kind) =>
|
|
||||||
kind === "group"
|
|
||||||
? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;`
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
expect((findNode(parseDiagram(xml).tree, "v") as GroupNode).gname).toBe(
|
|
||||||
"group_vpc",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("edges survive", () => {
|
|
||||||
it("recovers source, target and label", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
||||||
links: [{ source: "a", target: "b", label: "flows to" }],
|
|
||||||
})
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
expect(back.links).toHaveLength(1)
|
|
||||||
expect(back.links[0].source).toBe("a")
|
|
||||||
expect(back.links[0].target).toBe("b")
|
|
||||||
expect(back.links[0].label).toBe("flows to")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a step number and keeps it out of the label", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
||||||
links: [{ source: "a", target: "b", label: "HTTPS", step: 1 }],
|
|
||||||
})
|
|
||||||
const back = roundTrip(t).tree
|
|
||||||
expect(back.links[0].step).toBe(1)
|
|
||||||
expect(back.links[0].label).toBe("HTTPS")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recovers a dashed edge", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
||||||
links: [{ source: "a", target: "b", dashed: true }],
|
|
||||||
})
|
|
||||||
expect(roundTrip(t).tree.links[0].dashed).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("drops an edge with a missing endpoint and reports it", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
||||||
links: [{ source: "a", target: "ghost" }],
|
|
||||||
})
|
|
||||||
const r = renderDiagram(t)
|
|
||||||
expect(r.danglingLinks).toEqual(["ghost"])
|
|
||||||
expect(r.xml).not.toContain('target="ghost"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("emits no waypoints, so draw.io re-routes when the user moves a node", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
||||||
links: [{ source: "a", target: "b", label: "x" }],
|
|
||||||
})
|
|
||||||
expect(renderDiagram(t).xml).not.toContain('as="points"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("an edge's style does not grow across repeated re-layouts", () => {
|
|
||||||
// The router recomputes the connection points every pass, and an edge recovered from
|
|
||||||
// the canvas already carries the previous pass's set. Appending them added 76
|
|
||||||
// characters per round-trip forever. draw.io resolves duplicate keys last-wins, so
|
|
||||||
// the arrow always LOOKED right — only measuring the string catches it.
|
|
||||||
let t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
||||||
links: [{ source: "a", target: "b" }],
|
|
||||||
})
|
|
||||||
const styleOfEdge = (xml: string) =>
|
|
||||||
/<mxCell id="ed1"[^>]*style="([^"]*)"/.exec(xml)?.[1] ?? ""
|
|
||||||
|
|
||||||
const lengths: number[] = []
|
|
||||||
const portCounts: number[] = []
|
|
||||||
let xml = ""
|
|
||||||
for (let pass = 0; pass < 4; pass++) {
|
|
||||||
xml = renderDiagram(t).xml
|
|
||||||
const s = styleOfEdge(xml)
|
|
||||||
lengths.push(s.length)
|
|
||||||
portCounts.push((s.match(/exitX=/g) ?? []).length)
|
|
||||||
t = parseDiagram(xml).tree
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same length every pass, and the port keys stated once rather than accumulating.
|
|
||||||
expect(new Set(lengths).size).toBe(1)
|
|
||||||
expect(portCounts).toEqual([1, 1, 1, 1])
|
|
||||||
// Which is what lets the XML itself reach a fixed point.
|
|
||||||
expect(renderDiagram(parseDiagram(xml).tree).xml).toBe(xml)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("foreign cells survive", () => {
|
|
||||||
it("re-emits an unrecognised cell verbatim", () => {
|
|
||||||
const custom =
|
|
||||||
'<mxCell id="note" value="hand-written note" style="shape=note;whiteSpace=wrap;html=1;fillColor=#FFF2CC;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="160" height="80" as="geometry"/></mxCell>'
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
||||||
foreign: [{ id: "note", xml: custom, parent: "1" }],
|
|
||||||
})
|
|
||||||
expect(renderDiagram(t).xml).toContain(custom)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-creates the boundaries layer when a foreign cell needs it", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
||||||
foreign: [
|
|
||||||
{
|
|
||||||
id: "cluster",
|
|
||||||
xml: '<mxCell id="cluster" value="EKS" style="dashed=1;fillColor=none;" vertex="1" parent="boundaries"><mxGeometry x="0" y="0" width="100" height="50" as="geometry"/></mxCell>',
|
|
||||||
parent: "boundaries",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
expect(xml).toContain('<mxCell id="boundaries"')
|
|
||||||
expect(xml.indexOf('id="boundaries"')).toBeLessThan(
|
|
||||||
xml.indexOf('parent="boundaries"'),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("lets an edge anchor to a foreign cell", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
||||||
foreign: [
|
|
||||||
{
|
|
||||||
id: "legend",
|
|
||||||
xml: '<mxCell id="legend" value="L" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="50" height="50" as="geometry"/></mxCell>',
|
|
||||||
parent: "1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [{ source: "a", target: "legend" }],
|
|
||||||
})
|
|
||||||
const r = renderDiagram(t)
|
|
||||||
expect(r.danglingLinks).toEqual([])
|
|
||||||
expect(r.xml).toContain('target="legend"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("the emitted XML is well formed for draw.io", () => {
|
|
||||||
const t = tree(
|
|
||||||
[
|
|
||||||
group(
|
|
||||||
"region",
|
|
||||||
"row",
|
|
||||||
[
|
|
||||||
group(
|
|
||||||
"vpc",
|
|
||||||
"col",
|
|
||||||
[icon("a"), icon("b")],
|
|
||||||
"VPC",
|
|
||||||
"group_vpc",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
"Region",
|
|
||||||
"group_region",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
{ title: "T", links: [{ source: "a", target: "b" }] },
|
|
||||||
)
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
|
|
||||||
it("wraps the model in mxfile/diagram", () => {
|
|
||||||
expect(xml.startsWith("<mxfile")).toBe(true)
|
|
||||||
expect(xml).toContain("<diagram")
|
|
||||||
expect(xml).toContain("<mxGraphModel")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("includes the two root cells draw.io requires", () => {
|
|
||||||
expect(xml).toContain('<mxCell id="0"/>')
|
|
||||||
expect(xml).toContain('<mxCell id="1" parent="0"/>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("declares a parent before any cell that references it", () => {
|
|
||||||
expect(xml.indexOf('id="region"')).toBeLessThan(
|
|
||||||
xml.indexOf('parent="region"'),
|
|
||||||
)
|
|
||||||
expect(xml.indexOf('id="vpc"')).toBeLessThan(
|
|
||||||
xml.indexOf('parent="vpc"'),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gives every cell a unique id", () => {
|
|
||||||
const ids = [...xml.matchAll(/<mxCell id="([^"]+)"/g)].map((m) => m[1])
|
|
||||||
expect(new Set(ids).size).toBe(ids.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("sets the page size from the content", () => {
|
|
||||||
const w = Number(xml.match(/pageWidth="(\d+)"/)?.[1])
|
|
||||||
const h = Number(xml.match(/pageHeight="(\d+)"/)?.[1])
|
|
||||||
expect(w).toBeGreaterThan(0)
|
|
||||||
expect(h).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("writes nested geometry relative to the parent, as draw.io expects", () => {
|
|
||||||
// vpc sits inside region, so its x must be small — an absolute x would push it
|
|
||||||
// outside the frame when draw.io adds the parent offset.
|
|
||||||
const vpcGeo = xml.match(
|
|
||||||
/<mxCell id="vpc"[^>]*>\s*<mxGeometry x="(-?\d+)"/,
|
|
||||||
)?.[1]
|
|
||||||
expect(Number(vpcGeo)).toBeLessThan(100)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("stamps container=1 on containers so drag-and-drop reparents correctly", () => {
|
|
||||||
// Verified in-browser: without this, a shape dragged into the frame keeps
|
|
||||||
// parent="1" and the nesting is lost.
|
|
||||||
const regionStyle =
|
|
||||||
xml.match(/<mxCell id="region"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
||||||
expect(regionStyle).toContain("container=1")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not stamp container=1 on a leaf", () => {
|
|
||||||
const iconStyle =
|
|
||||||
xml.match(/<mxCell id="a"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
||||||
expect(iconStyle).not.toContain("container=1")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("user edits are inputs, not conflicts", () => {
|
|
||||||
it("keeps a hand-changed fill through a re-layout", () => {
|
|
||||||
// The user recoloured a box in draw.io. Re-deriving the tree picks up the style
|
|
||||||
// verbatim, so re-rendering preserves the colour rather than resetting it.
|
|
||||||
const t = tree([group("f", "row", [icon("a"), box("b", "Box")], "F")])
|
|
||||||
const first = renderDiagram(t).xml
|
|
||||||
const edited = first.replace(
|
|
||||||
/(<mxCell id="b"[^>]*style=")([^"]*)"/,
|
|
||||||
'$1$2fillColor=#FF0000;"',
|
|
||||||
)
|
|
||||||
const back = parseDiagram(edited).tree
|
|
||||||
expect(renderDiagram(back).xml).toContain("fillColor=#FF0000")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-lays-out around a node the user dragged into a different frame", () => {
|
|
||||||
// Two frames; the user moves icon "b" from f1 into f2. draw.io rewrites the
|
|
||||||
// parent attribute (container=1 is in place), so the next layout puts it inside
|
|
||||||
// f2 — no reconciliation step, the canvas simply says where things are.
|
|
||||||
const t = tree([
|
|
||||||
group(
|
|
||||||
"root",
|
|
||||||
"row",
|
|
||||||
[
|
|
||||||
group("f1", "col", [icon("a"), icon("b")], "F1"),
|
|
||||||
group("f2", "col", [icon("c")], "F2"),
|
|
||||||
],
|
|
||||||
"Root",
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const first = renderDiagram(t).xml
|
|
||||||
const moved = first.replace(
|
|
||||||
/(<mxCell id="b"[^>]*)parent="f1"/,
|
|
||||||
'$1parent="f2"',
|
|
||||||
)
|
|
||||||
const back = parseDiagram(moved).tree
|
|
||||||
expect(findParent(back, "b")?.id).toBe("f2")
|
|
||||||
// and the re-render keeps it there, sized to fit
|
|
||||||
const again = parseDiagram(renderDiagram(back).xml).tree
|
|
||||||
expect(findParent(again, "b")?.id).toBe("f2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("honours a pin the user added by hand in Edit Style", () => {
|
|
||||||
const t = tree([box("pin", "Pinned"), box("flow", "Flow")])
|
|
||||||
const first = renderDiagram(t).xml
|
|
||||||
const pinned = first.replace(
|
|
||||||
/(<mxCell id="pin"[^>]*style="[^"]*)"/,
|
|
||||||
'$1dai_pin=1;"',
|
|
||||||
)
|
|
||||||
const back = parseDiagram(pinned).tree
|
|
||||||
const node = findNode(back, "pin") as BoxNode
|
|
||||||
expect(node.pinned).toBe(true)
|
|
||||||
const pos = node.rect
|
|
||||||
// re-rendering leaves the pinned node exactly where it was
|
|
||||||
const again = parseDiagram(renderDiagram(back).xml).tree
|
|
||||||
expect((findNode(again, "pin") as BoxNode).rect).toEqual(pos)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not lose a cell the user added by hand", () => {
|
|
||||||
const t = tree([group("f", "row", [icon("a")], "F")])
|
|
||||||
const first = renderDiagram(t).xml
|
|
||||||
// user drops a new shape on the canvas
|
|
||||||
const withNew = first.replace(
|
|
||||||
"</root>",
|
|
||||||
'<mxCell id="userbox" value="Mine" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="800" y="400" width="120" height="60" as="geometry"/></mxCell></root>',
|
|
||||||
)
|
|
||||||
const back = parseDiagram(withNew).tree
|
|
||||||
const ids = [...walkTree(back)].map((n) => n.id)
|
|
||||||
expect(ids).toContain("userbox")
|
|
||||||
expect(renderDiagram(back).xml).toContain('id="userbox"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
/**
|
|
||||||
* Edge routing.
|
|
||||||
*
|
|
||||||
* The property that matters is geometric: a routed path must not run through an icon it
|
|
||||||
* does not connect to, and two edges must not leave the same side of a node at the same
|
|
||||||
* point. These tests check the produced geometry rather than the shape of the algorithm,
|
|
||||||
* so a different routing strategy that still satisfies them would pass.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { renderDiagram } from "@/lib/diagram-engine/render"
|
|
||||||
import { routeEdges } from "@/lib/diagram-engine/route"
|
|
||||||
import type {
|
|
||||||
DiagramNode,
|
|
||||||
DiagramTree,
|
|
||||||
GroupNode,
|
|
||||||
IconNode,
|
|
||||||
Rect,
|
|
||||||
} from "@/lib/diagram-engine/types"
|
|
||||||
|
|
||||||
const rect = (x: number, y: number, w = 48, h = 48): Rect => ({ x, y, w, h })
|
|
||||||
|
|
||||||
/** Absolute path of a routed edge: exit port → waypoints → entry port. */
|
|
||||||
function pathOf(
|
|
||||||
route: {
|
|
||||||
exit: { x: number; y: number }
|
|
||||||
entry: { x: number; y: number }
|
|
||||||
waypoints: { x: number; y: number }[]
|
|
||||||
},
|
|
||||||
src: Rect,
|
|
||||||
tgt: Rect,
|
|
||||||
): { x: number; y: number }[] {
|
|
||||||
return [
|
|
||||||
{ x: src.x + route.exit.x * src.w, y: src.y + route.exit.y * src.h },
|
|
||||||
...route.waypoints,
|
|
||||||
{ x: tgt.x + route.entry.x * tgt.w, y: tgt.y + route.entry.y * tgt.h },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Does a segment cross this rect's core (not merely graze its edge)? */
|
|
||||||
function segCrosses(
|
|
||||||
a: { x: number; y: number },
|
|
||||||
b: { x: number; y: number },
|
|
||||||
r: Rect,
|
|
||||||
): boolean {
|
|
||||||
const inset = Math.min(r.w, r.h) * 0.25
|
|
||||||
return (
|
|
||||||
Math.max(a.x, b.x) > r.x + inset &&
|
|
||||||
Math.min(a.x, b.x) < r.x + r.w - inset &&
|
|
||||||
Math.max(a.y, b.y) > r.y + inset &&
|
|
||||||
Math.min(a.y, b.y) < r.y + r.h - inset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathCrosses(path: { x: number; y: number }[], r: Rect): boolean {
|
|
||||||
for (let i = 0; i < path.length - 1; i++)
|
|
||||||
if (segCrosses(path[i], path[i + 1], r)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("connection points are always pinned", () => {
|
|
||||||
const rects = new Map([
|
|
||||||
["a", rect(0, 0)],
|
|
||||||
["b", rect(300, 0)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e1", source: "a", target: "b", hasLabel: false }],
|
|
||||||
rects,
|
|
||||||
new Set(["a", "b"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
it("emits an exit and an entry for every edge", () => {
|
|
||||||
// Without these draw.io picks the side itself, knowing nothing about the other
|
|
||||||
// icons on the page.
|
|
||||||
expect(routes).toHaveLength(1)
|
|
||||||
expect(routes[0].exit).toBeDefined()
|
|
||||||
expect(routes[0].entry).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves from the side facing the target", () => {
|
|
||||||
// b is to the right of a, so the arrow should exit a's right edge and enter b's left.
|
|
||||||
expect(routes[0].exit.x).toBe(1)
|
|
||||||
expect(routes[0].entry.x).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses fractions, so draw.io recomputes them when a node is dragged", () => {
|
|
||||||
for (const v of [
|
|
||||||
routes[0].exit.x,
|
|
||||||
routes[0].exit.y,
|
|
||||||
routes[0].entry.x,
|
|
||||||
routes[0].entry.y,
|
|
||||||
]) {
|
|
||||||
expect(v).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(v).toBeLessThanOrEqual(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("picks the vertical sides when the target is below", () => {
|
|
||||||
const r = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "c", hasLabel: false }],
|
|
||||||
new Map([
|
|
||||||
["a", rect(0, 0)],
|
|
||||||
["c", rect(0, 300)],
|
|
||||||
]),
|
|
||||||
new Set(["a", "c"]),
|
|
||||||
)
|
|
||||||
expect(r[0].exit.y).toBe(1) // leaves the bottom
|
|
||||||
expect(r[0].entry.y).toBe(0) // enters the top
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves leftwards when the target is to the left", () => {
|
|
||||||
const r = routeEdges(
|
|
||||||
[{ id: "e", source: "b", target: "a", hasLabel: false }],
|
|
||||||
rects,
|
|
||||||
new Set(["a", "b"]),
|
|
||||||
)
|
|
||||||
expect(r[0].exit.x).toBe(0)
|
|
||||||
expect(r[0].entry.x).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("ports on the same side are de-collided", () => {
|
|
||||||
it("spreads three edges leaving one node's right side", () => {
|
|
||||||
// This is the case from the reported screenshot: two edges left the same EC2 icon
|
|
||||||
// at the same point and overlapped on top of it.
|
|
||||||
const rects = new Map([
|
|
||||||
["hub", rect(0, 200)],
|
|
||||||
["t1", rect(400, 0)],
|
|
||||||
["t2", rect(400, 200)],
|
|
||||||
["t3", rect(400, 400)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "hub", target: "t1", hasLabel: false },
|
|
||||||
{ id: "e2", source: "hub", target: "t2", hasLabel: false },
|
|
||||||
{ id: "e3", source: "hub", target: "t3", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
new Set(["hub", "t1", "t2", "t3"]),
|
|
||||||
)
|
|
||||||
const ys = routes.map((r) => r.exit.y)
|
|
||||||
expect(new Set(ys).size).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps the edge that has a straight shot on the centre line", () => {
|
|
||||||
// t2 is level with hub, so that arrow can run straight; bending it to make room
|
|
||||||
// for the others would be the wrong trade.
|
|
||||||
const rects = new Map([
|
|
||||||
["hub", rect(0, 200)],
|
|
||||||
["t1", rect(400, 0)],
|
|
||||||
["t2", rect(400, 200)],
|
|
||||||
["t3", rect(400, 400)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "hub", target: "t1", hasLabel: false },
|
|
||||||
{ id: "e2", source: "hub", target: "t2", hasLabel: false },
|
|
||||||
{ id: "e3", source: "hub", target: "t3", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
new Set(["hub", "t1", "t2", "t3"]),
|
|
||||||
)
|
|
||||||
expect(routes[1].exit.y).toBe(0.5)
|
|
||||||
expect(routes[0].exit.y).not.toBe(0.5)
|
|
||||||
expect(routes[2].exit.y).not.toBe(0.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("orders the spread so the edges do not cross on the way out", () => {
|
|
||||||
// The target that sits highest should leave from the highest port.
|
|
||||||
const rects = new Map([
|
|
||||||
["hub", rect(0, 200)],
|
|
||||||
["top", rect(400, 0)],
|
|
||||||
["bottom", rect(400, 400)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "hub", target: "bottom", hasLabel: false },
|
|
||||||
{ id: "e2", source: "hub", target: "top", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
new Set(["hub", "top", "bottom"]),
|
|
||||||
)
|
|
||||||
// e2 goes up, so its exit must be above e1's
|
|
||||||
expect(routes[1].exit.y).toBeLessThan(routes[0].exit.y)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("spreads fan-in on the target side too", () => {
|
|
||||||
const rects = new Map([
|
|
||||||
["s1", rect(0, 0)],
|
|
||||||
["s2", rect(0, 200)],
|
|
||||||
["s3", rect(0, 400)],
|
|
||||||
["sink", rect(400, 200)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "s1", target: "sink", hasLabel: false },
|
|
||||||
{ id: "e2", source: "s2", target: "sink", hasLabel: false },
|
|
||||||
{ id: "e3", source: "s3", target: "sink", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
new Set(["s1", "s2", "s3", "sink"]),
|
|
||||||
)
|
|
||||||
expect(new Set(routes.map((r) => r.entry.y)).size).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("leaves a single edge centred", () => {
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "b", hasLabel: false }],
|
|
||||||
new Map([
|
|
||||||
["a", rect(0, 0)],
|
|
||||||
["b", rect(300, 0)],
|
|
||||||
]),
|
|
||||||
new Set(["a", "b"]),
|
|
||||||
)
|
|
||||||
expect(routes[0].exit.y).toBe(0.5)
|
|
||||||
expect(routes[0].entry.y).toBe(0.5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("routes avoid icons they do not connect to", () => {
|
|
||||||
it("bends around an icon sitting on the straight line", () => {
|
|
||||||
// a — blocker — b, all level. A straight line would run through the blocker.
|
|
||||||
const rects = new Map([
|
|
||||||
["a", rect(0, 100)],
|
|
||||||
["blocker", rect(200, 100)],
|
|
||||||
["b", rect(400, 100)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "b", hasLabel: false }],
|
|
||||||
rects,
|
|
||||||
new Set(["a", "blocker", "b"]),
|
|
||||||
)
|
|
||||||
const path = pathOf(
|
|
||||||
routes[0],
|
|
||||||
rects.get("a") as Rect,
|
|
||||||
rects.get("b") as Rect,
|
|
||||||
)
|
|
||||||
expect(pathCrosses(path, rects.get("blocker") as Rect)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("freezes the waypoints of a deliberate detour", () => {
|
|
||||||
// A re-route from the pins alone would put the path back through the blocker, so
|
|
||||||
// this is one of the two cases where the waypoints have to survive.
|
|
||||||
const rects = new Map([
|
|
||||||
["a", rect(0, 100)],
|
|
||||||
["blocker", rect(200, 100)],
|
|
||||||
["b", rect(400, 100)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "b", hasLabel: false }],
|
|
||||||
rects,
|
|
||||||
new Set(["a", "blocker", "b"]),
|
|
||||||
)
|
|
||||||
expect(routes[0].waypoints.length).toBeGreaterThan(0)
|
|
||||||
expect(routes[0].freeze).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does NOT freeze a clear straight run", () => {
|
|
||||||
// Nothing in the way, so leave the route to draw.io and keep the edge
|
|
||||||
// drag-friendly.
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "b", hasLabel: false }],
|
|
||||||
new Map([
|
|
||||||
["a", rect(0, 0)],
|
|
||||||
["b", rect(300, 0)],
|
|
||||||
]),
|
|
||||||
new Set(["a", "b"]),
|
|
||||||
)
|
|
||||||
expect(routes[0].waypoints).toEqual([])
|
|
||||||
expect(routes[0].freeze).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("freezes a labelled bend so the label lands on a straight segment", () => {
|
|
||||||
const rects = new Map([
|
|
||||||
["a", rect(0, 0)],
|
|
||||||
["b", rect(400, 300)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "b", hasLabel: true }],
|
|
||||||
rects,
|
|
||||||
new Set(["a", "b"]),
|
|
||||||
)
|
|
||||||
if (routes[0].waypoints.length > 0) expect(routes[0].freeze).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps clear of several icons in a row", () => {
|
|
||||||
const rects = new Map([
|
|
||||||
["a", rect(0, 200)],
|
|
||||||
["x1", rect(150, 200)],
|
|
||||||
["x2", rect(300, 200)],
|
|
||||||
["x3", rect(450, 200)],
|
|
||||||
["b", rect(600, 200)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "b", hasLabel: false }],
|
|
||||||
rects,
|
|
||||||
new Set(["a", "x1", "x2", "x3", "b"]),
|
|
||||||
)
|
|
||||||
const path = pathOf(
|
|
||||||
routes[0],
|
|
||||||
rects.get("a") as Rect,
|
|
||||||
rects.get("b") as Rect,
|
|
||||||
)
|
|
||||||
for (const id of ["x1", "x2", "x3"])
|
|
||||||
expect(pathCrosses(path, rects.get(id) as Rect)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats a container frame as passable, not an obstacle", () => {
|
|
||||||
// An arrow from outside a VPC to something inside it has to cross the border.
|
|
||||||
const rects = new Map([
|
|
||||||
["outside", rect(0, 100)],
|
|
||||||
["vpc", { x: 200, y: 0, w: 400, h: 300 }],
|
|
||||||
["inside", rect(350, 100)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "outside", target: "inside", hasLabel: false }],
|
|
||||||
rects,
|
|
||||||
// vpc deliberately absent from the obstacle set
|
|
||||||
new Set(["outside", "inside"]),
|
|
||||||
)
|
|
||||||
// A straight shot is available and should be taken.
|
|
||||||
expect(routes[0].waypoints).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("parallel segments are separated", () => {
|
|
||||||
it("does not leave two detours stacked on the same track", () => {
|
|
||||||
// Two edges that both have to bend around the same column of icons would
|
|
||||||
// otherwise pick the same corridor lane and overlap for its whole length.
|
|
||||||
const rects = new Map([
|
|
||||||
["a1", rect(0, 100)],
|
|
||||||
["a2", rect(0, 250)],
|
|
||||||
["blocker1", rect(250, 100)],
|
|
||||||
["blocker2", rect(250, 250)],
|
|
||||||
["b1", rect(500, 100)],
|
|
||||||
["b2", rect(500, 250)],
|
|
||||||
])
|
|
||||||
const routes = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "a1", target: "b1", hasLabel: false },
|
|
||||||
{ id: "e2", source: "a2", target: "b2", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
new Set(["a1", "a2", "blocker1", "blocker2", "b1", "b2"]),
|
|
||||||
)
|
|
||||||
// Collect the vertical lanes each route uses.
|
|
||||||
const lanes = routes.flatMap((r) =>
|
|
||||||
r.waypoints.map((p) => p.x).filter((x) => x !== undefined),
|
|
||||||
)
|
|
||||||
if (lanes.length >= 2) {
|
|
||||||
// No two lanes may sit within a few pixels of each other.
|
|
||||||
for (let i = 0; i < lanes.length; i++)
|
|
||||||
for (let j = i + 1; j < lanes.length; j++)
|
|
||||||
if (Math.abs(lanes[i] - lanes[j]) < 6)
|
|
||||||
expect(lanes[i]).toBe(lanes[j]) // same lane of one route is fine
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("produces the same result regardless of declaration order", () => {
|
|
||||||
// The nudge pass is global, so routing must not depend on the order link() was
|
|
||||||
// called in.
|
|
||||||
const rects = new Map([
|
|
||||||
["hub", rect(0, 200)],
|
|
||||||
["t1", rect(400, 0)],
|
|
||||||
["t2", rect(400, 200)],
|
|
||||||
["t3", rect(400, 400)],
|
|
||||||
])
|
|
||||||
const obstacles = new Set(["hub", "t1", "t2", "t3"])
|
|
||||||
const forward = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "hub", target: "t1", hasLabel: false },
|
|
||||||
{ id: "e2", source: "hub", target: "t2", hasLabel: false },
|
|
||||||
{ id: "e3", source: "hub", target: "t3", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
obstacles,
|
|
||||||
)
|
|
||||||
const reversed = routeEdges(
|
|
||||||
[
|
|
||||||
{ id: "e3", source: "hub", target: "t3", hasLabel: false },
|
|
||||||
{ id: "e2", source: "hub", target: "t2", hasLabel: false },
|
|
||||||
{ id: "e1", source: "hub", target: "t1", hasLabel: false },
|
|
||||||
],
|
|
||||||
rects,
|
|
||||||
obstacles,
|
|
||||||
)
|
|
||||||
const byId = (rs: typeof forward) =>
|
|
||||||
new Map(rs.map((r) => [r.id, JSON.stringify(r)]))
|
|
||||||
const f = byId(forward)
|
|
||||||
const r = byId(reversed)
|
|
||||||
for (const id of ["e1", "e2", "e3"]) expect(r.get(id)).toBe(f.get(id))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("degenerate input", () => {
|
|
||||||
it("returns a usable route when a terminal is missing", () => {
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "ghost", target: "b", hasLabel: false }],
|
|
||||||
new Map([["b", rect(0, 0)]]),
|
|
||||||
new Set(["b"]),
|
|
||||||
)
|
|
||||||
expect(routes).toHaveLength(1)
|
|
||||||
expect(routes[0].exit).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles a self-loop without hanging", () => {
|
|
||||||
const routes = routeEdges(
|
|
||||||
[{ id: "e", source: "a", target: "a", hasLabel: false }],
|
|
||||||
new Map([["a", rect(0, 0)]]),
|
|
||||||
new Set(["a"]),
|
|
||||||
)
|
|
||||||
expect(routes).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles no edges", () => {
|
|
||||||
expect(routeEdges([], new Map(), new Set())).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("routes 40 edges without blowing up", () => {
|
|
||||||
const rects = new Map<string, Rect>()
|
|
||||||
for (let i = 0; i < 40; i++)
|
|
||||||
rects.set(`n${i}`, rect((i % 8) * 120, Math.floor(i / 8) * 120))
|
|
||||||
const edges = []
|
|
||||||
for (let i = 0; i < 39; i++)
|
|
||||||
edges.push({
|
|
||||||
id: `e${i}`,
|
|
||||||
source: `n${i}`,
|
|
||||||
target: `n${i + 1}`,
|
|
||||||
hasLabel: false,
|
|
||||||
})
|
|
||||||
const routes = routeEdges(edges, rects, new Set(rects.keys()))
|
|
||||||
expect(routes).toHaveLength(39)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("the rendered XML carries the route", () => {
|
|
||||||
const icon = (id: string, label = ""): IconNode => ({
|
|
||||||
kind: "icon",
|
|
||||||
id,
|
|
||||||
name: "ec2",
|
|
||||||
label,
|
|
||||||
})
|
|
||||||
const tree = (
|
|
||||||
roots: DiagramNode[],
|
|
||||||
links: DiagramTree["links"],
|
|
||||||
): DiagramTree => ({
|
|
||||||
roots,
|
|
||||||
links,
|
|
||||||
foreign: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
it("writes exitX/entryX into the edge style", () => {
|
|
||||||
const t = tree(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "f",
|
|
||||||
gname: null,
|
|
||||||
label: "F",
|
|
||||||
dir: "row",
|
|
||||||
gap: 60,
|
|
||||||
children: [icon("a"), icon("b")],
|
|
||||||
} as GroupNode,
|
|
||||||
],
|
|
||||||
[{ source: "a", target: "b" }],
|
|
||||||
)
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
const edge = xml.match(/<mxCell[^>]*edge="1"[^>]*>/)?.[0] ?? ""
|
|
||||||
expect(edge).toContain("exitX=")
|
|
||||||
expect(edge).toContain("exitY=")
|
|
||||||
expect(edge).toContain("entryX=")
|
|
||||||
expect(edge).toContain("entryY=")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("writes no waypoints for an unobstructed unlabelled edge", () => {
|
|
||||||
const t = tree(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "f",
|
|
||||||
gname: null,
|
|
||||||
label: "F",
|
|
||||||
dir: "row",
|
|
||||||
gap: 60,
|
|
||||||
children: [icon("a"), icon("b")],
|
|
||||||
} as GroupNode,
|
|
||||||
],
|
|
||||||
[{ source: "a", target: "b" }],
|
|
||||||
)
|
|
||||||
expect(renderDiagram(t).xml).not.toContain('as="points"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("writes waypoints when the router had to bend around an icon", () => {
|
|
||||||
// Three icons in a row; the arrow skips the middle one.
|
|
||||||
const t = tree(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "f",
|
|
||||||
gname: null,
|
|
||||||
label: "F",
|
|
||||||
dir: "row",
|
|
||||||
gap: 60,
|
|
||||||
children: [icon("a"), icon("mid"), icon("b")],
|
|
||||||
} as GroupNode,
|
|
||||||
],
|
|
||||||
[{ source: "a", target: "b" }],
|
|
||||||
)
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
expect(xml).toContain('as="points"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not route an arrow through a sibling icon", () => {
|
|
||||||
const t = tree(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "f",
|
|
||||||
gname: null,
|
|
||||||
label: "F",
|
|
||||||
dir: "row",
|
|
||||||
gap: 60,
|
|
||||||
children: [icon("a"), icon("mid"), icon("b")],
|
|
||||||
} as GroupNode,
|
|
||||||
],
|
|
||||||
[{ source: "a", target: "b" }],
|
|
||||||
)
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
// Pull the geometry back out and check it against the middle icon.
|
|
||||||
const geo = (id: string): Rect | null => {
|
|
||||||
const m = xml.match(
|
|
||||||
new RegExp(
|
|
||||||
`<mxCell id="${id}"[^>]*>\\s*<mxGeometry x="(-?\\d+)" y="(-?\\d+)" width="(\\d+)" height="(\\d+)"`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!m) return null
|
|
||||||
// These are parent-relative; the frame's own origin has to be added back.
|
|
||||||
const f = xml.match(
|
|
||||||
/<mxCell id="f"[^>]*>\s*<mxGeometry x="(-?\d+)" y="(-?\d+)"/,
|
|
||||||
)
|
|
||||||
const ox = f ? Number(f[1]) : 0
|
|
||||||
const oy = f ? Number(f[2]) : 0
|
|
||||||
return {
|
|
||||||
x: Number(m[1]) + ox,
|
|
||||||
y: Number(m[2]) + oy,
|
|
||||||
w: Number(m[3]),
|
|
||||||
h: Number(m[4]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mid = geo("mid")
|
|
||||||
const pts = [
|
|
||||||
...xml.matchAll(/<mxPoint x="(-?\d+)" y="(-?\d+)"\/>/g),
|
|
||||||
].map((m) => ({ x: Number(m[1]), y: Number(m[2]) }))
|
|
||||||
expect(mid).not.toBeNull()
|
|
||||||
if (mid && pts.length >= 2) {
|
|
||||||
// The corridor the route uses must be clear of the middle icon.
|
|
||||||
for (const p of pts) {
|
|
||||||
const insideX = p.x > mid.x && p.x < mid.x + mid.w
|
|
||||||
const insideY = p.y > mid.y && p.y < mid.y + mid.h
|
|
||||||
expect(insideX && insideY).toBe(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("spreads a fan-out so the arrows do not stack on one point", () => {
|
|
||||||
// The screenshot's failure mode, checked through the full render path.
|
|
||||||
const t = tree(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "f",
|
|
||||||
gname: null,
|
|
||||||
label: "F",
|
|
||||||
dir: "row",
|
|
||||||
gap: 120,
|
|
||||||
children: [
|
|
||||||
icon("hub"),
|
|
||||||
{
|
|
||||||
kind: "group",
|
|
||||||
id: "col",
|
|
||||||
gname: null,
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 60,
|
|
||||||
children: [icon("t1"), icon("t2"), icon("t3")],
|
|
||||||
} as GroupNode,
|
|
||||||
],
|
|
||||||
} as GroupNode,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ source: "hub", target: "t1" },
|
|
||||||
{ source: "hub", target: "t2" },
|
|
||||||
{ source: "hub", target: "t3" },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
const { xml } = renderDiagram(t)
|
|
||||||
const exits = [...xml.matchAll(/exitX=([\d.]+);exitY=([\d.]+)/g)].map(
|
|
||||||
(m) => `${m[1]},${m[2]}`,
|
|
||||||
)
|
|
||||||
expect(exits).toHaveLength(3)
|
|
||||||
expect(new Set(exits).size).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import type { SequenceNode } from "@/lib/diagram-engine/types"
|
|
||||||
import { findNode } from "@/lib/diagram-engine/types"
|
|
||||||
import {
|
|
||||||
absoluteRects,
|
|
||||||
escapesParent,
|
|
||||||
outsidePage,
|
|
||||||
rectOf,
|
|
||||||
} from "./fixtures/geometry"
|
|
||||||
|
|
||||||
const LOGIN: Operation[] = [
|
|
||||||
{ op: "add_sequence", id: "s", label: "Login flow" },
|
|
||||||
{ op: "add_box", id: "u", parent: "s", label: "User" },
|
|
||||||
{ op: "add_box", id: "web", parent: "s", label: "Web App" },
|
|
||||||
{ op: "add_box", id: "auth", parent: "s", label: "Auth Service" },
|
|
||||||
{ op: "add_box", id: "db", parent: "s", label: "Database" },
|
|
||||||
{ op: "link", source: "u", target: "web", label: "credentials", step: 1 },
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "web",
|
|
||||||
target: "auth",
|
|
||||||
label: "POST /login",
|
|
||||||
step: 2,
|
|
||||||
},
|
|
||||||
{ op: "link", source: "auth", target: "db", label: "find user", step: 3 },
|
|
||||||
{ op: "link", source: "db", target: "auth", label: "user record", step: 4 },
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "auth",
|
|
||||||
target: "auth",
|
|
||||||
label: "sign token",
|
|
||||||
step: 5,
|
|
||||||
},
|
|
||||||
{ op: "link", source: "auth", target: "web", label: "JWT", step: 6 },
|
|
||||||
{ op: "link", source: "web", target: "u", label: "redirect", step: 7 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const PARTICIPANTS = ["u", "web", "auth", "db"]
|
|
||||||
|
|
||||||
/** Each message's y, in the order the cells were written. */
|
|
||||||
function messageYs(xml: string): { id: string; from: number; to: number }[] {
|
|
||||||
return [
|
|
||||||
...xml.matchAll(
|
|
||||||
/<mxCell id="(ed\d+)"[\s\S]*?<mxPoint x="-?[\d.]+" y="(-?[\d.]+)" as="sourcePoint"\/><mxPoint x="-?[\d.]+" y="(-?[\d.]+)" as="targetPoint"\/>/g,
|
|
||||||
),
|
|
||||||
].map((m) => ({ id: m[1], from: Number(m[2]), to: Number(m[3]) }))
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("sequence diagram: layout", () => {
|
|
||||||
const result = restructureDiagram("", LOGIN)
|
|
||||||
const xml = result.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
|
|
||||||
it("builds without errors", () => {
|
|
||||||
expect(result.errors).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts the participants in a row, in declaration order", () => {
|
|
||||||
const xs = PARTICIPANTS.map((id) => rectOf(rects, id).x)
|
|
||||||
expect(xs).toEqual([...xs].sort((a, b) => a - b))
|
|
||||||
// All the heads share a top edge.
|
|
||||||
const ys = PARTICIPANTS.map((id) => rectOf(rects, id).y)
|
|
||||||
expect(new Set(ys).size).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws each participant as a lifeline, head and line in one cell", () => {
|
|
||||||
// One cell so draw.io keeps them together when the user drags the participant.
|
|
||||||
for (const id of PARTICIPANTS)
|
|
||||||
expect(xml).toMatch(
|
|
||||||
new RegExp(`id="${id}"[^>]*shape=umlLifeline[^>]*size=\\d+`),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("makes the lifelines long enough for every message", () => {
|
|
||||||
const lowest = Math.max(...messageYs(xml).map((m) => m.to))
|
|
||||||
for (const id of PARTICIPANTS) {
|
|
||||||
const r = rects.get(id) as { y: number; h: number }
|
|
||||||
expect(r.y + r.h).toBeGreaterThan(lowest)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("orders the messages down the page by step number", () => {
|
|
||||||
const ys = messageYs(xml)
|
|
||||||
expect(ys.length).toBe(7)
|
|
||||||
const tops = ys.map((m) => Math.min(m.from, m.to))
|
|
||||||
expect(tops).toEqual([...tops].sort((a, b) => a - b))
|
|
||||||
// No two messages on the same line.
|
|
||||||
expect(new Set(tops).size).toBe(7)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("draws a message as a horizontal line between two lifelines", () => {
|
|
||||||
const ys = messageYs(xml)
|
|
||||||
// Every message except the self-call is level.
|
|
||||||
const level = ys.filter((m) => m.from === m.to)
|
|
||||||
expect(level.length).toBe(6)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("steps a self-message out and back a row lower", () => {
|
|
||||||
const self = messageYs(xml).find((m) => m.from !== m.to)
|
|
||||||
expect(self).toBeDefined()
|
|
||||||
expect((self as { to: number }).to).toBeGreaterThan(
|
|
||||||
(self as { from: number }).from,
|
|
||||||
)
|
|
||||||
// Two waypoints take it out to the side and back.
|
|
||||||
expect(xml).toMatch(
|
|
||||||
/id="ed5"[\s\S]*?<Array as="points"><mxPoint[^>]*\/><mxPoint[^>]*\/><\/Array>/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps everything inside the page and inside its parent", () => {
|
|
||||||
expect(outsidePage(xml, ["__title"])).toEqual([])
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("numbers unnumbered messages in declaration order", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_sequence", id: "s", label: "" },
|
|
||||||
{ op: "add_box", id: "a", parent: "s", label: "A" },
|
|
||||||
{ op: "add_box", id: "b", parent: "s", label: "B" },
|
|
||||||
{ op: "link", source: "a", target: "b", label: "first" },
|
|
||||||
{ op: "link", source: "b", target: "a", label: "second" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const ys = messageYs(r.xml as string)
|
|
||||||
expect(ys.length).toBe(2)
|
|
||||||
expect(ys[0].from).toBeLessThan(ys[1].from)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows several messages between the same two participants", () => {
|
|
||||||
// A back-and-forth conversation is the norm here, so the duplicate-edge guard that
|
|
||||||
// protects other diagram kinds must not apply.
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_sequence", id: "s", label: "" },
|
|
||||||
{ op: "add_box", id: "a", parent: "s", label: "A" },
|
|
||||||
{ op: "add_box", id: "b", parent: "s", label: "B" },
|
|
||||||
{ op: "link", source: "a", target: "b", label: "ask", step: 1 },
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "a",
|
|
||||||
target: "b",
|
|
||||||
label: "ask again",
|
|
||||||
step: 2,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(messageYs(r.xml as string).length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still rejects a duplicate edge outside a sequence diagram", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "a", label: "A" },
|
|
||||||
{ op: "add_box", id: "b", label: "B" },
|
|
||||||
{ op: "link", source: "a", target: "b" },
|
|
||||||
{ op: "link", source: "a", target: "b" },
|
|
||||||
])
|
|
||||||
expect(r.errors[0]).toContain("already exists")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("removes one message by step, leaving the others", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_sequence", id: "s", label: "" },
|
|
||||||
{ op: "add_box", id: "a", parent: "s", label: "A" },
|
|
||||||
{ op: "add_box", id: "b", parent: "s", label: "B" },
|
|
||||||
{ op: "link", source: "a", target: "b", label: "one", step: 1 },
|
|
||||||
{ op: "link", source: "a", target: "b", label: "two", step: 2 },
|
|
||||||
{ op: "unlink", source: "a", target: "b", step: 1 },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.outline).toContain("two")
|
|
||||||
expect(r.outline).not.toContain("one")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps two sequence diagrams on one page independent", () => {
|
|
||||||
// The fallback numbering has to restart per container, or the second diagram's
|
|
||||||
// messages continue the first one's count and fall below its own lifelines.
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_sequence", id: "s1", label: "First" },
|
|
||||||
{ op: "add_box", id: "a", parent: "s1", label: "A" },
|
|
||||||
{ op: "add_box", id: "b", parent: "s1", label: "B" },
|
|
||||||
{ op: "link", source: "a", target: "b" },
|
|
||||||
{ op: "add_sequence", id: "s2", label: "Second" },
|
|
||||||
{ op: "add_box", id: "c", parent: "s2", label: "C" },
|
|
||||||
{ op: "add_box", id: "d", parent: "s2", label: "D" },
|
|
||||||
{ op: "link", source: "c", target: "d" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(escapesParent(r.xml as string)).toEqual([])
|
|
||||||
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("sequence diagram: round-trip", () => {
|
|
||||||
it("comes back as a sequence with the same participants", () => {
|
|
||||||
const first = restructureDiagram("", LOGIN)
|
|
||||||
const { tree, warnings } = parseDiagram(first.xml as string)
|
|
||||||
expect(warnings).toEqual([])
|
|
||||||
const seq = findNode(tree, "s") as SequenceNode
|
|
||||||
expect(seq.kind).toBe("sequence")
|
|
||||||
expect(seq.children.map((c) => c.id)).toEqual(PARTICIPANTS)
|
|
||||||
expect(seq.label).toBe("Login flow")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps every message, including the repeat pair and the self-call", () => {
|
|
||||||
const first = restructureDiagram("", LOGIN)
|
|
||||||
const { tree } = parseDiagram(first.xml as string)
|
|
||||||
expect(tree.links.length).toBe(7)
|
|
||||||
expect(
|
|
||||||
tree.links.some((l) => l.source === "auth" && l.target === "auth"),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not move anything on a re-layout", () => {
|
|
||||||
const first = restructureDiagram("", LOGIN)
|
|
||||||
const second = restructureDiagram(first.xml as string, [])
|
|
||||||
expect(second.errors).toEqual([])
|
|
||||||
expect(second.warnings).toEqual([])
|
|
||||||
const a = absoluteRects(first.xml as string)
|
|
||||||
const b = absoluteRects(second.xml as string)
|
|
||||||
for (const id of PARTICIPANTS) expect(b.get(id)).toEqual(a.get(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not let the lifelines grow on every pass", () => {
|
|
||||||
// A lifeline's cell covers the head AND the line, so reading its full height back as
|
|
||||||
// the participant's own size would make it taller each time.
|
|
||||||
const first = restructureDiagram("", LOGIN)
|
|
||||||
const second = restructureDiagram(first.xml as string, [])
|
|
||||||
const third = restructureDiagram(second.xml as string, [])
|
|
||||||
const h = (xml: string) => rectOf(absoluteRects(xml), "u").h
|
|
||||||
expect(h(second.xml as string)).toBe(h(first.xml as string))
|
|
||||||
expect(h(third.xml as string)).toBe(h(first.xml as string))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds a participant to an existing diagram without redrawing it", () => {
|
|
||||||
const first = restructureDiagram("", LOGIN)
|
|
||||||
const second = restructureDiagram(first.xml as string, [
|
|
||||||
{ op: "add_box", id: "cache", parent: "s", label: "Cache" },
|
|
||||||
{
|
|
||||||
op: "link",
|
|
||||||
source: "auth",
|
|
||||||
target: "cache",
|
|
||||||
label: "check",
|
|
||||||
step: 8,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(second.errors).toEqual([])
|
|
||||||
const rects = absoluteRects(second.xml as string)
|
|
||||||
expect(rects.has("cache")).toBe(true)
|
|
||||||
// The new participant joins the row rather than landing on top of another.
|
|
||||||
expect(rectOf(rects, "cache").x).toBeGreaterThan(rectOf(rects, "db").x)
|
|
||||||
expect(escapesParent(second.xml as string)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { autoBoxSize } from "@/lib/diagram-engine/layout"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import {
|
|
||||||
mergeStyle,
|
|
||||||
nearestShape,
|
|
||||||
resolveShape,
|
|
||||||
} from "@/lib/diagram-engine/shapes"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The open shape vocabulary: catalog shapes fully understood, anything else passed
|
|
||||||
* through. What these tests pin down is not the happy path but the failure modes the
|
|
||||||
* design review flagged: silent degradation without feedback, round-trips freezing
|
|
||||||
* measurements, appearance-based reverse mapping dropping declarations, and string
|
|
||||||
* concatenation emitting contradictory style keys.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const styleOf = (xml: string, id: string): string =>
|
|
||||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))?.[1] ?? ""
|
|
||||||
|
|
||||||
describe("style merge ownership", () => {
|
|
||||||
it("keeps one token per key, later fragments winning", () => {
|
|
||||||
const out = mergeStyle(
|
|
||||||
"rounded=0;fillColor=#FFF;",
|
|
||||||
"rounded=1;arcSize=50;",
|
|
||||||
)
|
|
||||||
expect(out.match(/rounded=/g)).toHaveLength(1)
|
|
||||||
expect(out).toContain("rounded=1")
|
|
||||||
expect(out).toContain("fillColor=#FFF")
|
|
||||||
})
|
|
||||||
it("a later shape class displaces an earlier bare class", () => {
|
|
||||||
expect(mergeStyle("rhombus;", "ellipse;")).not.toContain("rhombus")
|
|
||||||
// and an explicit shape= displaces a bare class too
|
|
||||||
const out = mergeStyle("rhombus;", "shape=cloud;")
|
|
||||||
expect(out).not.toContain("rhombus")
|
|
||||||
expect(out).toContain("shape=cloud")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("shape resolution", () => {
|
|
||||||
it("catalog shapes carry their perimeter", () => {
|
|
||||||
expect(resolveShape("decision")?.spec.style).toContain(
|
|
||||||
"perimeter=rhombusPerimeter",
|
|
||||||
)
|
|
||||||
expect(resolveShape("hexagon")?.spec.style).toContain(
|
|
||||||
"perimeter=hexagonPerimeter2",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
it("unknown-but-safe tokens pass through; injection-capable ones are rejected", () => {
|
|
||||||
const pass = resolveShape("mxgraph.flowchart.or")
|
|
||||||
expect(pass?.passthrough).toBe(true)
|
|
||||||
expect(pass?.spec.style).toBe("shape=mxgraph.flowchart.or;")
|
|
||||||
expect(resolveShape("x;fillColor=red")).toBeNull()
|
|
||||||
expect(resolveShape("a=b")).toBeNull()
|
|
||||||
})
|
|
||||||
it("suggests the nearest catalog name for a typo", () => {
|
|
||||||
expect(nearestShape("cyclinder")).toBe("cylinder")
|
|
||||||
expect(nearestShape("hexgon")).toBe("hexagon")
|
|
||||||
expect(nearestShape("zzzzzz")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("engine integration", () => {
|
|
||||||
it("renders a catalog shape with theme colours composed on top", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "db",
|
|
||||||
label: "users",
|
|
||||||
shape: "cylinder",
|
|
||||||
group: "storage",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const s = styleOf(r.xml as string, "db")
|
|
||||||
expect(s).toContain("shape=cylinder3")
|
|
||||||
// theme owns colour: the group hue's tint, not the fallback white
|
|
||||||
expect(s).toContain("fillColor=#DAE8FC")
|
|
||||||
// exactly one fillColor — structured merge, not concatenation
|
|
||||||
expect(s.match(/fillColor=/g)).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("warns (not errors) on a pass-through token, with a near-match hint", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "a", label: "x", shape: "cyclinder" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.xml).toBeTruthy()
|
|
||||||
expect(r.warnings.join(" ")).toContain('Did you mean "cylinder"?')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects an injection-capable token as an error", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "a",
|
|
||||||
label: "x",
|
|
||||||
shape: "box;container=1",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors.join(" ")).toContain("not allowed")
|
|
||||||
expect(r.xml).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("sizes a decision box larger than a plain box for the same text", () => {
|
|
||||||
const text = "Is the request authorized to proceed?"
|
|
||||||
const plain = autoBoxSize(text)
|
|
||||||
const rhombus = autoBoxSize(text, undefined, "decision")
|
|
||||||
expect(rhombus.w).toBeGreaterThan(plain.w * 1.3)
|
|
||||||
expect(rhombus.h).toBeGreaterThan(plain.h * 1.3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("round trip", () => {
|
|
||||||
it("carries the declared token back via dai_shape, aliases intact", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "d", label: "choice?", shape: "diamond" },
|
|
||||||
{ op: "add_box", id: "q", label: "jobs", shape: "queue" },
|
|
||||||
])
|
|
||||||
const back = parseDiagram(r.xml as string)
|
|
||||||
const d = back.tree.roots.find((n) => n.id === "d")
|
|
||||||
const q = back.tree.roots.find((n) => n.id === "q")
|
|
||||||
// "diamond" must not come back as "decision" — the declaration survives.
|
|
||||||
expect(d?.kind === "box" && d.shape).toBe("diamond")
|
|
||||||
// "queue" is a rotated cylinder; appearance alone cannot tell them apart.
|
|
||||||
expect(q?.kind === "box" && q.shape).toBe("queue")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("re-measures on label change instead of freezing the first layout's size", () => {
|
|
||||||
const r1 = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "a", label: "hi" },
|
|
||||||
])
|
|
||||||
const r2 = restructureDiagram(r1.xml as string, [
|
|
||||||
{
|
|
||||||
op: "set_label",
|
|
||||||
id: "a",
|
|
||||||
label: "a much longer label that plainly needs a wider box to fit",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const w = (xml: string) =>
|
|
||||||
Number(
|
|
||||||
xml.match(
|
|
||||||
/id="a"[^>]*>\s*<mxGeometry[^>]*width="([\d.]+)"/,
|
|
||||||
)?.[1],
|
|
||||||
)
|
|
||||||
expect(w(r2.xml as string)).toBeGreaterThan(w(r1.xml as string))
|
|
||||||
})
|
|
||||||
|
|
||||||
it("a box with an mxgraph.* token stays a box, keeping role and group", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "b",
|
|
||||||
label: "x",
|
|
||||||
shape: "mxgraph.flowchart.or",
|
|
||||||
role: "callout",
|
|
||||||
group: "z1",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const back = parseDiagram(r.xml as string)
|
|
||||||
const b = back.tree.roots.find((n) => n.id === "b")
|
|
||||||
expect(b?.kind).toBe("box")
|
|
||||||
if (b?.kind !== "box") return
|
|
||||||
expect(b.shape).toBe("mxgraph.flowchart.or")
|
|
||||||
expect(b.role).toBe("callout")
|
|
||||||
expect(b.group).toBe("z1")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("set_shape / set_role / set_group", () => {
|
|
||||||
it("changes shape in place, dropping the stale style and size", () => {
|
|
||||||
const r1 = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "a", label: "db" },
|
|
||||||
])
|
|
||||||
const r2 = restructureDiagram(r1.xml as string, [
|
|
||||||
{ op: "set_shape", id: "a", shape: "cylinder" },
|
|
||||||
])
|
|
||||||
expect(r2.errors).toEqual([])
|
|
||||||
expect(styleOf(r2.xml as string, "a")).toContain("shape=cylinder3")
|
|
||||||
})
|
|
||||||
it("set_role and set_group restyle without losing the label", () => {
|
|
||||||
const r1 = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "a", label: "warn" },
|
|
||||||
])
|
|
||||||
const r2 = restructureDiagram(r1.xml as string, [
|
|
||||||
{ op: "set_role", id: "a", role: "bad" },
|
|
||||||
{ op: "set_group", id: "a", group: "zone1" },
|
|
||||||
])
|
|
||||||
expect(r2.errors).toEqual([])
|
|
||||||
const s = styleOf(r2.xml as string, "a")
|
|
||||||
expect(s).toContain("dai_role=bad")
|
|
||||||
expect(s).toContain("dai_group=zone1")
|
|
||||||
expect(r2.xml).toContain('value="warn"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import {
|
|
||||||
absoluteRects,
|
|
||||||
escapesParent,
|
|
||||||
outsidePage,
|
|
||||||
overlaps,
|
|
||||||
rectOf,
|
|
||||||
} from "./fixtures/geometry"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roles: a node says WHAT IT IS and the engine's theme decides how that looks.
|
|
||||||
*
|
|
||||||
* This is what lets one engine cover poster-style content — paper summaries, cheat
|
|
||||||
* sheets — that previously had to be hand-written XML with no layout guarantees. The
|
|
||||||
* model judges "this is a heading, this is a warning"; the theme guarantees the same
|
|
||||||
* role always renders the same way.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const styleOf = (xml: string, id: string): string =>
|
|
||||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))?.[1] ?? ""
|
|
||||||
const last = (style: string, key: string): string | undefined =>
|
|
||||||
[...style.matchAll(new RegExp(`${key}=([^;]*)`, "g"))].pop()?.[1]
|
|
||||||
|
|
||||||
describe("roles", () => {
|
|
||||||
it("styles each role from the theme, and measures type at its real size", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "Title",
|
|
||||||
role: "banner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "note",
|
|
||||||
parent: "page",
|
|
||||||
label: "fine print",
|
|
||||||
role: "muted",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "num",
|
|
||||||
parent: "page",
|
|
||||||
label: "18% -> 57%",
|
|
||||||
role: "metric",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "plain", parent: "page", label: "ordinary" },
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
expect(last(styleOf(xml, "mast"), "fontSize")).toBe("20")
|
|
||||||
expect(last(styleOf(xml, "mast"), "fillColor")).toBe("#1A237E")
|
|
||||||
expect(last(styleOf(xml, "note"), "fontSize")).toBe("9")
|
|
||||||
expect(last(styleOf(xml, "num"), "fontSize")).toBe("20")
|
|
||||||
// The default look is unchanged: the fallback's own 11px, no theme tokens.
|
|
||||||
expect(last(styleOf(xml, "plain"), "fontSize")).toBe("11")
|
|
||||||
expect(styleOf(xml, "plain")).not.toContain("dai_role")
|
|
||||||
|
|
||||||
// Layout reserved room for the larger type: the banner's cell is taller than a
|
|
||||||
// plain box, or its 20px line would overflow.
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
expect(rectOf(rects, "mast").h).toBeGreaterThan(
|
|
||||||
rectOf(rects, "plain").h,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("a banner spans its column; a heading spans its section", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "T",
|
|
||||||
role: "banner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "wide",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "a",
|
|
||||||
parent: "wide",
|
|
||||||
label: "left column content here",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "b",
|
|
||||||
parent: "wide",
|
|
||||||
label: "right column content here",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const rects = absoluteRects(r.xml as string)
|
|
||||||
const mast = rectOf(rects, "mast")
|
|
||||||
const wide = rectOf(rects, "wide")
|
|
||||||
// The masthead fills the page column's width, not just its own text width.
|
|
||||||
expect(mast.w).toBeGreaterThanOrEqual(wide.w - 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("roles survive the round trip", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "m", label: "Title", role: "banner" },
|
|
||||||
])
|
|
||||||
const again = restructureDiagram(r.xml as string, [])
|
|
||||||
expect(styleOf(again.xml as string, "m")).toContain("dai_role=banner")
|
|
||||||
expect(last(styleOf(again.xml as string, "m"), "fontSize")).toBe("20")
|
|
||||||
// Fixed point: a third pass matches the second.
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("add_graph nodes accept roles too", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_graph",
|
|
||||||
id: "g",
|
|
||||||
nodes: [
|
|
||||||
{ id: "t", label: "Pipeline", role: "heading" },
|
|
||||||
{ id: "a", label: "Build" },
|
|
||||||
{ id: "warn", label: "Flaky stage", role: "bad" },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ source: "t", target: "a" },
|
|
||||||
{ source: "a", target: "warn" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(last(styleOf(r.xml as string, "warn"), "fillColor")).toBe(
|
|
||||||
"#F8CECC",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("a full poster lays out with no sibling overlaps and nothing outside the page", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "Chain-of-Thought Prompting",
|
|
||||||
role: "banner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "byline",
|
|
||||||
parent: "page",
|
|
||||||
label: "Wei et al. | NeurIPS 2022",
|
|
||||||
role: "muted",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "cols",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
gap: 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "c1",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "c2",
|
|
||||||
parent: "cols",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
gap: 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "core",
|
|
||||||
parent: "c1",
|
|
||||||
label: "Core Idea",
|
|
||||||
role: "heading",
|
|
||||||
dir: "col",
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "def",
|
|
||||||
parent: "core",
|
|
||||||
label: "CoT = intermediate reasoning steps",
|
|
||||||
role: "callout",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "vs",
|
|
||||||
parent: "c1",
|
|
||||||
label: "Standard vs CoT",
|
|
||||||
role: "heading",
|
|
||||||
dir: "row",
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "std",
|
|
||||||
parent: "vs",
|
|
||||||
label: "Often wrong",
|
|
||||||
role: "bad",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "cot",
|
|
||||||
parent: "vs",
|
|
||||||
label: "Correct",
|
|
||||||
role: "good",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "res",
|
|
||||||
parent: "c2",
|
|
||||||
label: "Key Results",
|
|
||||||
role: "heading",
|
|
||||||
dir: "col",
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "m1",
|
|
||||||
parent: "res",
|
|
||||||
label: "GSM8K: 18% -> 57%",
|
|
||||||
role: "metric",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
// Overlaps are judged among siblings — containment of children is by design.
|
|
||||||
const parentOfId = new Map<string, string>()
|
|
||||||
for (const m of xml.matchAll(
|
|
||||||
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
|
|
||||||
))
|
|
||||||
parentOfId.set(m[1], m[2])
|
|
||||||
const byParent = new Map<string, string[]>()
|
|
||||||
for (const [id, p] of parentOfId) {
|
|
||||||
if (id.startsWith("__")) continue
|
|
||||||
const l = byParent.get(p) ?? []
|
|
||||||
l.push(id)
|
|
||||||
byParent.set(p, l)
|
|
||||||
}
|
|
||||||
for (const sibs of byParent.values())
|
|
||||||
expect(overlaps(rects, sibs)).toEqual([])
|
|
||||||
expect(outsidePage(xml)).toEqual([])
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import type { Operation } from "@/lib/diagram-engine"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { absoluteRects, escapesParent, outsidePage } from "./fixtures/geometry"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The text and border classes used together on a realistic diagram.
|
|
||||||
*
|
|
||||||
* Every class is unit-tested in isolation above; this checks they compose — that an explicit
|
|
||||||
* alignment survives alongside a role, that a dashed frame does not leak onto its sibling,
|
|
||||||
* and that none of it disturbs the geometry or the round-trip.
|
|
||||||
*/
|
|
||||||
describe("text classes on a real diagram", () => {
|
|
||||||
it("a comparison sheet using type, alignment and dashed borders", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "set_page", aspect: 1.3 },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "Deployment options",
|
|
||||||
role: "banner",
|
|
||||||
class: "self-stretch text-center text-2xl font-bold",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "cols",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "now",
|
|
||||||
parent: "cols",
|
|
||||||
label: "Today",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-1 min-w-0 gap-2 p-3 items-stretch",
|
|
||||||
group: "now",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "plan",
|
|
||||||
parent: "cols",
|
|
||||||
label: "Planned",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-1 min-w-0 gap-2 p-3 items-stretch border-2 border-dashed",
|
|
||||||
group: "plan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "n1",
|
|
||||||
parent: "now",
|
|
||||||
label: "Single region",
|
|
||||||
class: "text-left",
|
|
||||||
group: "now",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "n2",
|
|
||||||
parent: "now",
|
|
||||||
label: "99.9% uptime",
|
|
||||||
class: "font-bold text-lg",
|
|
||||||
group: "now",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p1",
|
|
||||||
parent: "plan",
|
|
||||||
label: "Multi region",
|
|
||||||
class: "text-left italic",
|
|
||||||
group: "plan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "p2",
|
|
||||||
parent: "plan",
|
|
||||||
label: "99.99% uptime",
|
|
||||||
class: "font-bold text-lg",
|
|
||||||
group: "plan",
|
|
||||||
},
|
|
||||||
] as Operation[])
|
|
||||||
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.warnings).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const st = (id: string) =>
|
|
||||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
|
||||||
const k = (s: string, key: string) => {
|
|
||||||
const all = [
|
|
||||||
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
|
|
||||||
]
|
|
||||||
return all.length ? all[all.length - 1][1] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// The masthead: centred 24px bold, and it spans the page.
|
|
||||||
expect(k(st("mast"), "align")).toBe("center")
|
|
||||||
expect(k(st("mast"), "fontSize")).toBe("24")
|
|
||||||
expect(k(st("mast"), "fontStyle")).toBe("1")
|
|
||||||
|
|
||||||
// The "planned" column is dashed; "today" is not.
|
|
||||||
expect(k(st("plan"), "dashed")).toBe("1")
|
|
||||||
expect(k(st("plan"), "strokeWidth")).toBe("2")
|
|
||||||
expect(k(st("now"), "dashed")).toBeUndefined()
|
|
||||||
|
|
||||||
// Type overrides land on the leaves too.
|
|
||||||
expect(k(st("n2"), "fontSize")).toBe("18")
|
|
||||||
expect(k(st("p1"), "fontStyle")).toBe("2") // italic only
|
|
||||||
expect(k(st("p1"), "align")).toBe("left")
|
|
||||||
|
|
||||||
// Nothing broken by any of it.
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
expect(outsidePage(xml)).toEqual([])
|
|
||||||
expect(rects.get("mast")!.w).toBe(rects.get("cols")!.w)
|
|
||||||
|
|
||||||
// And it settles.
|
|
||||||
const again = restructureDiagram(xml, [])
|
|
||||||
expect(again.errors).toEqual([])
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("radius, shadow and borderless compose with roles, groups and shapes", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "set_page", aspect: 1.2 },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
// A shape that ALREADY owns rounded/arcSize, with a radius class on top. The
|
|
||||||
// class is meant to win: a terminator is a rounded rectangle either way, and
|
|
||||||
// changing how round it is does not change what it is.
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "start",
|
|
||||||
parent: "page",
|
|
||||||
label: "Start",
|
|
||||||
shape: "terminator",
|
|
||||||
class: "rounded-lg self-stretch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "cards",
|
|
||||||
parent: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "row",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
// A raised card: radius and shadow together, on top of a role and a group.
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "card",
|
|
||||||
parent: "cards",
|
|
||||||
label: "Raised card",
|
|
||||||
role: "body",
|
|
||||||
group: "one",
|
|
||||||
class: "grow-1 min-w-0 rounded-xl shadow-md",
|
|
||||||
},
|
|
||||||
// A plain colour field: no outline at all, which nothing else could express.
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "field",
|
|
||||||
parent: "cards",
|
|
||||||
label: "Colour field",
|
|
||||||
role: "callout",
|
|
||||||
class: "grow-1 min-w-0 border-none rounded-2xl",
|
|
||||||
},
|
|
||||||
// Struck-through beside bold, to prove the bits add rather than replace.
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "gone",
|
|
||||||
parent: "page",
|
|
||||||
label: "Superseded step",
|
|
||||||
class: "line-through font-bold self-stretch",
|
|
||||||
},
|
|
||||||
] as Operation[])
|
|
||||||
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
expect(r.warnings).toEqual([])
|
|
||||||
const xml = r.xml as string
|
|
||||||
const st = (id: string) =>
|
|
||||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
|
||||||
const k = (s: string, key: string) => {
|
|
||||||
const all = [
|
|
||||||
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
|
|
||||||
]
|
|
||||||
return all.length ? all[all.length - 1][1] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// The class wins over the shape's own proportional corner, and brings the flag that
|
|
||||||
// reinterprets the number as pixels. Without absoluteArcSize, 16 would mean 16% of
|
|
||||||
// the box — a different radius on every node.
|
|
||||||
expect(k(st("start"), "shape")).toBeUndefined() // terminator is rounded=1, not shape=
|
|
||||||
expect(k(st("start"), "rounded")).toBe("1")
|
|
||||||
expect(k(st("start"), "absoluteArcSize")).toBe("1")
|
|
||||||
expect(k(st("start"), "arcSize")).toBe("16")
|
|
||||||
|
|
||||||
// The card keeps its role's fill while taking the class's radius and shadow.
|
|
||||||
expect(k(st("card"), "arcSize")).toBe("24")
|
|
||||||
expect(k(st("card"), "shadow")).toBe("1")
|
|
||||||
expect(k(st("card"), "shadowBlur")).toBe("6")
|
|
||||||
expect(k(st("card"), "fillColor")).not.toBe("none")
|
|
||||||
|
|
||||||
// borderless beats the role's own stroke; the role's fill survives, which is the
|
|
||||||
// point of a colour field.
|
|
||||||
expect(k(st("field"), "strokeColor")).toBe("none")
|
|
||||||
expect(k(st("field"), "fillColor")).not.toBe("none")
|
|
||||||
expect(k(st("field"), "arcSize")).toBe("32")
|
|
||||||
|
|
||||||
// Bold (1) plus strikethrough (8) in one key.
|
|
||||||
expect(k(st("gone"), "fontStyle")).toBe("9")
|
|
||||||
|
|
||||||
// None of it disturbs the layout.
|
|
||||||
expect(escapesParent(xml)).toEqual([])
|
|
||||||
expect(outsidePage(xml)).toEqual([])
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
expect(rects.get("card")!.w).toBeCloseTo(rects.get("field")!.w, 0)
|
|
||||||
|
|
||||||
// And it settles.
|
|
||||||
const again = restructureDiagram(xml, [])
|
|
||||||
expect(again.errors).toEqual([])
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import type { Operation } from "@/lib/diagram-engine"
|
|
||||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
|
||||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
||||||
import { parseTw } from "@/lib/diagram-engine/tw"
|
|
||||||
import type { BoxNode } from "@/lib/diagram-engine/types"
|
|
||||||
import { absoluteRects, rectOf } from "./fixtures/geometry"
|
|
||||||
|
|
||||||
describe("parseTw", () => {
|
|
||||||
it("reads direction, weights, alignment and distribution", () => {
|
|
||||||
expect(
|
|
||||||
parseTw("flex flex-col grow-3 items-stretch justify-between"),
|
|
||||||
).toEqual({
|
|
||||||
dir: "col",
|
|
||||||
grow: 3,
|
|
||||||
alignItems: "stretch",
|
|
||||||
justify: "between",
|
|
||||||
ignored: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("puts spacing on Tailwind's 4px scale", () => {
|
|
||||||
const r = parseTw("p-6 gap-4")
|
|
||||||
expect(r.pad).toBe(24)
|
|
||||||
expect(r.gap).toBe(16)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("treats a width fraction as a share of the row", () => {
|
|
||||||
// w-2/3 beside w-1/3 has to be the same layout as grow-2 beside grow-1.
|
|
||||||
expect(parseTw("w-2/3").grow).toBe(2)
|
|
||||||
expect(parseTw("w-1/3").grow).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reads both named and numeric max-width", () => {
|
|
||||||
expect(parseTw("max-w-md").maxW).toBe(448)
|
|
||||||
expect(parseTw("max-w-96").maxW).toBe(384)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("later classes win, the way Tailwind's own conflicts resolve", () => {
|
|
||||||
expect(parseTw("grow-1 grow-4").grow).toBe(4)
|
|
||||||
expect(parseTw("justify-start justify-evenly").justify).toBe("evenly")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("collects what it cannot honour instead of failing", () => {
|
|
||||||
const r = parseTw(
|
|
||||||
"grow-2 uppercase bg-blue-500 rounded-tl-xl hover:p-4",
|
|
||||||
)
|
|
||||||
expect(r.grow).toBe(2)
|
|
||||||
expect(r.ignored).toEqual([
|
|
||||||
"uppercase",
|
|
||||||
"bg-blue-500",
|
|
||||||
"rounded-tl-xl",
|
|
||||||
"hover:p-4",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects arbitrary values, so the scale stays a scale", () => {
|
|
||||||
// The point of a scale is that there is no p-7.5 and no w-[137px].
|
|
||||||
expect(parseTw("p-[13px] w-[137px]").ignored).toEqual([
|
|
||||||
"p-[13px]",
|
|
||||||
"w-[137px]",
|
|
||||||
])
|
|
||||||
expect(parseTw("p-[13px]").pad).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("class on an operation", () => {
|
|
||||||
it("drives real geometry: w-3/4 beside w-1/4 splits 3:1", () => {
|
|
||||||
// min-w-0 on both, because a column will not otherwise shrink below its own text —
|
|
||||||
// real flexbox behaviour, and what makes an exact ratio opt-in.
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "set_page", aspect: 1.3 },
|
|
||||||
{ op: "add_container", id: "row", label: "", dir: "row" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "main",
|
|
||||||
parent: "row",
|
|
||||||
label: "Main",
|
|
||||||
dir: "col",
|
|
||||||
class: "w-3/4 min-w-0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "side",
|
|
||||||
parent: "row",
|
|
||||||
label: "Side",
|
|
||||||
dir: "col",
|
|
||||||
class: "w-1/4 min-w-0",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "a", parent: "main", label: "a" },
|
|
||||||
{ op: "add_box", id: "b", parent: "side", label: "b" },
|
|
||||||
] as Operation[])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const rects = absoluteRects(r.xml as string)
|
|
||||||
const ratio = rectOf(rects, "main").w / rectOf(rects, "side").w
|
|
||||||
expect(ratio).toBeGreaterThan(2.7)
|
|
||||||
expect(ratio).toBeLessThan(3.3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("a max-width class caps a box and its text wraps instead", () => {
|
|
||||||
const long =
|
|
||||||
"A deliberately long single line that would otherwise stretch its box right across the page"
|
|
||||||
// max-w-48 is 192px — below the 260px a plain box already caps itself at, so this
|
|
||||||
// is a cap that actually bites. max-w-xs (320) would be a no-op here.
|
|
||||||
const capped = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "x", label: long, class: "max-w-48" },
|
|
||||||
] as Operation[])
|
|
||||||
const free = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "x", label: long },
|
|
||||||
] as Operation[])
|
|
||||||
const a = rectOf(absoluteRects(capped.xml as string), "x")
|
|
||||||
const b = rectOf(absoluteRects(free.xml as string), "x")
|
|
||||||
expect(a.w).toBeLessThanOrEqual(192)
|
|
||||||
// Same text in a narrower box means more lines, so it must be taller.
|
|
||||||
expect(a.h).toBeGreaterThan(b.h)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("an explicit field outranks the class that says the same thing", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "set_page", aspect: 1.3 },
|
|
||||||
{ op: "add_container", id: "row", label: "", dir: "row" },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "L",
|
|
||||||
parent: "row",
|
|
||||||
label: "L",
|
|
||||||
dir: "col",
|
|
||||||
class: "grow-1 min-w-0",
|
|
||||||
grow: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "R",
|
|
||||||
parent: "row",
|
|
||||||
label: "R",
|
|
||||||
dir: "col",
|
|
||||||
grow: 1,
|
|
||||||
class: "min-w-0",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "a", parent: "L", label: "a" },
|
|
||||||
{ op: "add_box", id: "b", parent: "R", label: "b" },
|
|
||||||
] as Operation[])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const rects = absoluteRects(r.xml as string)
|
|
||||||
const ratio = rectOf(rects, "L").w / rectOf(rects, "R").w
|
|
||||||
expect(ratio).toBeGreaterThan(2.7)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("tells the model which classes it dropped, once each", () => {
|
|
||||||
const r = restructureDiagram("", [
|
|
||||||
{ op: "add_container", id: "c", label: "C", dir: "col" },
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "a",
|
|
||||||
parent: "c",
|
|
||||||
label: "a",
|
|
||||||
class: "tracking-wide p-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "b",
|
|
||||||
parent: "c",
|
|
||||||
label: "b",
|
|
||||||
class: "tracking-wide grow-2",
|
|
||||||
},
|
|
||||||
] as Operation[])
|
|
||||||
expect(r.errors).toEqual([])
|
|
||||||
const notes = r.warnings.join(" ")
|
|
||||||
expect(notes).toContain("tracking-wide")
|
|
||||||
// Reported once even though two cards carried it.
|
|
||||||
expect(notes.match(/tracking-wide/g)).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("a class-driven layout survives a round-trip through the canvas", () => {
|
|
||||||
const first = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "c",
|
|
||||||
label: "C",
|
|
||||||
dir: "col",
|
|
||||||
class: "gap-4 p-6 items-stretch justify-between max-w-lg",
|
|
||||||
},
|
|
||||||
{ op: "add_box", id: "a", parent: "c", label: "a" },
|
|
||||||
{ op: "add_box", id: "b", parent: "c", label: "b" },
|
|
||||||
] as Operation[])
|
|
||||||
expect(first.errors).toEqual([])
|
|
||||||
const again = restructureDiagram(first.xml as string, [])
|
|
||||||
expect(again.errors).toEqual([])
|
|
||||||
|
|
||||||
// Every field the classes set has to come back, or the next edit would silently
|
|
||||||
// drop it: re-reading the canvas is the ONLY place the structure comes from.
|
|
||||||
for (const marker of [
|
|
||||||
"dai_gap=16", // gap-4
|
|
||||||
"dai_pad=24", // p-6
|
|
||||||
"dai_aitems=stretch", // items-stretch
|
|
||||||
"dai_justify=between", // justify-between
|
|
||||||
"dai_maxw=512", // max-w-lg
|
|
||||||
])
|
|
||||||
expect(again.xml).toContain(marker)
|
|
||||||
|
|
||||||
// Geometry is the real test of a round-trip: same structure in, same boxes out.
|
|
||||||
// (The style string itself is not compared — re-stamping appends a duplicate
|
|
||||||
// container=1, which draw.io resolves last-value-wins. See markers.ts.)
|
|
||||||
const a = absoluteRects(first.xml as string)
|
|
||||||
const b = absoluteRects(again.xml as string)
|
|
||||||
for (const id of ["c", "a", "b"])
|
|
||||||
expect(rectOf(b, id)).toEqual(rectOf(a, id))
|
|
||||||
|
|
||||||
// And it must reach a fixed point rather than drifting one step per pass.
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text and border classes.
|
|
||||||
*
|
|
||||||
* The supported set was chosen by reading Tailwind's property index against draw.io's own
|
|
||||||
* style reference. These tests pin both halves: that what IS accepted reaches the XML, and
|
|
||||||
* that what was rejected stays rejected for the documented reason — the exclusions are the
|
|
||||||
* interesting part, because each one is a property draw.io either cannot express at all or
|
|
||||||
* can only express coarsely.
|
|
||||||
*/
|
|
||||||
describe("text and border classes", () => {
|
|
||||||
const styleOf = (xml: string, id: string) =>
|
|
||||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
|
||||||
const key = (style: string, k: string) => {
|
|
||||||
const all = [...style.matchAll(new RegExp(`(?:^|;)${k}=([^;]*)`, "g"))]
|
|
||||||
return all.length ? all[all.length - 1][1] : undefined
|
|
||||||
}
|
|
||||||
const build = (cls: string) =>
|
|
||||||
restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "x", label: "Hello", class: cls },
|
|
||||||
] as Operation[])
|
|
||||||
|
|
||||||
it("packs bold, italic and underline into one fontStyle bitmask", () => {
|
|
||||||
// draw.io adds the bits: 1 bold + 2 italic + 4 underline = 7. Three separate
|
|
||||||
// fontStyle keys would leave only the last one in effect.
|
|
||||||
const s = styleOf(
|
|
||||||
build("font-bold italic underline").xml as string,
|
|
||||||
"x",
|
|
||||||
)
|
|
||||||
expect(key(s, "fontStyle")).toBe("7")
|
|
||||||
expect(s.match(/fontStyle=/g)).toHaveLength(1)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
key(styleOf(build("font-bold").xml as string, "x"), "fontStyle"),
|
|
||||||
).toBe("1")
|
|
||||||
expect(
|
|
||||||
key(styleOf(build("italic").xml as string, "x"), "fontStyle"),
|
|
||||||
).toBe("2")
|
|
||||||
expect(
|
|
||||||
key(styleOf(build("underline").xml as string, "x"), "fontStyle"),
|
|
||||||
).toBe("4")
|
|
||||||
expect(
|
|
||||||
key(
|
|
||||||
styleOf(build("font-bold italic").xml as string, "x"),
|
|
||||||
"fontStyle",
|
|
||||||
),
|
|
||||||
).toBe("3")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("maps the type scale to Tailwind's own pixel values", () => {
|
|
||||||
for (const [cls, px] of [
|
|
||||||
["text-xs", "12"],
|
|
||||||
["text-base", "16"],
|
|
||||||
["text-2xl", "24"],
|
|
||||||
["text-4xl", "36"],
|
|
||||||
] as const)
|
|
||||||
expect(
|
|
||||||
key(styleOf(build(cls).xml as string, "x"), "fontSize"),
|
|
||||||
).toBe(px)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("sets both text alignments", () => {
|
|
||||||
const s = styleOf(build("text-right align-bottom").xml as string, "x")
|
|
||||||
expect(key(s, "align")).toBe("right")
|
|
||||||
expect(key(s, "verticalAlign")).toBe("bottom")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("an explicit alignment beats the paragraph heuristic", () => {
|
|
||||||
// A long label is set flush left automatically. Asking for centre has to win, or
|
|
||||||
// the class would silently do nothing on exactly the labels it matters for.
|
|
||||||
const long =
|
|
||||||
"A label long enough that the engine sets it flush left by itself, well past sixty characters"
|
|
||||||
const auto = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "x", label: long },
|
|
||||||
] as Operation[])
|
|
||||||
const forced = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "x", label: long, class: "text-center" },
|
|
||||||
] as Operation[])
|
|
||||||
expect(key(styleOf(auto.xml as string, "x"), "align")).toBe("left")
|
|
||||||
expect(key(styleOf(forced.xml as string, "x"), "align")).toBe("center")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("border width and dash style reach the stroke", () => {
|
|
||||||
expect(
|
|
||||||
key(styleOf(build("border-4").xml as string, "x"), "strokeWidth"),
|
|
||||||
).toBe("4")
|
|
||||||
expect(
|
|
||||||
key(styleOf(build("border").xml as string, "x"), "strokeWidth"),
|
|
||||||
).toBe("1")
|
|
||||||
|
|
||||||
const dashed = styleOf(build("border-dashed").xml as string, "x")
|
|
||||||
expect(key(dashed, "dashed")).toBe("1")
|
|
||||||
expect(key(dashed, "dashPattern")).toBeUndefined()
|
|
||||||
|
|
||||||
// Dotted needs the pattern too, or draw.io draws a dash and calls it dotted.
|
|
||||||
const dotted = styleOf(build("border-dotted").xml as string, "x")
|
|
||||||
expect(key(dotted, "dashed")).toBe("1")
|
|
||||||
expect(key(dotted, "dashPattern")).toBe("1 3")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("whitespace-nowrap keeps a label on one line", () => {
|
|
||||||
expect(
|
|
||||||
key(
|
|
||||||
styleOf(build("whitespace-nowrap").xml as string, "x"),
|
|
||||||
"whiteSpace",
|
|
||||||
),
|
|
||||||
).toBe("nowrap")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects the seven font weights draw.io cannot distinguish", () => {
|
|
||||||
// draw.io's fontStyle has ONE bold bit, so five of Tailwind's nine weights would
|
|
||||||
// collapse onto bold and four onto normal. Reporting them beats pretending.
|
|
||||||
for (const w of [
|
|
||||||
"font-thin",
|
|
||||||
"font-extralight",
|
|
||||||
"font-light",
|
|
||||||
"font-medium",
|
|
||||||
"font-semibold",
|
|
||||||
"font-extrabold",
|
|
||||||
"font-black",
|
|
||||||
])
|
|
||||||
expect(parseTw(w).ignored).toEqual([w])
|
|
||||||
// The two that do map are not reported.
|
|
||||||
expect(parseTw("font-bold font-normal").ignored).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects opacity, because Tailwind's is a free number rather than a scale", () => {
|
|
||||||
// draw.io's opacity is 0-100 and would map, but `opacity-<number>` accepts any
|
|
||||||
// number — admitting it gives up the constraint that justifies this vocabulary.
|
|
||||||
expect(parseTw("opacity-25 opacity-37").ignored).toEqual([
|
|
||||||
"opacity-25",
|
|
||||||
"opacity-37",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects truncate, which promises an ellipsis draw.io cannot draw", () => {
|
|
||||||
// Tailwind's truncate is overflow:hidden + text-overflow:ellipsis + nowrap, and
|
|
||||||
// draw.io's overflow has no ellipsis value — the text would just be cut.
|
|
||||||
expect(parseTw("truncate").ignored).toEqual(["truncate"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects every outline class, which draw.io has no concept of", () => {
|
|
||||||
const outlines = [
|
|
||||||
"outline-2",
|
|
||||||
"outline-solid",
|
|
||||||
"outline-dashed",
|
|
||||||
"outline-offset-2",
|
|
||||||
]
|
|
||||||
expect(parseTw(outlines.join(" ")).ignored).toEqual(outlines)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects per-side borders, which would cost the shape slot", () => {
|
|
||||||
// draw.io draws these correctly via shape=partialRectangle, but that is a SHAPE
|
|
||||||
// name — a node cannot be both a diamond and left-edge-only, and what a node IS
|
|
||||||
// outranks how its border looks.
|
|
||||||
const sides = ["border-t", "border-l-4", "border-x", "border-y-2"]
|
|
||||||
expect(parseTw(sides.join(" ")).ignored).toEqual(sides)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects per-side padding, which draw.io only has for the label", () => {
|
|
||||||
// spacingTop/Right/Bottom/Left look like an exact match and are not: they pad the
|
|
||||||
// LABEL inside its cell, while this engine's `pad` is room for a container's
|
|
||||||
// CHILDREN. Accepting `pt-8` would imply it pushes child nodes down.
|
|
||||||
const pads = ["pt-8", "px-4", "pb-2", "ps-6"]
|
|
||||||
expect(parseTw(pads.join(" ")).ignored).toEqual(pads)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects per-corner radius while accepting the whole-shape one", () => {
|
|
||||||
// Per-corner lives only on the mxgraph.basic.rect template shape, which would take
|
|
||||||
// the node's own shape — the same trade the per-side borders lose.
|
|
||||||
expect(parseTw("rounded-tl-lg rounded-br-sm").ignored).toEqual([
|
|
||||||
"rounded-tl-lg",
|
|
||||||
"rounded-br-sm",
|
|
||||||
])
|
|
||||||
expect(parseTw("rounded-lg").radius).toBe(8)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects text-shadow, whose draw.io key really is one flag", () => {
|
|
||||||
// Unlike the box shadow family, `textShadow` has no offset or blur, so Tailwind's
|
|
||||||
// six sizes would all draw the same picture.
|
|
||||||
expect(parseTw("text-shadow-lg").ignored).toEqual(["text-shadow-lg"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects letter-spacing, case and line-height — absent, not coarse", () => {
|
|
||||||
const absent = ["tracking-wide", "uppercase", "capitalize", "leading-6"]
|
|
||||||
expect(parseTw(absent.join(" ")).ignored).toEqual(absent)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts the radius scale in real pixels", () => {
|
|
||||||
// Tailwind's own values. These are pixels only because absoluteArcSize switches
|
|
||||||
// arcSize off its default percentage reading.
|
|
||||||
expect(parseTw("rounded").radius).toBe(4)
|
|
||||||
expect(parseTw("rounded-xs").radius).toBe(2)
|
|
||||||
expect(parseTw("rounded-md").radius).toBe(6)
|
|
||||||
expect(parseTw("rounded-xl").radius).toBe(12)
|
|
||||||
expect(parseTw("rounded-2xl").radius).toBe(16)
|
|
||||||
expect(parseTw("rounded-3xl").radius).toBe(24)
|
|
||||||
expect(parseTw("rounded-4xl").radius).toBe(32)
|
|
||||||
expect(parseTw("rounded-none").radius).toBe(0)
|
|
||||||
// `rounded-full` is calc(infinity * 1px) in CSS. draw.io clamps to half the shorter
|
|
||||||
// side, so the value only has to exceed half the tallest box a diagram ever has —
|
|
||||||
// and it must stay readable, because the Arrange panel shows it in an editable field.
|
|
||||||
const full = parseTw("rounded-full").radius as number
|
|
||||||
expect(full).toBeGreaterThan(200 / 2)
|
|
||||||
expect(full).toBeLessThan(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts four shadow rungs and an explicit none", () => {
|
|
||||||
expect(parseTw("shadow-sm").shadow).toBe(1)
|
|
||||||
expect(parseTw("shadow-md").shadow).toBe(2)
|
|
||||||
expect(parseTw("shadow-lg").shadow).toBe(3)
|
|
||||||
expect(parseTw("shadow-xl").shadow).toBe(4)
|
|
||||||
expect(parseTw("shadow-none").shadow).toBe(0)
|
|
||||||
// The steps that would be indistinguishable at a diagram's scale, and the colour
|
|
||||||
// form, stay out.
|
|
||||||
expect(
|
|
||||||
parseTw("shadow-2xs shadow-xs shadow-2xl shadow-blue-500").ignored,
|
|
||||||
).toEqual(["shadow-2xs", "shadow-xs", "shadow-2xl", "shadow-blue-500"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("distinguishes no border from a zero-width one", () => {
|
|
||||||
// `border-0` must not read as "a 0px border": draw.io would still draw its default
|
|
||||||
// hairline. Both forms mean strokeColor=none.
|
|
||||||
expect(parseTw("border-none").borderless).toBe(true)
|
|
||||||
expect(parseTw("border-0").borderless).toBe(true)
|
|
||||||
expect(parseTw("border-0").borderWidth).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts line-through, and no-underline does not clear it", () => {
|
|
||||||
expect(parseTw("line-through").strike).toBe(true)
|
|
||||||
// Both are values of text-decoration-line in CSS, so "not underlined" is not
|
|
||||||
// "undecorated".
|
|
||||||
const r = parseTw("line-through no-underline")
|
|
||||||
expect(r.strike).toBe(true)
|
|
||||||
expect(r.underline).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not mistake a colour class for a type size", () => {
|
|
||||||
// `text-` is three Tailwind properties at once: size, alignment and colour.
|
|
||||||
const r = parseTw("text-red-500 text-lg")
|
|
||||||
expect(r.fontSize).toBe(18)
|
|
||||||
expect(r.ignored).toEqual(["text-red-500"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("text and border survive a round-trip through the canvas", () => {
|
|
||||||
const first = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "x",
|
|
||||||
label: "Note",
|
|
||||||
class: "font-bold italic text-lg text-right align-top border-2 border-dashed",
|
|
||||||
},
|
|
||||||
] as Operation[])
|
|
||||||
expect(first.errors).toEqual([])
|
|
||||||
const again = restructureDiagram(first.xml as string, [])
|
|
||||||
expect(again.errors).toEqual([])
|
|
||||||
|
|
||||||
const s = styleOf(again.xml as string, "x")
|
|
||||||
expect(key(s, "fontStyle")).toBe("3")
|
|
||||||
expect(key(s, "fontSize")).toBe("18")
|
|
||||||
expect(key(s, "align")).toBe("right")
|
|
||||||
expect(key(s, "verticalAlign")).toBe("top")
|
|
||||||
expect(key(s, "strokeWidth")).toBe("2")
|
|
||||||
expect(key(s, "dashed")).toBe("1")
|
|
||||||
|
|
||||||
// And it settles rather than drifting a step per pass.
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("radius, shadow, strike and borderless survive a round-trip", () => {
|
|
||||||
const first = restructureDiagram("", [
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "card",
|
|
||||||
label: "Card",
|
|
||||||
class: "rounded-lg shadow-md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "field",
|
|
||||||
label: "Field",
|
|
||||||
class: "border-none",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "old",
|
|
||||||
label: "Superseded",
|
|
||||||
class: "line-through",
|
|
||||||
},
|
|
||||||
] as Operation[])
|
|
||||||
expect(first.errors).toEqual([])
|
|
||||||
const again = restructureDiagram(first.xml as string, [])
|
|
||||||
expect(again.errors).toEqual([])
|
|
||||||
|
|
||||||
const card = styleOf(again.xml as string, "card")
|
|
||||||
// A radius needs all three keys: arcSize alone would be read as a percentage.
|
|
||||||
expect(key(card, "rounded")).toBe("1")
|
|
||||||
expect(key(card, "absoluteArcSize")).toBe("1")
|
|
||||||
// Doubled on the way out because draw.io halves it on the way in.
|
|
||||||
expect(key(card, "arcSize")).toBe("16")
|
|
||||||
expect(key(card, "shadow")).toBe("1")
|
|
||||||
expect(key(card, "shadowOffsetY")).toBe("4")
|
|
||||||
expect(key(card, "shadowBlur")).toBe("6")
|
|
||||||
|
|
||||||
expect(key(styleOf(again.xml as string, "field"), "strokeColor")).toBe(
|
|
||||||
"none",
|
|
||||||
)
|
|
||||||
// Bit 8, on its own since nothing asked for bold or italic.
|
|
||||||
expect(key(styleOf(again.xml as string, "old"), "fontStyle")).toBe("8")
|
|
||||||
|
|
||||||
const third = restructureDiagram(again.xml as string, [])
|
|
||||||
expect(third.xml).toBe(again.xml)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("a theme's own borderless and square corners are not read as requests", () => {
|
|
||||||
// A heading is ghost text — no fill, no stroke, square — because of what it IS, and
|
|
||||||
// nearly every box carries `rounded=0` from the fallback style. Recording either as a
|
|
||||||
// declared override would outlive a later role change, since `set_role` clears a
|
|
||||||
// node's style but keeps its text overrides. Same trap `size` and `align` avoid by
|
|
||||||
// comparing against the defaults for the node's kind.
|
|
||||||
const first = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "h", label: "Section", role: "heading" },
|
|
||||||
{ op: "add_box", id: "b", label: "Body", role: "body" },
|
|
||||||
] as Operation[])
|
|
||||||
expect(first.errors).toEqual([])
|
|
||||||
|
|
||||||
const t = parseDiagram(first.xml as string).tree
|
|
||||||
const heading = t.roots.find((n) => n.id === "h") as BoxNode
|
|
||||||
const body = t.roots.find((n) => n.id === "b") as BoxNode
|
|
||||||
expect(heading.role).toBe("heading")
|
|
||||||
expect(heading.text?.borderless).toBeUndefined()
|
|
||||||
expect(heading.text?.radius).toBeUndefined()
|
|
||||||
expect(body.text?.radius).toBeUndefined()
|
|
||||||
|
|
||||||
// A class-declared one IS recorded, so the distinction is real rather than a blanket
|
|
||||||
// refusal to read these keys.
|
|
||||||
const asked = restructureDiagram("", [
|
|
||||||
{ op: "add_box", id: "x", label: "Field", class: "border-none" },
|
|
||||||
] as Operation[])
|
|
||||||
const x = parseDiagram(asked.xml as string).tree.roots.find(
|
|
||||||
(n) => n.id === "x",
|
|
||||||
) as BoxNode
|
|
||||||
expect(x.text?.borderless).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* Geometry checks on rendered draw.io XML.
|
|
||||||
*
|
|
||||||
* These assert what a reader would notice: an arrow running through a box that has nothing
|
|
||||||
* to do with it, two shapes drawn on top of each other, a node outside the frame that is
|
|
||||||
* supposed to contain it. Asserting on exact coordinates instead would break on every
|
|
||||||
* spacing change while still passing on a diagram that looks wrong.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Rect {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Point {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A rendered edge, resolved to the points it actually passes through. */
|
|
||||||
export interface EdgePath {
|
|
||||||
id: string
|
|
||||||
source: string
|
|
||||||
target: string
|
|
||||||
label: string
|
|
||||||
points: Point[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every vertex's rectangle in PAGE coordinates.
|
|
||||||
*
|
|
||||||
* The XML stores a nested cell's geometry relative to its parent, so the offsets have to be
|
|
||||||
* added up through the parent chain. Resolved lazily and memoised, since a parent may appear
|
|
||||||
* after its child in document order.
|
|
||||||
*/
|
|
||||||
export function absoluteRects(xml: string): Map<string, Rect> {
|
|
||||||
const raw = new Map<string, Rect & { parent: string }>()
|
|
||||||
for (const m of xml.matchAll(
|
|
||||||
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"><mxGeometry x="(-?[\d.]+)" y="(-?[\d.]+)" width="(-?[\d.]+)" height="(-?[\d.]+)"/g,
|
|
||||||
))
|
|
||||||
raw.set(m[1], {
|
|
||||||
parent: m[2],
|
|
||||||
x: Number(m[3]),
|
|
||||||
y: Number(m[4]),
|
|
||||||
w: Number(m[5]),
|
|
||||||
h: Number(m[6]),
|
|
||||||
})
|
|
||||||
|
|
||||||
const abs = new Map<string, Rect>()
|
|
||||||
const resolve = (id: string, seen = new Set<string>()): Rect => {
|
|
||||||
const hit = abs.get(id)
|
|
||||||
if (hit) return hit
|
|
||||||
const r = raw.get(id) as Rect & { parent: string }
|
|
||||||
// A malformed cycle must not hang the test run.
|
|
||||||
const base =
|
|
||||||
r.parent === "1" || !raw.has(r.parent) || seen.has(r.parent)
|
|
||||||
? { x: 0, y: 0 }
|
|
||||||
: resolve(r.parent, new Set(seen).add(id))
|
|
||||||
const out = { x: r.x + base.x, y: r.y + base.y, w: r.w, h: r.h }
|
|
||||||
abs.set(id, out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for (const id of raw.keys()) resolve(id)
|
|
||||||
return abs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One node's rectangle, or a failure naming the id that was missing.
|
|
||||||
*
|
|
||||||
* A missing id here almost always means the renderer dropped a cell, and "cannot read
|
|
||||||
* property y of undefined" does not say which one.
|
|
||||||
*/
|
|
||||||
export function rectOf(rects: Map<string, Rect>, id: string): Rect {
|
|
||||||
const r = rects.get(id)
|
|
||||||
if (!r) throw new Error(`no cell was rendered for "${id}"`)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The page size the renderer declared. */
|
|
||||||
function pageSize(xml: string): { w: number; h: number } {
|
|
||||||
const m = xml.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)
|
|
||||||
return { w: Number(m?.[1] ?? 0), h: Number(m?.[2] ?? 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The path each edge takes: its two connection points plus any waypoints between them.
|
|
||||||
*
|
|
||||||
* A connection point is a fraction of the terminal's bounds, so it is resolved against that
|
|
||||||
* terminal's rectangle. Without one, draw.io picks the side itself and the centre is the best
|
|
||||||
* available guess.
|
|
||||||
*/
|
|
||||||
export function edgePaths(xml: string, rects: Map<string, Rect>): EdgePath[] {
|
|
||||||
const out: EdgePath[] = []
|
|
||||||
for (const m of xml.matchAll(
|
|
||||||
/<mxCell id="([^"]+)" value="([^"]*)" style="([^"]*)" edge="1"[^>]*source="([^"]+)" target="([^"]+)">([\s\S]*?)<\/mxCell>/g,
|
|
||||||
)) {
|
|
||||||
const a = rects.get(m[4])
|
|
||||||
const b = rects.get(m[5])
|
|
||||||
if (!a || !b) continue
|
|
||||||
const exit = m[3].match(/exitX=([\d.]+);exitY=([\d.]+)/)
|
|
||||||
const entry = m[3].match(/entryX=([\d.]+);entryY=([\d.]+)/)
|
|
||||||
const start = exit
|
|
||||||
? { x: a.x + Number(exit[1]) * a.w, y: a.y + Number(exit[2]) * a.h }
|
|
||||||
: { x: a.x + a.w / 2, y: a.y + a.h / 2 }
|
|
||||||
const end = entry
|
|
||||||
? {
|
|
||||||
x: b.x + Number(entry[1]) * b.w,
|
|
||||||
y: b.y + Number(entry[2]) * b.h,
|
|
||||||
}
|
|
||||||
: { x: b.x + b.w / 2, y: b.y + b.h / 2 }
|
|
||||||
const waypoints = [
|
|
||||||
...m[6].matchAll(/<mxPoint x="(-?[\d.]+)" y="(-?[\d.]+)"\/>/g),
|
|
||||||
].map((p) => ({ x: Number(p[1]), y: Number(p[2]) }))
|
|
||||||
out.push({
|
|
||||||
id: m[1],
|
|
||||||
source: m[4],
|
|
||||||
target: m[5],
|
|
||||||
label: m[2],
|
|
||||||
points: [start, ...waypoints, end],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edge segments that pass through a node that is neither of their endpoints.
|
|
||||||
*
|
|
||||||
* A 2px tolerance, so a line grazing a border on its way past does not count — that is the
|
|
||||||
* router deliberately hugging a shape, not an arrow drawn over it.
|
|
||||||
*/
|
|
||||||
export function nodeCollisions(
|
|
||||||
paths: EdgePath[],
|
|
||||||
rects: Map<string, Rect>,
|
|
||||||
nodeIds: string[],
|
|
||||||
): string[] {
|
|
||||||
const bad = new Set<string>()
|
|
||||||
for (const p of paths)
|
|
||||||
for (let i = 0; i + 1 < p.points.length; i++) {
|
|
||||||
const a = p.points[i]
|
|
||||||
const b = p.points[i + 1]
|
|
||||||
const lo = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }
|
|
||||||
const hi = { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }
|
|
||||||
for (const id of nodeIds) {
|
|
||||||
if (id === p.source || id === p.target) continue
|
|
||||||
const r = rects.get(id)
|
|
||||||
if (!r) continue
|
|
||||||
if (
|
|
||||||
lo.x < r.x + r.w - 2 &&
|
|
||||||
hi.x > r.x + 2 &&
|
|
||||||
lo.y < r.y + r.h - 2 &&
|
|
||||||
hi.y > r.y + 2
|
|
||||||
)
|
|
||||||
bad.add(`${p.id} (${p.source}→${p.target}) crosses ${id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...bad]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pairs of nodes drawn on top of each other. */
|
|
||||||
export function overlaps(
|
|
||||||
rects: Map<string, Rect>,
|
|
||||||
nodeIds: string[],
|
|
||||||
): string[] {
|
|
||||||
const bad: string[] = []
|
|
||||||
for (let i = 0; i < nodeIds.length; i++)
|
|
||||||
for (let j = i + 1; j < nodeIds.length; j++) {
|
|
||||||
const a = rects.get(nodeIds[i])
|
|
||||||
const b = rects.get(nodeIds[j])
|
|
||||||
if (!a || !b) continue
|
|
||||||
if (
|
|
||||||
a.x < b.x + b.w &&
|
|
||||||
b.x < a.x + a.w &&
|
|
||||||
a.y < b.y + b.h &&
|
|
||||||
b.y < a.y + a.h
|
|
||||||
)
|
|
||||||
bad.push(`${nodeIds[i]} overlaps ${nodeIds[j]}`)
|
|
||||||
}
|
|
||||||
return bad
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cells that fall outside the page the renderer declared. */
|
|
||||||
export function outsidePage(xml: string, skip: string[] = []): string[] {
|
|
||||||
const page = pageSize(xml)
|
|
||||||
const bad: string[] = []
|
|
||||||
for (const [id, r] of absoluteRects(xml)) {
|
|
||||||
if (skip.includes(id)) continue
|
|
||||||
if (r.x < 0 || r.y < 0 || r.x + r.w > page.w || r.y + r.h > page.h)
|
|
||||||
bad.push(
|
|
||||||
`${id} at ${r.x},${r.y} ${r.w}x${r.h} is outside the ${page.w}x${page.h} page`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return bad
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cells that stick out of the parent they declare.
|
|
||||||
*
|
|
||||||
* 1px of slack absorbs the rounding the renderer does on each coordinate independently.
|
|
||||||
*/
|
|
||||||
export function escapesParent(xml: string): string[] {
|
|
||||||
const rects = absoluteRects(xml)
|
|
||||||
const bad = new Set<string>()
|
|
||||||
for (const m of xml.matchAll(
|
|
||||||
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
|
|
||||||
)) {
|
|
||||||
const child = rects.get(m[1])
|
|
||||||
const parent = rects.get(m[2])
|
|
||||||
if (!child || !parent) continue
|
|
||||||
if (
|
|
||||||
child.x < parent.x - 1 ||
|
|
||||||
child.y < parent.y - 1 ||
|
|
||||||
child.x + child.w > parent.x + parent.w + 1 ||
|
|
||||||
child.y + child.h > parent.y + parent.h + 1
|
|
||||||
)
|
|
||||||
bad.add(`${m[1]} sticks out of ${m[2]}`)
|
|
||||||
}
|
|
||||||
return [...bad]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `parent` attribute of one cell. */
|
|
||||||
export function parentOf(xml: string, id: string): string | null {
|
|
||||||
const m = xml.match(new RegExp(`<mxCell id="${id}"[^>]*parent="([^"]+)"`))
|
|
||||||
return m ? m[1] : null
|
|
||||||
}
|
|
||||||
271
tests/unit/output-token-limit.test.ts
Normal file
271
tests/unit/output-token-limit.test.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||||
|
parseOutputTokenLimit,
|
||||||
|
resolveMaxOutputTokens,
|
||||||
|
withOutputTokenLimitFallback,
|
||||||
|
} from "@/lib/output-token-limit"
|
||||||
|
|
||||||
|
describe("parseOutputTokenLimit", () => {
|
||||||
|
it("reads the ceiling from a Bedrock rejection", () => {
|
||||||
|
const error = {
|
||||||
|
message:
|
||||||
|
"The maximum tokens you requested exceeds the model limit of 4096. Try again with a maximum tokens value that is lower than 4096.",
|
||||||
|
}
|
||||||
|
expect(parseOutputTokenLimit(error)).toBe(4096)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("subtracts the input when the ceiling covers input plus output", () => {
|
||||||
|
const error = {
|
||||||
|
message:
|
||||||
|
"This endpoint's maximum context length is 64000 tokens. However, you requested about 64025 tokens (25 of text input, 64000 in the output).",
|
||||||
|
}
|
||||||
|
// 64000 - 25 - 1024 margin
|
||||||
|
expect(parseOutputTokenLimit(error)).toBe(62951)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads the ceiling from an Anthropic rejection", () => {
|
||||||
|
const error = {
|
||||||
|
message:
|
||||||
|
"max_tokens: 200000 > 64000, which is the maximum allowed number of output tokens for claude-sonnet-4-5",
|
||||||
|
}
|
||||||
|
expect(parseOutputTokenLimit(error)).toBe(64000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads the ceiling from an OpenAI rejection", () => {
|
||||||
|
const error = {
|
||||||
|
message:
|
||||||
|
"max_tokens is too large: 64000. This model supports at most 16384 completion tokens",
|
||||||
|
}
|
||||||
|
expect(parseOutputTokenLimit(error)).toBe(16384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("looks in the response body too", () => {
|
||||||
|
const error = {
|
||||||
|
message: "Bad request",
|
||||||
|
responseBody: '{"message":"exceeds the model limit of 10000."}',
|
||||||
|
}
|
||||||
|
expect(parseOutputTokenLimit(error)).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for unrelated errors", () => {
|
||||||
|
expect(parseOutputTokenLimit({ message: "Invalid API key" })).toBeNull()
|
||||||
|
expect(parseOutputTokenLimit(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores a number that is not about tokens", () => {
|
||||||
|
// An earlier draft matched "lower than N" generically, which turned any
|
||||||
|
// message shaped like this into a bogus budget
|
||||||
|
expect(
|
||||||
|
parseOutputTokenLimit({
|
||||||
|
message: "temperature must be lower than 2",
|
||||||
|
statusCode: 400,
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
parseOutputTokenLimit({
|
||||||
|
message: "reduce requests to lower than 60 per minute",
|
||||||
|
statusCode: 429,
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skips errors whose status is not a bad request", () => {
|
||||||
|
const error = {
|
||||||
|
message: "exceeds the model limit of 4096",
|
||||||
|
statusCode: 429,
|
||||||
|
}
|
||||||
|
expect(parseOutputTokenLimit(error)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a ceiling too small to hold a diagram", () => {
|
||||||
|
expect(
|
||||||
|
parseOutputTokenLimit({ message: "model limit of 200" }),
|
||||||
|
).toBeNull()
|
||||||
|
// Context ceiling that leaves almost nothing after the input
|
||||||
|
expect(
|
||||||
|
parseOutputTokenLimit({
|
||||||
|
message:
|
||||||
|
"This endpoint's maximum context length is 64000 tokens. However, you requested about 128000 tokens (63500 of text input, 64000 in the output).",
|
||||||
|
}),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when the input alone fills the context", () => {
|
||||||
|
const error = {
|
||||||
|
message:
|
||||||
|
"This endpoint's maximum context length is 1000 tokens. However, you requested about 65000 tokens (64000 of text input, 1000 in the output).",
|
||||||
|
}
|
||||||
|
expect(parseOutputTokenLimit(error)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveMaxOutputTokens", () => {
|
||||||
|
it("uses a valid header value", () => {
|
||||||
|
expect(resolveMaxOutputTokens("32000")).toBe(32000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the default for missing or bogus values", () => {
|
||||||
|
expect(resolveMaxOutputTokens(null)).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
expect(resolveMaxOutputTokens("")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
expect(resolveMaxOutputTokens("abc")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
expect(resolveMaxOutputTokens("0")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
expect(resolveMaxOutputTokens("-5")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
expect(resolveMaxOutputTokens("1.5")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
// Above the sanity ceiling, e.g. an extra zero
|
||||||
|
expect(resolveMaxOutputTokens("640000")).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the env value when no header is sent, and validates it too", () => {
|
||||||
|
const original = process.env.MAX_OUTPUT_TOKENS
|
||||||
|
try {
|
||||||
|
process.env.MAX_OUTPUT_TOKENS = "24000"
|
||||||
|
expect(resolveMaxOutputTokens(null)).toBe(24000)
|
||||||
|
// Header still wins
|
||||||
|
expect(resolveMaxOutputTokens("8000")).toBe(8000)
|
||||||
|
|
||||||
|
process.env.MAX_OUTPUT_TOKENS = "-1"
|
||||||
|
expect(resolveMaxOutputTokens(null)).toBe(DEFAULT_MAX_OUTPUT_TOKENS)
|
||||||
|
} finally {
|
||||||
|
if (original === undefined) delete process.env.MAX_OUTPUT_TOKENS
|
||||||
|
else process.env.MAX_OUTPUT_TOKENS = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Minimal stand-in for a v3 language model that records what it was asked for. */
|
||||||
|
function fakeModel(
|
||||||
|
behaviors: Array<() => Promise<unknown>>,
|
||||||
|
): [any, Array<Record<string, unknown>>] {
|
||||||
|
const calls: Array<Record<string, unknown>> = []
|
||||||
|
let index = 0
|
||||||
|
const model = {
|
||||||
|
specificationVersion: "v3" as const,
|
||||||
|
provider: "test",
|
||||||
|
modelId: "test-model",
|
||||||
|
supportedUrls: {},
|
||||||
|
doGenerate: async () => {
|
||||||
|
throw new Error("not used")
|
||||||
|
},
|
||||||
|
doStream: async (options: Record<string, unknown>) => {
|
||||||
|
calls.push(options)
|
||||||
|
const behavior = behaviors[index] ?? behaviors[behaviors.length - 1]
|
||||||
|
index++
|
||||||
|
return behavior()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return [model, calls]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STREAM_OK = { stream: new ReadableStream() }
|
||||||
|
|
||||||
|
describe("withOutputTokenLimitFallback", () => {
|
||||||
|
it("retries once with the ceiling named in the rejection", async () => {
|
||||||
|
const [model, calls] = fakeModel([
|
||||||
|
() =>
|
||||||
|
Promise.reject(
|
||||||
|
Object.assign(
|
||||||
|
new Error("exceeds the model limit of 4096"),
|
||||||
|
{ statusCode: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
() => Promise.resolve(STREAM_OK),
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapped = withOutputTokenLimitFallback(model)
|
||||||
|
await wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any)
|
||||||
|
|
||||||
|
expect(calls.map((c) => c.maxOutputTokens)).toEqual([64000, 4096])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not retry an error it cannot attribute to the budget", async () => {
|
||||||
|
const [model, calls] = fakeModel([
|
||||||
|
() =>
|
||||||
|
Promise.reject(
|
||||||
|
Object.assign(new Error("Invalid API key"), {
|
||||||
|
statusCode: 401,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapped = withOutputTokenLimitFallback(model)
|
||||||
|
await expect(
|
||||||
|
wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any),
|
||||||
|
).rejects.toThrow("Invalid API key")
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not retry when the ceiling is not actually smaller", async () => {
|
||||||
|
const [model, calls] = fakeModel([
|
||||||
|
() =>
|
||||||
|
Promise.reject(
|
||||||
|
Object.assign(
|
||||||
|
new Error("exceeds the model limit of 64000"),
|
||||||
|
{ statusCode: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapped = withOutputTokenLimitFallback(model)
|
||||||
|
await expect(
|
||||||
|
wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any),
|
||||||
|
).rejects.toThrow()
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("retries at most once, so a second rejection propagates", async () => {
|
||||||
|
const [model, calls] = fakeModel([
|
||||||
|
() =>
|
||||||
|
Promise.reject(
|
||||||
|
Object.assign(
|
||||||
|
new Error("exceeds the model limit of 4096"),
|
||||||
|
{ statusCode: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
Promise.reject(
|
||||||
|
Object.assign(
|
||||||
|
new Error("exceeds the model limit of 2048"),
|
||||||
|
{ statusCode: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapped = withOutputTokenLimitFallback(model)
|
||||||
|
await expect(
|
||||||
|
wrapped.doStream({ prompt: [], maxOutputTokens: 64000 } as any),
|
||||||
|
).rejects.toThrow("model limit of 2048")
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps the other call options when retrying", async () => {
|
||||||
|
const [model, calls] = fakeModel([
|
||||||
|
() =>
|
||||||
|
Promise.reject(
|
||||||
|
Object.assign(
|
||||||
|
new Error("exceeds the model limit of 4096"),
|
||||||
|
{ statusCode: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
() => Promise.resolve(STREAM_OK),
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapped = withOutputTokenLimitFallback(model)
|
||||||
|
await wrapped.doStream({
|
||||||
|
prompt: [],
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
temperature: 0.4,
|
||||||
|
providerOptions: {
|
||||||
|
bedrock: { reasoningConfig: { type: "enabled" } },
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(calls[1].temperature).toBe(0.4)
|
||||||
|
expect(calls[1].providerOptions).toEqual({
|
||||||
|
bedrock: { reasoningConfig: { type: "enabled" } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { render } from "@testing-library/react"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
|
||||||
|
|
||||||
const dict = {
|
|
||||||
tools: { complete: "Complete" },
|
|
||||||
chat: { copied: "c", failedToCopy: "f", copyResponse: "r" },
|
|
||||||
}
|
|
||||||
const base = {
|
|
||||||
expandedTools: {},
|
|
||||||
setExpandedTools: () => {},
|
|
||||||
onCopy: () => {},
|
|
||||||
copiedToolCallId: null,
|
|
||||||
copyFailedToolCallId: null,
|
|
||||||
dict,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the tool-call card shows in the chat.
|
|
||||||
*
|
|
||||||
* Both diagram tools happen to name their argument `operations`, but the items have
|
|
||||||
* different shapes: edit_diagram sends `operation`/`cell_id`/`new_xml` patches, while
|
|
||||||
* restructure_diagram sends `op`/`id` structural steps. The card used to dispatch on
|
|
||||||
* "does an operations key exist", so a restructure call was rendered as edit patches and
|
|
||||||
* every row printed a blank `cell_id:` label with nothing after it.
|
|
||||||
*/
|
|
||||||
describe("ToolCallCard", () => {
|
|
||||||
it("shows restructure_diagram operations, not blank cell_id rows", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard
|
|
||||||
{...base}
|
|
||||||
part={{
|
|
||||||
type: "tool-restructure_diagram",
|
|
||||||
toolCallId: "t1",
|
|
||||||
state: "output-available",
|
|
||||||
input: {
|
|
||||||
operations: [
|
|
||||||
{ op: "set_page", aspect: 0.8 },
|
|
||||||
{
|
|
||||||
op: "add_container",
|
|
||||||
id: "page",
|
|
||||||
label: "",
|
|
||||||
dir: "col",
|
|
||||||
class: "gap-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_box",
|
|
||||||
id: "mast",
|
|
||||||
parent: "page",
|
|
||||||
label: "Title",
|
|
||||||
role: "banner",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
op: "add_graph",
|
|
||||||
id: "g",
|
|
||||||
nodes: [{ id: "a" }, { id: "b" }],
|
|
||||||
edges: [{ source: "a", target: "b" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
output: 'Diagram updated.\n\npage: col (wrapper)\n mast: box "Title"',
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
const text = container.textContent ?? ""
|
|
||||||
// The bug: every row printed "cell_id:" with nothing after it.
|
|
||||||
expect(text).not.toContain("cell_id:")
|
|
||||||
// Operation names and ids are visible.
|
|
||||||
for (const s of [
|
|
||||||
"set_page",
|
|
||||||
"add_container",
|
|
||||||
"add_box",
|
|
||||||
"add_graph",
|
|
||||||
"page",
|
|
||||||
"mast",
|
|
||||||
])
|
|
||||||
expect(text).toContain(s)
|
|
||||||
// Arguments are summarised.
|
|
||||||
expect(text).toContain("class=gap-4")
|
|
||||||
expect(text).toContain("2 nodes, 1 edge")
|
|
||||||
// The tool's own answer is shown, not thrown away.
|
|
||||||
expect(text).toContain("Diagram updated.")
|
|
||||||
// And it has a readable name.
|
|
||||||
expect(text).toContain("Build Diagram")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still renders edit_diagram patches the old way", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard
|
|
||||||
{...base}
|
|
||||||
part={{
|
|
||||||
type: "tool-edit_diagram",
|
|
||||||
toolCallId: "t2",
|
|
||||||
state: "output-available",
|
|
||||||
input: {
|
|
||||||
operations: [
|
|
||||||
{
|
|
||||||
operation: "update",
|
|
||||||
cell_id: "3",
|
|
||||||
new_xml: '<mxCell id="3"/>',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
const text = container.textContent ?? ""
|
|
||||||
expect(text).toContain("cell_id: 3")
|
|
||||||
expect(text).toContain("update")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streaming: the tool input arrives character by character.
|
|
||||||
*
|
|
||||||
* The card renders on every frame of that, so it is handed JSON that has been repaired
|
|
||||||
* mid-flight — an operation may be `{}`, have a half-typed name, or be a hole in the array.
|
|
||||||
* The first version of the restructure renderer read `op.op.startsWith(...)` and crashed the
|
|
||||||
* whole message with "Cannot read properties of undefined". These cases are what the earlier
|
|
||||||
* tests missed by only ever passing complete input.
|
|
||||||
*/
|
|
||||||
describe("ToolCallCard while the input is still streaming", () => {
|
|
||||||
const partial = (operations: unknown[]) => ({
|
|
||||||
type: "tool-restructure_diagram",
|
|
||||||
toolCallId: "s1",
|
|
||||||
state: "input-streaming",
|
|
||||||
input: { operations },
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders an operation with no name yet", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard {...base} part={partial([{}]) as never} />,
|
|
||||||
)
|
|
||||||
expect(container.textContent).toContain("…")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders a half-typed operation name", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard
|
|
||||||
{...base}
|
|
||||||
part={partial([{ op: "add_contai" }]) as never}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(container.textContent).toContain("add_contai")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives a hole in the array", () => {
|
|
||||||
// A repaired JSON array can have missing entries, which arrive as undefined.
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard
|
|
||||||
{...base}
|
|
||||||
part={
|
|
||||||
partial([
|
|
||||||
{ op: "set_page", aspect: 0.8 },
|
|
||||||
undefined,
|
|
||||||
null,
|
|
||||||
{ op: "add_box", id: "a", label: "A" },
|
|
||||||
]) as never
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
const text = container.textContent ?? ""
|
|
||||||
expect(text).toContain("set_page")
|
|
||||||
expect(text).toContain("add_box")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("survives an operation whose fields are half-formed", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard
|
|
||||||
{...base}
|
|
||||||
part={
|
|
||||||
partial([
|
|
||||||
{ op: "add_graph", id: "g", nodes: undefined },
|
|
||||||
{ op: "add_box", label: null },
|
|
||||||
{ op: 42 },
|
|
||||||
]) as never
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(container.textContent).toContain("add_graph")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("edit_diagram's renderer survives the same partial input", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCallCard
|
|
||||||
{...base}
|
|
||||||
part={
|
|
||||||
{
|
|
||||||
type: "tool-edit_diagram",
|
|
||||||
toolCallId: "s2",
|
|
||||||
state: "input-streaming",
|
|
||||||
input: {
|
|
||||||
operations: [
|
|
||||||
{},
|
|
||||||
{ operation: "upda" },
|
|
||||||
undefined,
|
|
||||||
{ operation: "update", cell_id: "3" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as never
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
const text = container.textContent ?? ""
|
|
||||||
expect(text).toContain("cell_id: 3")
|
|
||||||
// Exactly one label, for the one entry that has an id — a half-formed entry must
|
|
||||||
// not print a bare "cell_id:" with nothing after it, which is the original bug.
|
|
||||||
expect(text.match(/cell_id:/g)).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"functions": {
|
"functions": {
|
||||||
"app/api/chat/route.ts": {
|
"app/api/chat/route.ts": {
|
||||||
"memory": 512,
|
"memory": 512,
|
||||||
"maxDuration": 120
|
"maxDuration": 300
|
||||||
},
|
},
|
||||||
"app/api/**/route.ts": {
|
"app/api/**/route.ts": {
|
||||||
"memory": 256,
|
"memory": 256,
|
||||||
|
|||||||
Reference in New Issue
Block a user