import assert from "node:assert/strict"; import test from "node:test"; import type { DiagramIRV2 } from "../../model/diagram-ir.js"; import { applyEditBatch, type EditBatch } from "./edit-batch.js"; const SOURCE: DiagramIRV2 = { version: 2, pages: [{ id: "main", title: "Main", layout: { type: "manual" }, nodes: [ { id: "zone", label: "Zone", kind: "container", geometry: { x: 0, y: 0, width: 400, height: 300 } }, { id: "api", label: "API", kind: "service", parentId: "zone", geometry: { x: 20, y: 40, width: 120, height: 60 } }, { id: "db", label: "DB", kind: "database", geometry: { x: 500, y: 40, width: 120, height: 60 } }, ], edges: [{ id: "query", source: "api", target: "db", label: "SQL" }] }] }; test("transactional edits apply typed add/update/move/connect operations with stable IDs", () => { const batch: EditBatch = { pageId: "main", preconditions: [{ type: "exists", id: "api" }, { type: "not-exists", id: "worker" }], operations: [ { type: "add", node: { id: "worker", label: "Worker", kind: "service", geometry: { x: 200, y: 40, width: 120, height: 60 } } }, { type: "update", id: "api", changes: { label: "Public API", properties: { owner: "platform" } } }, { type: "move", id: "worker", parentId: "zone", geometry: { x: 180, y: 140, width: 120, height: 60 } }, { type: "connect", edge: { id: "dispatch", source: "api", target: "worker", kind: "async" } }, ] }; const result = applyEditBatch(SOURCE, batch); assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "api")?.label, "Public API"); assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.parentId, "zone"); assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "dispatch")?.target, "worker"); assert.deepEqual(SOURCE.pages[0].nodes.map((node) => node.id), ["zone", "api", "db"]); }); test("edit batches reject unknown precondition types", () => { const batch = { pageId: "main", preconditions: [{ type: "surprise", id: "missing" }], operations: [] } as unknown as EditBatch; assert.throws(() => applyEditBatch(SOURCE, batch), /unknown precondition type.*surprise/i); }); test("edit batches reject unknown operation types instead of deleting", () => { const batch = { pageId: "main", operations: [{ type: "surprise", id: "api" }] } as unknown as EditBatch; assert.throws(() => applyEditBatch(SOURCE, batch), /unknown operation type.*surprise/i); assert.ok(SOURCE.pages[0].nodes.some((node) => node.id === "api")); }); test("edit batches reject id changes in runtime update payloads", () => { const batch = { pageId: "main", operations: [{ type: "update", id: "api", changes: { id: "renamed" } }] } as unknown as EditBatch; assert.throws(() => applyEditBatch(SOURCE, batch), /update changes.*id/i); }); test("edit batches are atomic, support dry-run, and require explicit cascade", () => { const before = JSON.stringify(SOURCE); assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone" }] }), /cascade/i); assert.equal(JSON.stringify(SOURCE), before); assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "update", id: "api", changes: { parentId: "missing" } }] }), /unknown parent/i); assert.equal(JSON.stringify(SOURCE), before); const dry = applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone", cascade: true }] }, { dryRun: true }); assert.deepEqual(dry.ir, SOURCE); assert.deepEqual(dry.preview.pages[0].nodes.map((node) => node.id), ["db"]); });