191 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|