diagrams-fable-tauri/tests/editor.test.ts
2026-07-02 23:22:21 +02:00

191 lines
7.5 KiB
TypeScript

/**
* 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<string, unknown>).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);
});
});