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:
parent
6585cfb32c
commit
2701b4e198
4549
src-tauri/Cargo.lock
generated
Normal file
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
26
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
src-tauri/capabilities/default.json
Normal file
17
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
5934
src-tauri/gen/schemas/desktop-schema.json
Normal file
5934
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
5934
src-tauri/gen/schemas/linux-schema.json
Normal file
5934
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.png
Normal file
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
8
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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
31
src-tauri/tauri.conf.json
Normal 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
23
tests/catalog.test.ts
Normal 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
117
tests/components.test.tsx
Normal 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
50
tests/edgeStyles.test.ts
Normal 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
190
tests/editor.test.ts
Normal 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
107
tests/flow.test.ts
Normal 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
92
tests/graph.test.ts
Normal 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
57
tests/history.test.ts
Normal 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
54
tests/nodeTypes.test.ts
Normal 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
107
tests/properties.test.ts
Normal 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
124
tests/setup.ts
Normal 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
91
tests/validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user