feat(tauri): Tauri 2 shell with dialog/fs plugins; test: 76-test vitest suite with jsdom SVG polyfills

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:22:21 +02:00
parent 6585cfb32c
commit 2701b4e198
23 changed files with 17522 additions and 0 deletions

4549
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "pipeline-diagram-editor"
version = "0.1.0"
description = "Pipeline network diagram editor"
authors = ["Ilya Ashikhmin"]
edition = "2021"
[lib]
name = "pipeline_diagram_editor_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = true

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default window capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:default",
{
"identifier": "fs:scope",
"allow": [{ "path": "$HOME/**" }, { "path": "$DOCUMENT/**" }, { "path": "$DOWNLOAD/**" }]
},
"fs:allow-read-text-file",
"fs:allow-write-text-file"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default window capabilities","local":true,"windows":["main"],"permissions":["core:default","dialog:default","fs:default",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DOWNLOAD/**"}]},"fs:allow-read-text-file","fs:allow-write-text-file"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

8
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents an additional console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pipeline_diagram_editor_lib::run()
}

31
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Pipeline Diagram Editor",
"version": "0.1.0",
"identifier": "com.ashikhmin.pipeline-diagram-editor",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Pipeline Diagram Editor",
"width": 1400,
"height": 900,
"minWidth": 900,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"icon": ["icons/icon.png"]
}
}

23
tests/catalog.test.ts Normal file
View File

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { CATALOGUES, getCatalogue, getCatalogueItem, itemLabels } from '../src/model/catalog';
describe('catalogues', () => {
it('ships the built-in catalogues', () => {
expect(CATALOGUES.map((c) => c.id)).toEqual(
expect.arrayContaining(['pipeDiameters', 'materials', 'media', 'insulation']),
);
});
it('looks up catalogues and items by id', () => {
expect(getCatalogue('materials')?.label).toMatch(/material/i);
expect(getCatalogueItem('pipeDiameters', 'dn50')?.attrs?.innerDiameter).toBe(50);
expect(getCatalogueItem('pipeDiameters', 'dn999')).toBeUndefined();
expect(getCatalogueItem('nope', 'dn50')).toBeUndefined();
expect(getCatalogueItem('materials', null)).toBeUndefined();
});
it('resolves labels for multi-item values, skipping unknown ids', () => {
expect(itemLabels('insulation', ['none', 'pur', 'ghost'])).toEqual(['None', 'PUR shell 40mm']);
expect(itemLabels('ghost-cat', ['x'])).toEqual([]);
});
});

117
tests/components.test.tsx Normal file
View File

