diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index acd3c09..5cb2ee5 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -408,33 +408,37 @@ Notes: }), }, edit_diagram: { - description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML. -CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly. -IMPORTANT: Keep edits concise: -- COPY the exact mxCell line from the current XML (attribute order matters!) -- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed -- Break large changes into multiple smaller edits -- Each search must contain complete lines (never truncate mid-line) -- First match only - be specific enough to target the right element + description: `Edit the current diagram by ID-based operations (update/add/delete cells). -⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`, +Operations: +- update: Replace an existing cell by its id. Provide cell_id and complete new_xml. +- add: Add a new cell. Provide cell_id (new unique id) and new_xml. +- delete: Remove a cell by its id. Only cell_id is needed. + +For update/add, new_xml must be a complete mxCell element including mxGeometry. + +⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`, inputSchema: z.object({ - edits: z + operations: z .array( z.object({ - search: z + type: z + .enum(["update", "add", "delete"]) + .describe("Operation type"), + cell_id: z .string() .describe( - "EXACT lines copied from current XML (preserve attribute order!)", + "The id of the mxCell. Must match the id attribute in new_xml.", ), - replace: z + new_xml: z .string() - .describe("Replacement lines"), + .optional() + .describe( + "Complete mxCell XML element (required for update/add)", + ), }), ) - .describe( - "Array of search/replace pairs to apply sequentially", - ), + .describe("Array of operations to apply"), }), }, append_diagram: { diff --git a/components/chat-message-display.tsx b/components/chat-message-display.tsx index 34b605c..3be4322 100644 --- a/components/chat-message-display.tsx +++ b/components/chat-message-display.tsx @@ -10,9 +10,7 @@ import { Cpu, FileCode, FileText, - Minus, Pencil, - Plus, RotateCcw, ThumbsDown, ThumbsUp, @@ -38,9 +36,10 @@ import { import ExamplePanel from "./chat-example-panel" import { CodeBlock } from "./code-block" -interface EditPair { - search: string - replace: string +interface DiagramOperation { + type: "update" | "add" | "delete" + cell_id: string + new_xml?: string } // Tool part interface for type safety @@ -48,49 +47,44 @@ interface ToolPartLike { type: string toolCallId: string state?: string - input?: { xml?: string; edits?: EditPair[] } & Record + input?: { + xml?: string + operations?: DiagramOperation[] + } & Record output?: string } -function EditDiffDisplay({ edits }: { edits: EditPair[] }) { +function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) { return (
- {edits.map((edit, index) => ( + {operations.map((op, index) => (
- - Change {index + 1} + + {op.type} + + + cell_id: {op.cell_id}
-
- {/* Search (old) */} + {op.new_xml && (
-
- - - Remove - -
-
-                                {edit.search}
+                            
+                                {op.new_xml}
                             
- {/* Replace (new) */} -
-
- - - Add - -
-
-                                {edit.replace}
-                            
-
-
+ )}
))}
@@ -582,9 +576,9 @@ export function ChatMessageDisplay({ {typeof input === "object" && input.xml ? ( ) : typeof input === "object" && - input.edits && - Array.isArray(input.edits) ? ( - + input.operations && + Array.isArray(input.operations) ? ( + ) : typeof input === "object" && Object.keys(input).length > 0 ? ( + const { operations } = toolCall.input as { + operations: Array<{ + type: "update" | "add" | "delete" + cell_id: string + new_xml?: string + }> } let currentXml = "" @@ -338,8 +342,36 @@ ${finalXml} currentXml = await onFetchChart(false) } - const { replaceXMLParts } = await import("@/lib/utils") - const editedXml = replaceXMLParts(currentXml, edits) + const { applyDiagramOperations } = await import( + "@/lib/utils" + ) + const { result: editedXml, errors } = + applyDiagramOperations(currentXml, operations) + + // Check for operation errors + if (errors.length > 0) { + const errorMessages = errors + .map( + (e) => + `- ${e.type} on cell_id="${e.cellId}": ${e.message}`, + ) + .join("\n") + + addToolOutput({ + tool: "edit_diagram", + toolCallId: toolCall.toolCallId, + state: "output-error", + errorText: `Some operations failed:\n${errorMessages} + +Current diagram XML: +\`\`\`xml +${currentXml} +\`\`\` + +Please check the cell IDs and retry.`, + }) + return + } // loadDiagram validates and returns error if invalid const validationError = onDisplayChart(editedXml) @@ -359,7 +391,7 @@ Current diagram XML: ${currentXml} \`\`\` -Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid references).`, +Please fix the operations to avoid structural issues.`, }) return } @@ -367,7 +399,7 @@ Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid ref addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, - output: `Successfully applied ${edits.length} edit(s) to the diagram.`, + output: `Successfully applied ${operations.length} operation(s) to the diagram.`, }) } catch (error) { console.error("[edit_diagram] Failed:", error) @@ -375,7 +407,6 @@ Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid ref const errorMessage = error instanceof Error ? error.message : String(error) - // Use addToolOutput with state: 'output-error' for proper error signaling addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, @@ -387,7 +418,7 @@ Current diagram XML: ${currentXml || "No XML available"} \`\`\` -Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`, +Please check cell IDs and retry, or use display_diagram to regenerate.`, }) } } else if (toolCall.toolName === "append_diagram") { diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index 5bfed24..2f1bc3b 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -88,19 +88,15 @@ Note that: - NEVER include XML comments () in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns. When using edit_diagram tool: -- CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters! -- Always include the element's id attribute for unique targeting: {"search": "", "replace": ""} -- For multiple changes, use separate edits in array -- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead. +- 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": [{"type": "update", "cell_id": "3", "new_xml": "\\n \\n"}]} +- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]} +- Example add: {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]} -⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values: -- CORRECT: "y=\\"119\\"" (both quotes escaped) -- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!) -- Every " inside a JSON string value needs \\" - no exceptions! +⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ## Draw.io XML Structure Reference @@ -268,69 +264,43 @@ const EXTENDED_ADDITIONS = ` ### edit_diagram Details -**CRITICAL RULES:** -- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context -- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly -- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed -- Break large changes into multiple smaller edits -- Each search must contain complete lines (never truncate mid-line) -- First match only - be specific enough to target the right element +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. +- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml. +- **delete**: Remove a cell. Only cell_id is needed. **Input Format:** \`\`\`json { - "edits": [ - { - "search": "EXACT lines copied from current XML (preserve attribute order!)", - "replace": "Replacement lines" - } + "operations": [ + {"type": "update", "cell_id": "3", "new_xml": ""}, + {"type": "add", "cell_id": "new1", "new_xml": ""}, + {"type": "delete", "cell_id": "5"} ] } \`\`\` -## edit_diagram Best Practices +**Examples:** -### Core Principle: Unique & Precise Patterns -Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern: -1. Review the "Current diagram XML" in the system context -2. Identify the exact element(s) to modify by their unique id attribute -3. Include enough context to ensure uniqueness - -### Pattern Construction Rules - -**Rule 1: Always include the element's id attribute** +Change label: \`\`\`json -{"search": "\\n \\n"}]} \`\`\` -**Rule 2: Include complete XML elements when possible** +Add new shape: \`\`\`json -{ - "search": "\\n \\n", - "replace": "\\n \\n" -} +{"operations": [{"type": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]} \`\`\` -**Rule 3: Preserve exact whitespace and formatting** -Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order. +Delete cell: +\`\`\`json +{"operations": [{"type": "delete", "cell_id": "5"}]} +\`\`\` -### Good vs Bad Patterns - -**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements -**BAD:** \`{"search": ""}\` - Uses unique id with full context - -### ⚠️ JSON Escaping (CRITICAL) -Every double quote inside JSON string values MUST be escaped with backslash: -- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped -- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error! - -### Error Recovery -If edit_diagram fails with "pattern not found": -1. **First retry**: Check attribute order - copy EXACTLY from current XML -2. **Second retry**: Expand context - include more surrounding lines -3. **Third retry**: Try matching on just \` { - const dict = new Map() - for (const char of str) { - dict.set(char, (dict.get(char) || 0) + 1) - } - return dict +// ============================================================================ +// ID-based Diagram Operations +// ============================================================================ + +export interface DiagramOperation { + type: "update" | "add" | "delete" + cell_id: string + new_xml?: string +} + +export interface OperationError { + type: "update" | "add" | "delete" + cellId: string + message: string +} + +export interface ApplyOperationsResult { + result: string + errors: OperationError[] } /** - * Compare two strings by character frequency (order-agnostic) + * Apply diagram operations (update/add/delete) using ID-based lookup. + * This replaces the text-matching approach with direct DOM manipulation. + * + * @param xmlContent - The full mxfile XML content + * @param operations - Array of operations to apply + * @returns Object with result XML and any errors */ -function sameCharFrequency(a: string, b: string): boolean { - const trimmedA = a.trim() - const trimmedB = b.trim() - if (trimmedA.length !== trimmedB.length) return false - - const dictA = charCountDict(trimmedA) - const dictB = charCountDict(trimmedB) - - if (dictA.size !== dictB.size) return false - - for (const [char, count] of dictA) { - if (dictB.get(char) !== count) return false - } - return true -} - -/** - * Replace specific parts of XML content using search and replace pairs - * @param xmlContent - The original XML string - * @param searchReplacePairs - Array of {search: string, replace: string} objects - * @returns The updated XML string with replacements applied - */ -export function replaceXMLParts( +export function applyDiagramOperations( xmlContent: string, - searchReplacePairs: Array<{ search: string; replace: string }>, -): string { - // Format the XML first to ensure consistent line breaks - let result = formatXML(xmlContent) + operations: DiagramOperation[], +): ApplyOperationsResult { + const errors: OperationError[] = [] - for (const { search, replace } of searchReplacePairs) { - // Also format the search content for consistency - const formattedSearch = formatXML(search) - const searchLines = formattedSearch.split("\n") + // Parse the XML + const parser = new DOMParser() + const doc = parser.parseFromString(xmlContent, "text/xml") - // Split into lines for exact line matching - const resultLines = result.split("\n") - - // Remove trailing empty line if exists (from the trailing \n in search content) - if (searchLines[searchLines.length - 1] === "") { - searchLines.pop() + // Check for parse errors + const parseError = doc.querySelector("parsererror") + if (parseError) { + return { + result: xmlContent, + errors: [ + { + type: "update", + cellId: "", + message: `XML parse error: ${parseError.textContent}`, + }, + ], } - - // Always search from the beginning - pairs may not be in document order - const startLineNum = 0 - - // Try to find match using multiple strategies - let matchFound = false - let matchStartLine = -1 - let matchEndLine = -1 - - // First try: exact match - for ( - let i = startLineNum; - i <= resultLines.length - searchLines.length; - i++ - ) { - let matches = true - - for (let j = 0; j < searchLines.length; j++) { - if (resultLines[i + j] !== searchLines[j]) { - matches = false - break - } - } - - if (matches) { - matchStartLine = i - matchEndLine = i + searchLines.length - matchFound = true - break - } - } - - // Second try: line-trimmed match (fallback) - if (!matchFound) { - for ( - let i = startLineNum; - i <= resultLines.length - searchLines.length; - i++ - ) { - let matches = true - - for (let j = 0; j < searchLines.length; j++) { - const originalTrimmed = resultLines[i + j].trim() - const searchTrimmed = searchLines[j].trim() - - if (originalTrimmed !== searchTrimmed) { - matches = false - break - } - } - - if (matches) { - matchStartLine = i - matchEndLine = i + searchLines.length - matchFound = true - break - } - } - } - - // Third try: substring match as last resort (for single-line XML) - if (!matchFound) { - // Try to find as a substring in the entire content - const searchStr = search.trim() - const resultStr = result - const index = resultStr.indexOf(searchStr) - - if (index !== -1) { - // Found as substring - replace it - result = - resultStr.substring(0, index) + - replace.trim() + - resultStr.substring(index + searchStr.length) - // Re-format after substring replacement - result = formatXML(result) - continue // Skip the line-based replacement below - } - } - - // Fourth try: character frequency match (attribute-order agnostic) - // This handles cases where the model generates XML with different attribute order - if (!matchFound) { - for ( - let i = startLineNum; - i <= resultLines.length - searchLines.length; - i++ - ) { - let matches = true - - for (let j = 0; j < searchLines.length; j++) { - if ( - !sameCharFrequency(resultLines[i + j], searchLines[j]) - ) { - matches = false - break - } - } - - if (matches) { - matchStartLine = i - matchEndLine = i + searchLines.length - matchFound = true - break - } - } - } - - // Fifth try: Match by mxCell id attribute - // Extract id from search pattern and find the element with that id - if (!matchFound) { - const idMatch = search.match(/id="([^"]+)"/) - if (idMatch) { - const searchId = idMatch[1] - // Find lines that contain this id - for (let i = startLineNum; i < resultLines.length; i++) { - if (resultLines[i].includes(`id="${searchId}"`)) { - // Found the element with matching id - // Now find the extent of this element (it might span multiple lines) - let endLine = i + 1 - const line = resultLines[i].trim() - - // Check if it's a self-closing tag or has children - if (!line.endsWith("/>")) { - // Find the closing tag or the end of the mxCell block - let depth = 1 - while (endLine < resultLines.length && depth > 0) { - const currentLine = resultLines[endLine].trim() - if ( - currentLine.startsWith("<") && - !currentLine.startsWith("") - ) { - depth++ - } else if (currentLine.startsWith("")) { - let depth = 1 - while (endLine < resultLines.length && depth > 0) { - const currentLine = resultLines[endLine].trim() - if ( - currentLine.startsWith("<") && - !currentLine.startsWith("") - ) { - depth++ - } else if (currentLine.startsWith(" s.replace(/\s+/g, " ").trim() - const normalizedSearch = normalizeWs(search) - - for ( - let i = startLineNum; - i <= resultLines.length - searchLines.length; - i++ - ) { - // Build a normalized version of the candidate lines - const candidateLines = resultLines.slice( - i, - i + searchLines.length, - ) - const normalizedCandidate = normalizeWs( - candidateLines.join(" "), - ) - - if (normalizedCandidate === normalizedSearch) { - matchStartLine = i - matchEndLine = i + searchLines.length - matchFound = true - break - } - } - } - - if (!matchFound) { - throw new Error( - `Search pattern not found in the diagram. The pattern may not exist in the current structure.`, - ) - } - - // Replace the matched lines - const replaceLines = replace.split("\n") - - // Remove trailing empty line if exists - if (replaceLines[replaceLines.length - 1] === "") { - replaceLines.pop() - } - - // Perform the replacement - const newResultLines = [ - ...resultLines.slice(0, matchStartLine), - ...replaceLines, - ...resultLines.slice(matchEndLine), - ] - - result = newResultLines.join("\n") } - return result + // Find the root element (inside mxGraphModel) + const root = doc.querySelector("root") + if (!root) { + return { + result: xmlContent, + errors: [ + { + type: "update", + cellId: "", + message: "Could not find element in XML", + }, + ], + } + } + + // Build a map of cell IDs to elements + const cellMap = new Map() + root.querySelectorAll("mxCell").forEach((cell) => { + const id = cell.getAttribute("id") + if (id) cellMap.set(id, cell) + }) + + // Process each operation + for (const op of operations) { + if (op.type === "update") { + const existingCell = cellMap.get(op.cell_id) + if (!existingCell) { + errors.push({ + type: "update", + cellId: op.cell_id, + message: `Cell with id="${op.cell_id}" not found`, + }) + continue + } + + if (!op.new_xml) { + errors.push({ + type: "update", + cellId: op.cell_id, + message: "new_xml is required for update operation", + }) + continue + } + + // Parse the new XML + const newDoc = parser.parseFromString( + `${op.new_xml}`, + "text/xml", + ) + const newCell = newDoc.querySelector("mxCell") + if (!newCell) { + errors.push({ + type: "update", + cellId: op.cell_id, + message: "new_xml must contain an mxCell element", + }) + continue + } + + // Validate ID matches + const newCellId = newCell.getAttribute("id") + if (newCellId !== op.cell_id) { + errors.push({ + type: "update", + cellId: op.cell_id, + message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, + }) + continue + } + + // Import and replace the node + const importedNode = doc.importNode(newCell, true) + existingCell.parentNode?.replaceChild(importedNode, existingCell) + + // Update the map with the new element + cellMap.set(op.cell_id, importedNode) + } else if (op.type === "add") { + // Check if ID already exists + if (cellMap.has(op.cell_id)) { + errors.push({ + type: "add", + cellId: op.cell_id, + message: `Cell with id="${op.cell_id}" already exists`, + }) + continue + } + + if (!op.new_xml) { + errors.push({ + type: "add", + cellId: op.cell_id, + message: "new_xml is required for add operation", + }) + continue + } + + // Parse the new XML + const newDoc = parser.parseFromString( + `${op.new_xml}`, + "text/xml", + ) + const newCell = newDoc.querySelector("mxCell") + if (!newCell) { + errors.push({ + type: "add", + cellId: op.cell_id, + message: "new_xml must contain an mxCell element", + }) + continue + } + + // Validate ID matches + const newCellId = newCell.getAttribute("id") + if (newCellId !== op.cell_id) { + errors.push({ + type: "add", + cellId: op.cell_id, + message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, + }) + continue + } + + // Import and append the node + const importedNode = doc.importNode(newCell, true) + root.appendChild(importedNode) + + // Add to map + cellMap.set(op.cell_id, importedNode) + } else if (op.type === "delete") { + const existingCell = cellMap.get(op.cell_id) + if (!existingCell) { + errors.push({ + type: "delete", + cellId: op.cell_id, + message: `Cell with id="${op.cell_id}" not found`, + }) + continue + } + + // Check for edges referencing this cell (warning only, still delete) + const referencingEdges = root.querySelectorAll( + `mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`, + ) + if (referencingEdges.length > 0) { + const edgeIds = Array.from(referencingEdges) + .map((e) => e.getAttribute("id")) + .join(", ") + console.warn( + `[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`, + ) + } + + // Remove the node + existingCell.parentNode?.removeChild(existingCell) + cellMap.delete(op.cell_id) + } + } + + // Serialize back to string + const serializer = new XMLSerializer() + const result = serializer.serializeToString(doc) + + return { result, errors } } // ============================================================================ diff --git a/scripts/test-diagram-operations.mjs b/scripts/test-diagram-operations.mjs new file mode 100644 index 0000000..252660d --- /dev/null +++ b/scripts/test-diagram-operations.mjs @@ -0,0 +1,251 @@ +/** + * Simple test script for applyDiagramOperations function + * Run with: node scripts/test-diagram-operations.mjs + */ + +import { JSDOM } from "jsdom" + +// Set up DOMParser for Node.js environment +const dom = new JSDOM() +globalThis.DOMParser = dom.window.DOMParser +globalThis.XMLSerializer = dom.window.XMLSerializer + +// Import the function (we'll inline it since it's not ESM exported) +function applyDiagramOperations(xmlContent, operations) { + const errors = [] + const parser = new DOMParser() + const doc = parser.parseFromString(xmlContent, "text/xml") + + const parseError = doc.querySelector("parsererror") + if (parseError) { + return { + result: xmlContent, + errors: [{ type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}` }], + } + } + + const root = doc.querySelector("root") + if (!root) { + return { + result: xmlContent, + errors: [{ type: "update", cellId: "", message: "Could not find element in XML" }], + } + } + + const cellMap = new Map() + root.querySelectorAll("mxCell").forEach((cell) => { + const id = cell.getAttribute("id") + if (id) cellMap.set(id, cell) + }) + + for (const op of operations) { + if (op.type === "update") { + const existingCell = cellMap.get(op.cell_id) + if (!existingCell) { + errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` }) + continue + } + if (!op.new_xml) { + errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation" }) + continue + } + const newDoc = parser.parseFromString(`${op.new_xml}`, "text/xml") + const newCell = newDoc.querySelector("mxCell") + if (!newCell) { + errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element" }) + continue + } + const newCellId = newCell.getAttribute("id") + if (newCellId !== op.cell_id) { + errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` }) + continue + } + const importedNode = doc.importNode(newCell, true) + existingCell.parentNode?.replaceChild(importedNode, existingCell) + cellMap.set(op.cell_id, importedNode) + } else if (op.type === "add") { + if (cellMap.has(op.cell_id)) { + errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists` }) + continue + } + if (!op.new_xml) { + errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation" }) + continue + } + const newDoc = parser.parseFromString(`${op.new_xml}`, "text/xml") + const newCell = newDoc.querySelector("mxCell") + if (!newCell) { + errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element" }) + continue + } + const newCellId = newCell.getAttribute("id") + if (newCellId !== op.cell_id) { + errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` }) + continue + } + const importedNode = doc.importNode(newCell, true) + root.appendChild(importedNode) + cellMap.set(op.cell_id, importedNode) + } else if (op.type === "delete") { + const existingCell = cellMap.get(op.cell_id) + if (!existingCell) { + errors.push({ type: "delete", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` }) + continue + } + existingCell.parentNode?.removeChild(existingCell) + cellMap.delete(op.cell_id) + } + } + + const serializer = new XMLSerializer() + const result = serializer.serializeToString(doc) + return { result, errors } +} + +// Test data +const sampleXml = ` + + + + + + + + + + + + + + + + + + +` + +let passed = 0 +let failed = 0 + +function test(name, fn) { + try { + fn() + console.log(`✓ ${name}`) + passed++ + } catch (e) { + console.log(`✗ ${name}`) + console.log(` Error: ${e.message}`) + failed++ + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || "Assertion failed") +} + +// Tests +test("Update operation changes cell value", () => { + const { result, errors } = applyDiagramOperations(sampleXml, [ + { + type: "update", + cell_id: "2", + new_xml: '', + }, + ]) + assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`) + assert(result.includes('value="Updated Box A"'), "Updated value should be in result") + assert(!result.includes('value="Box A"'), "Old value should not be in result") +}) + +test("Update operation fails for non-existent cell", () => { + const { errors } = applyDiagramOperations(sampleXml, [ + { type: "update", cell_id: "999", new_xml: '' }, + ]) + assert(errors.length === 1, "Should have one error") + assert(errors[0].message.includes("not found"), "Error should mention not found") +}) + +test("Update operation fails on ID mismatch", () => { + const { errors } = applyDiagramOperations(sampleXml, [ + { type: "update", cell_id: "2", new_xml: '' }, + ]) + assert(errors.length === 1, "Should have one error") + assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch") +}) + +test("Add operation creates new cell", () => { + const { result, errors } = applyDiagramOperations(sampleXml, [ + { + type: "add", + cell_id: "new1", + new_xml: '', + }, + ]) + assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`) + assert(result.includes('id="new1"'), "New cell should be in result") + assert(result.includes('value="New Box"'), "New cell value should be in result") +}) + +test("Add operation fails for duplicate ID", () => { + const { errors } = applyDiagramOperations(sampleXml, [ + { type: "add", cell_id: "2", new_xml: '' }, + ]) + assert(errors.length === 1, "Should have one error") + assert(errors[0].message.includes("already exists"), "Error should mention already exists") +}) + +test("Add operation fails on ID mismatch", () => { + const { errors } = applyDiagramOperations(sampleXml, [ + { type: "add", cell_id: "new1", new_xml: '' }, + ]) + assert(errors.length === 1, "Should have one error") + assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch") +}) + +test("Delete operation removes cell", () => { + const { result, errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "3" }]) + assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`) + assert(!result.includes('id="3"'), "Deleted cell should not be in result") + assert(result.includes('id="2"'), "Other cells should remain") +}) + +test("Delete operation fails for non-existent cell", () => { + const { errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "999" }]) + assert(errors.length === 1, "Should have one error") + assert(errors[0].message.includes("not found"), "Error should mention not found") +}) + +test("Multiple operations in sequence", () => { + const { result, errors } = applyDiagramOperations(sampleXml, [ + { + type: "update", + cell_id: "2", + new_xml: '', + }, + { + type: "add", + cell_id: "new1", + new_xml: '', + }, + { type: "delete", cell_id: "3" }, + ]) + assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`) + assert(result.includes('value="Updated"'), "Updated value should be present") + assert(result.includes('id="new1"'), "Added cell should be present") + assert(!result.includes('id="3"'), "Deleted cell should not be present") +}) + +test("Invalid XML returns parse error", () => { + const { errors } = applyDiagramOperations(" { + const { errors } = applyDiagramOperations("", [{ type: "delete", cell_id: "1" }]) + assert(errors.length === 1, "Should have one error") + assert(errors[0].message.includes("root"), "Error should mention root element") +}) + +// Summary +console.log(`\n${passed} passed, ${failed} failed`) +process.exit(failed > 0 ? 1 : 0)