/** * Integration tests of EditorController on a jsdom-hosted JointJS paper. * Rendering fidelity is not asserted (jsdom has no layout); model-level * behavior — adding, linking, deleting, undo/redo, clipboard, simulation — * is exercised end to end. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EditorController, DEFAULT_CONFIG } from '../src/editor/paper'; import type { FlowResult } from '../src/sim/flow'; let container: HTMLDivElement; let controller: EditorController; let selections: string[][]; let simEvents: { running: boolean; result: FlowResult | null }[]; function makeController(): EditorController { return new EditorController(container, { ...DEFAULT_CONFIG }, { onSelectionChange: (ids) => selections.push(ids), onGraphChange: () => {}, onHistoryChange: () => {}, onSimulationChange: (running, result) => simEvents.push({ running, result }), }); } beforeEach(() => { vi.stubGlobal('requestAnimationFrame', () => 0); vi.stubGlobal('cancelAnimationFrame', () => {}); container = document.createElement('div'); document.body.appendChild(container); selections = []; simEvents = []; controller = makeController(); }); afterEach(() => { controller.dispose(); container.remove(); vi.unstubAllGlobals(); }); function linkCells(sourceId: string, sourcePort: string, targetId: string, targetPort: string): string { const link = (controller as unknown as { makeLink(): { id: string | number; source(v: object): void; target(v: object): void } }).makeLink(); link.source({ id: sourceId, port: sourcePort }); link.target({ id: targetId, port: targetPort }); controller.graph.addCell(link as never); return String(link.id); } describe('EditorController', () => { it('adds nodes snapped to the grid and selects them', () => { const el = controller.addNode('pump', 103, 118); expect(el.position()).toEqual({ x: 100, y: 120 }); // snapped to 20px grid expect(controller.getSelection()).toEqual([String(el.id)]); expect(el.get('nodeTypeId')).toBe('pump'); expect((el.get('props') as Record).head).toBe(32); }); it('exposes ports on created elements', () => { const el = controller.addNode('boiler', 0, 0); const ports = el.getPorts(); expect(ports.map((p) => p.id).sort()).toEqual(['gasIn', 'heatOut', 'heatReturn', 'powerIn']); }); it('deleting a node also removes its edges', () => { const src = controller.addNode('waterSource', 0, 0); const pump = controller.addNode('pump', 200, 0); linkCells(String(src.id), 'out', String(pump.id), 'in'); expect(controller.graph.getLinks()).toHaveLength(1); controller.setSelection([String(src.id)]); controller.deleteSelection(); expect(controller.graph.getElements()).toHaveLength(1); expect(controller.graph.getLinks()).toHaveLength(0); }); it('undo/redo restores cells', async () => { controller.addNode('tank', 40, 40); // Let the debounced history push run. await new Promise((r) => setTimeout(r, 350)); expect(controller.graph.getElements()).toHaveLength(1); controller.undo(); expect(controller.graph.getElements()).toHaveLength(0); controller.redo(); expect(controller.graph.getElements()).toHaveLength(1); expect(controller.graph.getElements()[0].get('nodeTypeId')).toBe('tank'); }); it('copy/paste duplicates nodes and internal edges with new ids', () => { const src = controller.addNode('waterSource', 0, 0); const pump = controller.addNode('pump', 200, 0); const edgeId = linkCells(String(src.id), 'out', String(pump.id), 'in'); controller.setSelection([String(src.id), String(pump.id)]); controller.copySelection(); controller.paste(); expect(controller.graph.getElements()).toHaveLength(4); expect(controller.graph.getLinks()).toHaveLength(2); const newLink = controller.graph.getLinks().find((l) => String(l.id) !== edgeId)!; expect(String(newLink.source().id)).not.toBe(String(src.id)); // Pasted selection replaces the old one. expect(controller.getSelection()).toHaveLength(3); }); it('setCellProp moves an element when x/y presentation props change', () => { const el = controller.addNode('valve', 100, 100); controller.setCellProp(String(el.id), 'x', 260); expect(el.position().x).toBe(260); }); it('setCellProp restyles edges when markers change', () => { const src = controller.addNode('waterSource', 0, 0); const pump = controller.addNode('pump', 200, 0); const edgeId = linkCells(String(src.id), 'out', String(pump.id), 'in'); const link = controller.graph.getCell(edgeId)!; controller.applyLinkStyle(link as never); controller.setCellProp(edgeId, 'targetMarker', 'diamond'); const marker = link.attr('line/targetMarker') as { d?: string }; expect(marker.d).toContain('L 8 -6'); // diamond path controller.setCellProp(edgeId, 'lineStyle', 'dashed'); expect(link.attr('line/strokeDasharray')).toBe('8,5'); }); it('serializes to a diagram document and loads it back', () => { const src = controller.addNode('waterSource', 0, 0); const home = controller.addNode('consumer', 300, 0); linkCells(String(src.id), 'out', String(home.id), 'waterIn'); const json = controller.serialize(); const doc = JSON.parse(json); expect(doc.nodes).toHaveLength(2); expect(doc.edges).toHaveLength(1); expect(doc.edges[0].relation).toBe('water'); controller.clearDocument(); expect(controller.graph.getCells()).toHaveLength(0); controller.loadDocumentJSON(json); expect(controller.graph.getElements()).toHaveLength(2); expect(controller.graph.getLinks()).toHaveLength(1); }); it('runs the mock simulation and writes read-only results to edges', () => { const src = controller.addNode('waterSource', 0, 0); const home = controller.addNode('consumer', 300, 0); const edgeId = linkCells(String(src.id), 'out', String(home.id), 'waterIn'); const result = controller.runSimulation(); expect(result.totalDemand).toBe(5); expect(result.flows.get(edgeId)).toBeCloseTo(5); expect(controller.simulationRunning).toBe(true); const props = controller.getCellProps(edgeId)!; expect(props.simFlow).toBeCloseTo(5); expect(props.simDirection).toBe('along drawing'); expect(simEvents.at(-1)?.running).toBe(true); controller.stopSimulation(); expect(controller.simulationRunning).toBe(false); expect(simEvents.at(-1)?.running).toBe(false); }); it('applyConfig switches router and connector on existing links', () => { const src = controller.addNode('waterSource', 0, 0); const pump = controller.addNode('pump', 200, 0); const edgeId = linkCells(String(src.id), 'out', String(pump.id), 'in'); const link = controller.graph.getCell(edgeId) as unknown as { router(): { name?: string }; connector(): { name?: string }; }; controller.applyConfig({ ...DEFAULT_CONFIG, arcOnIntersections: false, router: 'orthogonal' }); expect(link.router()?.name).toBe('orthogonal'); expect(link.connector()?.name).toBe('rounded'); controller.applyConfig({ ...DEFAULT_CONFIG, arcOnIntersections: true }); expect(link.connector()?.name).toBe('jumpover'); }); it('nudges only element cells in the selection', () => { const a = controller.addNode('valve', 100, 100); const b = controller.addNode('valve', 200, 200); controller.setSelection([String(a.id), String(b.id)]); controller.nudgeSelection(20, 0); expect(a.position().x).toBe(120); expect(b.position().x).toBe(220); }); });