@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Palette } from '../src/ui/Palette';
import { PropertyPanel } from '../src/ui/PropertyPanel';
import { SettingsDialog } from '../src/ui/SettingsDialog';
import { DEFAULT_CONFIG, type EditorController } from '../src/editor/paper';
import { defaultNodeProps } from '../src/model/nodeTypes';
import { defaultEdgeProps } from '../src/model/graph';
describe('Palette', () => {
it('renders grouped node types with draggable items', () => {
render(<Palette schemeId="classic" onQuickAdd={() => {}} />);
expect(screen.getByText('Sources & supply')).toBeInTheDocument();
expect(screen.getByText('Valves & fittings')).toBeInTheDocument();
const pump = screen.getByTestId('palette-item-pump');
expect(pump).toHaveAttribute('draggable', 'true');
});
it('filters items by search query', () => {
render(<Palette schemeId="classic" onQuickAdd={() => {}} />);
fireEvent.change(screen.getByPlaceholderText('Search nodes…'), { target: { value: 'boiler' } });
expect(screen.getByTestId('palette-item-boiler')).toBeInTheDocument();
expect(screen.queryByTestId('palette-item-pump')).not.toBeInTheDocument();
});
it('quick-adds a node on double click', () => {
const onQuickAdd = vi.fn();
render(<Palette schemeId="classic" onQuickAdd={onQuickAdd} />);
fireEvent.doubleClick(screen.getByTestId('palette-item-valve'));
expect(onQuickAdd).toHaveBeenCalledWith('valve');
});
});
function stubController(kind: 'node' | 'edge', typeId: string | null, props: Record<string, unknown>) {
const setCellProp = vi.fn();
const controller = {
cellKind: () => kind,
cellTypeId: () => typeId,
getCellProps: () => ({ ...props }),
setCellProp,
} as unknown as EditorController;
return { controller, setCellProp };
}
describe('PropertyPanel', () => {
it('shows a hint when nothing is selected', () => {
render(<PropertyPanel controller={null} selection={[]} graphVersion={0} />);
expect(screen.getByText(/Select a node or an edge/)).toBeInTheDocument();
});
it('renders grouped editors for a node', () => {
const { controller } = stubController('node', 'pump', defaultNodeProps('pump'));
render(<PropertyPanel controller={controller} selection={['n1']} graphVersion={0} />);
expect(screen.getByTestId('prop-group-general')).toBeInTheDocument();
expect(screen.getByTestId('prop-group-presentation')).toBeInTheDocument();
expect(screen.getByTestId('prop-group-physics')).toBeInTheDocument();
expect(screen.getByTestId('prop-title')).toHaveValue('Pump');
expect(screen.getByTestId('prop-head')).toHaveValue(32);
// catalogueItem picker renders catalogue options
expect(screen.getByTestId('prop-medium')).toBeInTheDocument();
});
it('commits an edited numeric property through the controller', () => {
const { controller, setCellProp } = stubController('node', 'pump', defaultNodeProps('pump'));
render(<PropertyPanel controller={controller} selection={['n1']} graphVersion={0} />);
const head = screen.getByTestId('prop-head');
fireEvent.change(head, { target: { value: '48' } });
fireEvent.blur(head);
expect(setCellProp).toHaveBeenCalledWith('n1', 'head', 48);
});
it('rejects invalid values and shows the error instead of committing', () => {
const { controller, setCellProp } = stubController('node', 'pump', defaultNodeProps('pump'));
render(<PropertyPanel controller={controller} selection={['n1']} graphVersion={0} />);
const head = screen.getByTestId('prop-head');
fireEvent.change(head, { target: { value: '-5' } });
fireEvent.blur(head);
expect(setCellProp).not.toHaveBeenCalled();
expect(screen.getByText(/must be ≥ 0/)).toBeInTheDocument();
});
it('renders edge properties with marker enums and read-only sim results', () => {
const { controller } = stubController('edge', null, { ...defaultEdgeProps(), simFlow: 12.5 });
render(<PropertyPanel controller={controller} selection={['e1']} graphVersion={0} />);
expect(screen.getByText('Pipe / line')).toBeInTheDocument();
expect(screen.getByTestId('prop-targetMarker')).toHaveValue('arrow');
expect(screen.getByTestId('prop-simFlow')).toHaveTextContent('12.5');
// multi catalogue items editor renders checkboxes
expect(screen.getByTestId('prop-insulation')).toBeInTheDocument();
});
it('shows a count for multi-selection', () => {
const { controller } = stubController('node', 'pump', {});
render(<PropertyPanel controller={controller} selection={['a', 'b']} graphVersion={0} />);
expect(screen.getByText('2 items selected.')).toBeInTheDocument();
});
});
describe('SettingsDialog', () => {
it('exposes the arc-on-intersection toggle and reports changes', () => {
const onChange = vi.fn();
render(
<SettingsDialog open config={{ ...DEFAULT_CONFIG }} onChange={onChange} onClose={() => {}} />,
);
const arc = screen.getByTestId('setting-arc');
expect(arc).toBeChecked();
fireEvent.click(arc);
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ arcOnIntersections: false }));
});
it('renders nothing when closed', () => {
const { container } = render(
<SettingsDialog open={false} config={{ ...DEFAULT_CONFIG }} onChange={() => {}} onClose={() => {}} />,
);
expect(container).toBeEmptyDOMElement();
});
});

50
tests/edgeStyles.test.ts Normal file
View File

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { dashArray, markerDef, readEdgeStyle, buildLineAttrs } from '../src/editor/edgeStyles';
import { defaultEdgeProps } from '../src/model/graph';
import { COLOR_SCHEMES, relationColor } from '../src/editor/colorSchemes';
describe('edge styles', () => {
it('computes dash arrays per line style', () => {
expect(dashArray('solid', 2)).toBeNull();
expect(dashArray('dashed', 2)).toBe('8,5');
expect(dashArray('dotted', 2)).toBe('2,4');
expect(dashArray('dashDot', 2)).toBe('8,4,2,4');
});
it('builds marker defs for all marker kinds', () => {
expect(markerDef('arrow', '#f00')).toMatchObject({ type: 'path', fill: '#f00' });
expect(markerDef('circle', '#f00')).toMatchObject({ type: 'circle', stroke: '#f00' });
expect(markerDef('diamond', '#f00')).toMatchObject({ type: 'path' });
expect(markerDef('bar', '#f00')).toMatchObject({ type: 'path', fill: 'none' });
expect(markerDef('none', '#f00')).toMatchObject({ fill: 'none' });
});
it('reads style props from the edge default bag', () => {
const style = readEdgeStyle(defaultEdgeProps());
expect(style).toEqual({
lineStyle: 'solid',
lineWidth: 2,
lineColor: '',
sourceMarker: 'none',
targetMarker: 'arrow',
});
});
it('uses relation color unless overridden', () => {
const style = readEdgeStyle(defaultEdgeProps());
const attrs = buildLineAttrs(style, '#123456');
expect(attrs.stroke).toBe('#123456');
const attrs2 = buildLineAttrs({ ...style, lineColor: '#abcdef' }, '#123456');
expect(attrs2.stroke).toBe('#abcdef');
});
it('color schemes define colors for every relation', () => {
for (const scheme of COLOR_SCHEMES) {
for (const rel of ['water', 'heat', 'gas', 'sewage', 'power', 'signal'] as const) {
expect(scheme.relations[rel]).toMatch(/^#[0-9a-fA-F]{6}$/);
}
}
expect(relationColor('classic', 'water')).toBe('#2563eb');
expect(relationColor('missing-scheme', 'water')).toBe('#2563eb'); // falls back to first scheme
});
});

190
tests/editor.test.ts Normal file
View File

@ -0,0 +1,190 @@
/**
* 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);
});
});

107
tests/flow.test.ts Normal file
View File

@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { PipelineGraph } from '../src/model/graph';
import { solveFlow } from '../src/sim/flow';
function chain(): PipelineGraph {
// source --e1--> pump --e2--> consumer(demand 5)
const g = new PipelineGraph();
g.addNode({ id: 'src', typeId: 'waterSource', x: 0, y: 0, angle: 0, props: { supply: 50 } });
g.addNode({ id: 'pump', typeId: 'pump', x: 1, y: 0, angle: 0 });
g.addNode({ id: 'c1', typeId: 'consumer', x: 2, y: 0, angle: 0, props: { demand: 5 } });
g.addEdge({ id: 'e1', sourceNodeId: 'src', sourcePortId: 'out', targetNodeId: 'pump', targetPortId: 'in' });
g.addEdge({ id: 'e2', sourceNodeId: 'pump', sourcePortId: 'out', targetNodeId: 'c1', targetPortId: 'waterIn' });
return g;
}
describe('mock flow solver', () => {
it('pushes consumer demand along a simple chain', () => {
const r = solveFlow(chain());
expect(r.flows.get('e1')).toBeCloseTo(5);
expect(r.flows.get('e2')).toBeCloseTo(5);
expect(r.totalDemand).toBe(5);
expect(r.totalSupplied).toBe(5);
expect(r.unreachedConsumers).toEqual([]);
});
it('reports negative flow when an edge is drawn against the flow direction', () => {
const g = new PipelineGraph();
g.addNode({ id: 'src', typeId: 'waterSource', x: 0, y: 0, angle: 0, props: { supply: 50 } });
g.addNode({ id: 'tee', typeId: 'tee', x: 1, y: 0, angle: 0 });
g.addNode({ id: 'c1', typeId: 'consumer', x: 2, y: 0, angle: 0, props: { demand: 8 } });
// Drawn from the tee back to the source: flow should be negative.
g.addEdge({ id: 'back', sourceNodeId: 'tee', sourcePortId: 'a', targetNodeId: 'src', targetPortId: 'out' });
g.addEdge({ id: 'fwd', sourceNodeId: 'tee', sourcePortId: 'b', targetNodeId: 'c1', targetPortId: 'waterIn' });
const r = solveFlow(g);
expect(r.flows.get('back')).toBeCloseTo(-8);
expect(r.flows.get('fwd')).toBeCloseTo(8);
});
it('splits demand across sources proportionally to their supply', () => {
const g = new PipelineGraph();
g.addNode({ id: 's1', typeId: 'waterSource', x: 0, y: 0, angle: 0, props: { supply: 30 } });
g.addNode({ id: 's2', typeId: 'waterSource', x: 0, y: 2, angle: 0, props: { supply: 10 } });
g.addNode({ id: 'tee', typeId: 'tee', x: 1, y: 1, angle: 0 });
g.addNode({ id: 'c1', typeId: 'consumer', x: 2, y: 1, angle: 0, props: { demand: 20 } });
g.addEdge({ id: 'a1', sourceNodeId: 's1', sourcePortId: 'out', targetNodeId: 'tee', targetPortId: 'a' });
g.addEdge({ id: 'a2', sourceNodeId: 's2', sourcePortId: 'out', targetNodeId: 'tee', targetPortId: 'c' });
g.addEdge({ id: 'out', sourceNodeId: 'tee', sourcePortId: 'b', targetNodeId: 'c1', targetPortId: 'waterIn' });
const r = solveFlow(g);
expect(r.flows.get('a1')).toBeCloseTo(15); // 20 * 30/40
expect(r.flows.get('a2')).toBeCloseTo(5); // 20 * 10/40
expect(r.flows.get('out')).toBeCloseTo(20);
});
it('conserves mass at pass-through junctions', () => {
const g = new PipelineGraph();
g.addNode({ id: 'src', typeId: 'waterSource', x: 0, y: 0, angle: 0, props: { supply: 100 } });
g.addNode({ id: 'tee', typeId: 'tee', x: 1, y: 0, angle: 0 });
g.addNode({ id: 'c1', typeId: 'consumer', x: 2, y: 0, angle: 0, props: { demand: 7 } });
g.addNode({ id: 'c2', typeId: 'consumer', x: 2, y: 1, angle: 0, props: { demand: 3 } });
g.addEdge({ id: 'in', sourceNodeId: 'src', sourcePortId: 'out', targetNodeId: 'tee', targetPortId: 'a' });
g.addEdge({ id: 'o1', sourceNodeId: 'tee', sourcePortId: 'b', targetNodeId: 'c1', targetPortId: 'waterIn' });
g.addEdge({ id: 'o2', sourceNodeId: 'tee', sourcePortId: 'c', targetNodeId: 'c2', targetPortId: 'waterIn' });
const r = solveFlow(g);
// Inflow to tee equals sum of outflows.
expect(r.flows.get('in')).toBeCloseTo(r.flows.get('o1')! + r.flows.get('o2')!);
expect(r.flows.get('in')).toBeCloseTo(10);
});
it('scales demand down when supply is insufficient', () => {
const g = chain();
g.nodes.get('src')!.props.supply = 2; // demand is 5
const r = solveFlow(g);
expect(r.flows.get('e2')).toBeCloseTo(2);
expect(r.totalSupplied).toBeCloseTo(2);
expect(r.totalDemand).toBe(5);
});
it('flags consumers with no path to any source', () => {
const g = chain();
g.addNode({ id: 'lonely', typeId: 'consumer', x: 9, y: 9, angle: 0, props: { demand: 4 } });
const r = solveFlow(g);
expect(r.unreachedConsumers).toEqual(['lonely']);
expect(r.totalSupplied).toBeCloseTo(5);
});
it('ignores signal lines when routing flow', () => {
const g = new PipelineGraph();
g.addNode({ id: 'src', typeId: 'waterSource', x: 0, y: 0, angle: 0, props: { supply: 50 } });
g.addNode({ id: 'sensor', typeId: 'sensor', x: 1, y: 0, angle: 0 });
g.addNode({ id: 'meter', typeId: 'meter', x: 1, y: 1, angle: 0 });
g.addNode({ id: 'c1', typeId: 'consumer', x: 2, y: 0, angle: 0, props: { demand: 5 } });
// Water path: src → meter → consumer. Signal line meter → nothing useful.
g.addEdge({ id: 'w1', sourceNodeId: 'src', sourcePortId: 'out', targetNodeId: 'meter', targetPortId: 'in' });
g.addEdge({ id: 'w2', sourceNodeId: 'meter', sourcePortId: 'out', targetNodeId: 'c1', targetPortId: 'waterIn' });
const r = solveFlow(g);
expect(r.flows.get('w1')).toBeCloseTo(5);
expect(r.flows.get('w2')).toBeCloseTo(5);
void g.nodes.get('sensor');
});
it('handles an empty graph', () => {
const r = solveFlow(new PipelineGraph());
expect(r.maxAbsFlow).toBe(0);
expect(r.totalDemand).toBe(0);
expect(r.flows.size).toBe(0);
});
});

92
tests/graph.test.ts Normal file
View File

@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { PipelineGraph, parseDocument, serializeDocument } from '../src/model/graph';
function sampleGraph(): PipelineGraph {
const g = new PipelineGraph();
g.addNode({ id: 'src', typeId: 'waterSource', x: 0, y: 0, angle: 0 });
g.addNode({ id: 'pump', typeId: 'pump', x: 200, y: 0, angle: 0 });
g.addNode({ id: 'home', typeId: 'consumer', x: 400, y: 0, angle: 0 });
g.addEdge({
id: 'e1',
sourceNodeId: 'src',
sourcePortId: 'out',
targetNodeId: 'pump',
targetPortId: 'in',
});
g.addEdge({
id: 'e2',
sourceNodeId: 'pump',
sourcePortId: 'out',
targetNodeId: 'home',
targetPortId: 'waterIn',
});
return g;
}
describe('PipelineGraph', () => {
it('adds nodes with type defaults merged into props', () => {
const g = sampleGraph();
const src = g.nodes.get('src')!;
expect(src.props.supply).toBe(50); // waterSource default
expect(src.props.title).toBe('Water source');
});
it('rejects unknown node types, ports and duplicate ids', () => {
const g = sampleGraph();
expect(() => g.addNode({ id: 'x', typeId: 'nope', x: 0, y: 0, angle: 0 })).toThrow(/Unknown node type/);
expect(() => g.addNode({ id: 'src', typeId: 'pump', x: 0, y: 0, angle: 0 })).toThrow(/Duplicate/);
expect(() =>
g.addEdge({ id: 'e3', sourceNodeId: 'src', sourcePortId: 'nope', targetNodeId: 'pump', targetPortId: 'in' }),
).toThrow(/Unknown source port/);
});
it('rejects edges between incompatible ports', () => {
const g = sampleGraph();
g.addNode({ id: 'ps', typeId: 'powerSupply', x: 0, y: 200, angle: 0 });
expect(() =>
g.addEdge({ id: 'bad', sourceNodeId: 'ps', sourcePortId: 'out', targetNodeId: 'home', targetPortId: 'waterIn' }),
).toThrow(/Incompatible relations/);
// power → consumer power inlet is fine
expect(() =>
g.addEdge({ id: 'ok', sourceNodeId: 'ps', sourcePortId: 'out', targetNodeId: 'home', targetPortId: 'powerIn' }),
).not.toThrow();
});
it('derives edge relation from the source port', () => {
const g = sampleGraph();
expect(g.edges.get('e1')!.relation).toBe('water');
});
it('removing a node cascades to connected edges', () => {
const g = sampleGraph();
g.removeNode('pump');
expect(g.nodes.has('pump')).toBe(false);
expect(g.edges.size).toBe(0);
});
it('edgesOf returns incident edges', () => {
const g = sampleGraph();
expect(g.edgesOf('pump').map((e) => e.id).sort()).toEqual(['e1', 'e2']);
expect(g.edgesOf('home').map((e) => e.id)).toEqual(['e2']);
});
it('survives a serialization round-trip', () => {
const g = sampleGraph();
g.edges.get('e1')!.vertices = [{ x: 100, y: 50 }];
g.nodes.get('pump')!.props.head = 45;
const json = serializeDocument(g.toDocument());
const restored = PipelineGraph.fromDocument(parseDocument(json));
expect(restored.nodes.size).toBe(3);
expect(restored.edges.size).toBe(2);
expect(restored.nodes.get('pump')!.props.head).toBe(45);
expect(restored.edges.get('e1')!.vertices).toEqual([{ x: 100, y: 50 }]);
expect(restored.edges.get('e2')!.relation).toBe('water');
});
it('parseDocument rejects malformed payloads', () => {
expect(() => parseDocument('{"foo": 1}')).toThrow(/Invalid diagram document/);
expect(() => parseDocument('not json')).toThrow();
});
});

57
tests/history.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { History } from '../src/state/history';
describe('History', () => {
it('starts with nothing to undo or redo', () => {
const h = new History('initial');
expect(h.canUndo).toBe(false);
expect(h.canRedo).toBe(false);
expect(h.undo()).toBeUndefined();
expect(h.redo()).toBeUndefined();
});
it('undoes and redoes pushed states in order', () => {
const h = new History('s0');
h.push('s1');
h.push('s2');
expect(h.undo()).toBe('s1');
expect(h.undo()).toBe('s0');
expect(h.canUndo).toBe(false);
expect(h.redo()).toBe('s1');
expect(h.redo()).toBe('s2');
expect(h.canRedo).toBe(false);
});
it('clears the redo stack on a new push after undo', () => {
const h = new History('s0');
h.push('s1');
h.push('s2');
h.undo(); // back to s1
h.push('s3');
expect(h.canRedo).toBe(false);
expect(h.undo()).toBe('s1');
expect(h.redo()).toBe('s3');
});
it('honors the depth limit by dropping oldest entries', () => {
const h = new History(0, 3);
for (let i = 1; i <= 10; i += 1) h.push(i);
let undos = 0;
while (h.canUndo) {
h.undo();
undos += 1;
}
expect(undos).toBe(3);
expect(h.peek()).toBe(7);
});
it('reset replaces state and clears both stacks', () => {
const h = new History('a');
h.push('b');
h.undo();
h.reset('fresh');
expect(h.canUndo).toBe(false);
expect(h.canRedo).toBe(false);
expect(h.peek()).toBe('fresh');
});
});

54
tests/nodeTypes.test.ts Normal file
View File

@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { NODE_TYPES, getNodeType, nodeTypesByCategory, defaultNodeProps } from '../src/model/nodeTypes';
import { PROPERTY_GROUPS } from '../src/model/properties';
describe('node type registry', () => {
it('contains the core pipeline equipment', () => {
const ids = NODE_TYPES.map((t) => t.id);
for (const expected of ['waterSource', 'pump', 'valve', 'tee', 'tank', 'consumer', 'boiler', 'powerSupply']) {
expect(ids).toContain(expected);
}
});
it('every type has SVG markup, ports and grouped properties', () => {
for (const type of NODE_TYPES) {
expect(type.svg.length, type.id).toBeGreaterThan(10);
expect(type.ports.length, type.id).toBeGreaterThan(0);
expect(type.width).toBeGreaterThan(0);
const groups = new Set(type.properties.map((p) => p.group));
expect(groups.has('general'), type.id).toBe(true);
expect(groups.has('presentation'), type.id).toBe(true);
for (const g of groups) {
expect(PROPERTY_GROUPS.some((pg) => pg.id === g), `unknown group ${g} in ${type.id}`).toBe(true);
}
}
});
it('port ids are unique per type and positions are normalized 0..1', () => {
for (const type of NODE_TYPES) {
const ids = type.ports.map((p) => p.id);
expect(new Set(ids).size, type.id).toBe(ids.length);
for (const port of type.ports) {
expect(port.x, `${type.id}.${port.id}`).toBeGreaterThanOrEqual(0);
expect(port.x).toBeLessThanOrEqual(1);
expect(port.y).toBeGreaterThanOrEqual(0);
expect(port.y).toBeLessThanOrEqual(1);
}
}
});
it('groups types by category for the palette', () => {
const grouped = nodeTypesByCategory();
expect(grouped.length).toBeGreaterThanOrEqual(5);
const total = grouped.reduce((s, g) => s + g.types.length, 0);
expect(total).toBe(NODE_TYPES.length);
});
it('builds default props including physics values', () => {
const props = defaultNodeProps('waterSource');
expect(props.supply).toBe(50);
expect(props.title).toBe('Water source');
expect(defaultNodeProps('unknown-type')).toEqual({});
expect(getNodeType('pump')?.simRole).toBe('junction');
});
});

107
tests/properties.test.ts Normal file
View File

@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import {
validateValue,
defaultValues,
groupDefs,
type PropertyDef,
} from '../src/model/properties';
const def = (partial: Partial<PropertyDef> & Pick<PropertyDef, 'type'>): PropertyDef => ({
key: 'p',
label: 'Prop',
group: 'general',
...partial,
});
describe('property validation', () => {
it('accepts any string and coerces null to empty', () => {
expect(validateValue(def({ type: 'string' }), 'hello')).toEqual({ ok: true, value: 'hello' });
expect(validateValue(def({ type: 'string' }), null).value).toBe('');
});
it('validates int: coercion, rounding, bounds', () => {
expect(validateValue(def({ type: 'int' }), '42').value).toBe(42);
expect(validateValue(def({ type: 'int' }), 3.7).value).toBe(4);
expect(validateValue(def({ type: 'int', min: 0 }), -1).ok).toBe(false);
expect(validateValue(def({ type: 'int', max: 10 }), 11).ok).toBe(false);
expect(validateValue(def({ type: 'int' }), 'abc').ok).toBe(false);
});
it('validates float with bounds', () => {
expect(validateValue(def({ type: 'float' }), '3.14').value).toBeCloseTo(3.14);
expect(validateValue(def({ type: 'float', min: 0, max: 100 }), 50).ok).toBe(true);
expect(validateValue(def({ type: 'float', min: 0 }), '-0.5').ok).toBe(false);
expect(validateValue(def({ type: 'float' }), Infinity).ok).toBe(false);
});
it('validates enum against options', () => {
const e = def({ type: 'enum', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] });
expect(validateValue(e, 'a').ok).toBe(true);
expect(validateValue(e, 'c').ok).toBe(false);
});
it('validates color as hex', () => {
expect(validateValue(def({ type: 'color' }), '#ff0000').ok).toBe(true);
expect(validateValue(def({ type: 'color' }), '#f00').ok).toBe(true);
expect(validateValue(def({ type: 'color' }), 'red').ok).toBe(false);
expect(validateValue(def({ type: 'color' }), '#zzz').ok).toBe(false);
});
it('handles catalogueItem: nullable single id', () => {
const c = def({ type: 'catalogueItem', catalogueId: 'materials' });
expect(validateValue(c, 'steel').value).toBe('steel');
expect(validateValue(c, null).value).toBeNull();
expect(validateValue(c, '').value).toBeNull();
});
it('handles catalogueItems: array of ids', () => {
const c = def({ type: 'catalogueItems', catalogueId: 'insulation' });
expect(validateValue(c, ['a', 'b']).value).toEqual(['a', 'b']);
expect(validateValue(c, null).value).toEqual([]);
expect(validateValue(c, 'single').value).toEqual(['single']);
});
it('validates listOfValues with numeric item types', () => {
const l = def({ type: 'listOfValues', itemType: 'float' });
expect(validateValue(l, ['1.5', '2'] as never).value).toEqual([1.5, 2]);
expect(validateValue(l, ['x'] as never).ok).toBe(false);
const ls = def({ type: 'listOfValues', itemType: 'string' });
expect(validateValue(ls, ['a', 1] as never).value).toEqual(['a', '1']);
});
});
describe('defaults and grouping', () => {
it('produces sensible defaults per type', () => {
const defs: PropertyDef[] = [
def({ key: 's', type: 'string' }),
def({ key: 'i', type: 'int', min: 5 }),
def({ key: 'e', type: 'enum', options: [{ value: 'x', label: 'X' }] }),
def({ key: 'c', type: 'color' }),
def({ key: 'ci', type: 'catalogueItem' }),
def({ key: 'cis', type: 'catalogueItems' }),
def({ key: 'l', type: 'listOfValues' }),
def({ key: 'd', type: 'float', default: 9.5 }),
];
const v = defaultValues(defs);
expect(v.s).toBe('');
expect(v.i).toBe(5);
expect(v.e).toBe('x');
expect(v.c).toBe('#000000');
expect(v.ci).toBeNull();
expect(v.cis).toEqual([]);
expect(v.l).toEqual([]);
expect(v.d).toBe(9.5);
});
it('groups defs in canonical order', () => {
const defs: PropertyDef[] = [
def({ key: 'a', group: 'physics' }),
def({ key: 'b', group: 'general' }),
def({ key: 'c', group: 'presentation' }),
def({ key: 'd', group: 'physics' }),
];
const groups = groupDefs(defs.map((d) => ({ ...d, type: 'string' as const })));
expect(groups.map((g) => g.group.id)).toEqual(['general', 'presentation', 'physics']);
expect(groups[2].defs.map((d) => d.key)).toEqual(['a', 'd']);
});
});

124
tests/setup.ts Normal file
View File

@ -0,0 +1,124 @@
import '@testing-library/jest-dom/vitest';
/**
* SVG polyfills for jsdom so JointJS (@joint/core) can run in tests.
* Vectorizer feature-detects `window.SVGAngle` at module load and jsdom
* implements neither it nor the SVG geometry APIs (matrices, points, bbox).
* These must be installed before @joint/core is imported.
*/
class Matrix {
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0;
multiply(m: Matrix): Matrix {
const r = new Matrix();
r.a = this.a * m.a + this.c * m.b;
r.b = this.b * m.a + this.d * m.b;
r.c = this.a * m.c + this.c * m.d;
r.d = this.b * m.c + this.d * m.d;
r.e = this.a * m.e + this.c * m.f + this.e;
r.f = this.b * m.e + this.d * m.f + this.f;
return r;
}
translate(tx: number, ty: number): Matrix {
const m = new Matrix();
m.e = tx;
m.f = ty;
return this.multiply(m);
}
scale(s: number): Matrix {
return this.scaleNonUniform(s, s);
}
scaleNonUniform(sx: number, sy: number): Matrix {
const m = new Matrix();
m.a = sx;
m.d = sy;
return this.multiply(m);
}
rotate(deg: number): Matrix {
const rad = (deg * Math.PI) / 180;
const m = new Matrix();
m.a = Math.cos(rad);
m.b = Math.sin(rad);
m.c = -Math.sin(rad);
m.d = Math.cos(rad);
return this.multiply(m);
}
rotateFromVector(): Matrix { return new Matrix(); }
flipX(): Matrix { return this.scaleNonUniform(-1, 1); }
flipY(): Matrix { return this.scaleNonUniform(1, -1); }
skewX(): Matrix { return new Matrix(); }
skewY(): Matrix { return new Matrix(); }
inverse(): Matrix {
const det = this.a * this.d - this.b * this.c;
if (det === 0) throw new Error('non-invertible matrix');
const r = new Matrix();
r.a = this.d / det;
r.b = -this.b / det;
r.c = -this.c / det;
r.d = this.a / det;
r.e = (this.c * this.f - this.d * this.e) / det;
r.f = (this.b * this.e - this.a * this.f) / det;
return r;
}
}
class Point {
x = 0; y = 0;
matrixTransform(m: Matrix): Point {
const p = new Point();
p.x = m.a * this.x + m.c * this.y + m.e;
p.y = m.b * this.x + m.d * this.y + m.f;
return p;
}
}
const g = globalThis as Record<string, unknown>;
if (!('SVGAngle' in g)) {
g.SVGAngle = class SVGAngle {};
}
const svgElementProto = (window.SVGSVGElement ?? window.SVGElement)?.prototype as unknown as Record<string, unknown>;
if (svgElementProto) {
svgElementProto.createSVGMatrix = () => new Matrix();
svgElementProto.createSVGPoint = () => new Point();
svgElementProto.createSVGTransform = () => ({
matrix: new Matrix(),
setMatrix(m: Matrix) { this.matrix = m; },
setTranslate() {},
});
}
const graphicsProto = window.SVGElement?.prototype as unknown as Record<string, unknown>;
if (graphicsProto) {
graphicsProto.getBBox = function getBBox() {
return { x: 0, y: 0, width: 0, height: 0 };
};
graphicsProto.getScreenCTM = function getScreenCTM() {
return new Matrix();
};
graphicsProto.getCTM = function getCTM() {
return new Matrix();
};
}
if (!window.Element.prototype.checkVisibility) {
window.Element.prototype.checkVisibility = function checkVisibility() {
return this.isConnected;
};
}
const pathProto = (window.SVGPathElement?.prototype ?? graphicsProto) as unknown as Record<string, unknown>;
if (pathProto) {
pathProto.getTotalLength = function getTotalLength() { return 0; };
pathProto.getPointAtLength = function getPointAtLength() {
const p = new Point();
return p;
};
}

91
tests/validation.test.ts Normal file
View File

@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import {
canConnectRelations,
canConnectDirections,
checkConnection,
type PortSpec,
} from '../src/model/validation';
const port = (partial: Partial<PortSpec>): PortSpec => ({
id: 'p',
relation: 'water',
direction: 'any',
x: 0,
y: 0,
...partial,
});
describe('relation compatibility', () => {
it('allows same-relation connections', () => {
expect(canConnectRelations('water', 'water')).toBe(true);
expect(canConnectRelations('gas', 'gas')).toBe(true);
expect(canConnectRelations('power', 'power')).toBe(true);
});
it('allows declared cross-relation pairs symmetrically', () => {
expect(canConnectRelations('water', 'heat')).toBe(true);
expect(canConnectRelations('heat', 'water')).toBe(true);
expect(canConnectRelations('sewage', 'water')).toBe(true);
});
it('rejects incompatible relations', () => {
expect(canConnectRelations('water', 'power')).toBe(false);
expect(canConnectRelations('gas', 'water')).toBe(false);
expect(canConnectRelations('signal', 'power')).toBe(false);
});
});
describe('direction compatibility', () => {
it('rejects out→out and in→in', () => {
expect(canConnectDirections('out', 'out')).toBe(false);
expect(canConnectDirections('in', 'in')).toBe(false);
});
it('allows out→in, in→out and any combinations', () => {
expect(canConnectDirections('out', 'in')).toBe(true);
expect(canConnectDirections('in', 'out')).toBe(true);
expect(canConnectDirections('any', 'out')).toBe(true);
expect(canConnectDirections('any', 'any')).toBe(true);
});
});
describe('checkConnection scenarios', () => {
it('pump discharge (water out) → consumer water inlet: allowed', () => {
const r = checkConnection(
port({ id: 'out', relation: 'water', direction: 'out' }),
port({ id: 'waterIn', relation: 'water', direction: 'in' }),
);
expect(r.ok).toBe(true);
});
it('power supply → water port: rejected with reason', () => {
const r = checkConnection(
port({ id: 'out', relation: 'power', direction: 'out' }),
port({ id: 'in', relation: 'water', direction: 'in' }),
);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/Incompatible relations/);
});
it('two source outlets: rejected by direction', () => {
const r = checkConnection(
port({ id: 'a', relation: 'water', direction: 'out' }),
port({ id: 'b', relation: 'water', direction: 'out' }),
);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/Incompatible directions/);
});
it('same port to itself on one node: rejected', () => {
const p = port({ id: 'x' });
expect(checkConnection(p, p, { sameNode: true }).ok).toBe(false);
});
it('different ports of the same node may connect (self-loop)', () => {
const r = checkConnection(
port({ id: 'a', relation: 'heat', direction: 'out' }),
port({ id: 'b', relation: 'heat', direction: 'in' }),
{ sameNode: true },
);
expect(r.ok).toBe(true);
});
});