diff --git a/.gitignore b/.gitignore index 8113e12..fea8e42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ src-tauri/target/ *.log .DS_Store coverage/ +e2e/*.png diff --git a/docs/plan/plan.md b/docs/plan/plan.md index ff521db..249735b 100644 --- a/docs/plan/plan.md +++ b/docs/plan/plan.md @@ -55,79 +55,79 @@ tests/ # vitest unit + component tests ### Phase 0 — Research & scaffolding - [x] Web research: select diagramming library (JointJS core) - [x] Write implementation plan (this document) -- [ ] Init git repository, `.gitignore`, conventional commits -- [ ] Install Linux system deps for Tauri (webkit2gtk 4.1, librsvg, appindicator) -- [ ] Scaffold Vite + React + TS app, add `@joint/core` -- [ ] Add Tauri 2 shell (`src-tauri`), dev/build scripts -- [ ] Set up vitest + jsdom + testing-library +- [x] Init git repository, `.gitignore`, conventional commits +- [x] Install Linux system deps for Tauri (webkit2gtk 4.1, librsvg, appindicator) +- [x] Scaffold Vite + React + TS app, add `@joint/core` +- [x] Add Tauri 2 shell (`src-tauri`), dev/build scripts +- [x] Set up vitest + jsdom + testing-library ### Phase 1 — Domain model -- [ ] Property system: `string, int, float, enum, catalogueItem, catalogueItems, color, listOfValues` +- [x] Property system: `string, int, float, enum, catalogueItem, catalogueItems, color, listOfValues` with property groups (Presentation / Physics / General) and validation -- [ ] Catalogues: pipe diameter series, materials, media catalogues -- [ ] Port relations: water, gas, heat, power, signal + compatibility matrix -- [ ] Node type registry: SVG view, port layout per type (pump, valve, tank, source, +- [x] Catalogues: pipe diameter series, materials, media catalogues +- [x] Port relations: water, gas, heat, power, signal + compatibility matrix +- [x] Node type registry: SVG view, port layout per type (pump, valve, tank, source, consumer, junction/tee, heat exchanger, boiler, power supply, sensor…) -- [ ] Graph document model + JSON (de)serialization +- [x] Graph document model + JSON (de)serialization ### Phase 2 — Editor canvas -- [ ] JointJS paper: rectangular grid, pan (space/drag), zoom (wheel), snap-to-grid -- [ ] Node shapes generated from SVG defs with ports -- [ ] Click node → show/highlight ports (magnets) available for linking -- [ ] Edge drawing from port to compatible port only (`validateConnection` by relation) -- [ ] Orthogonal (manhattan) edge routing along grid -- [ ] Config option: `jumpover` connector — arc on line intersections (on/off) -- [ ] Edge styles: line (solid/dashed/dotted), width, color; head & tail markers: +- [x] JointJS paper: rectangular grid, pan (space/drag), zoom (wheel), snap-to-grid +- [x] Node shapes generated from SVG defs with ports +- [x] Click node → show/highlight ports (magnets) available for linking +- [x] Edge drawing from port to compatible port only (`validateConnection` by relation) +- [x] Orthogonal (manhattan) edge routing along grid +- [x] Config option: `jumpover` connector — arc on line intersections (on/off) +- [x] Edge styles: line (solid/dashed/dotted), width, color; head & tail markers: arrow, circle, diamond, bar, none -- [ ] Port relation color schemes (≥3 schemes) applied to ports and edges +- [x] Port relation color schemes (≥3 schemes) applied to ports and edges ### Phase 3 — Editing UX (the "missed features") -- [ ] Node dragging with grid snap -- [ ] Multi-select (shift-click + rubber-band), group move -- [ ] Delete node/edge (Del), with connected-edge cleanup -- [ ] Undo / redo (Ctrl+Z / Ctrl+Shift+Z) command stack -- [ ] Copy / paste / duplicate (Ctrl+C/V/D) with offset -- [ ] Edge vertex editing (drag to add bend points) via link tools -- [ ] Select-all (Ctrl+A), Escape clears selection -- [ ] Zoom to fit, zoom in/out buttons, reset zoom -- [ ] Keyboard nudge of selected nodes with arrow keys +- [x] Node dragging with grid snap +- [x] Multi-select (shift-click + rubber-band), group move +- [x] Delete node/edge (Del), with connected-edge cleanup +- [x] Undo / redo (Ctrl+Z / Ctrl+Shift+Z) command stack +- [x] Copy / paste / duplicate (Ctrl+C/V/D) with offset +- [x] Edge vertex editing (drag to add bend points) via link tools +- [x] Select-all (Ctrl+A), Escape clears selection +- [x] Zoom to fit, zoom in/out buttons, reset zoom +- [x] Keyboard nudge of selected nodes with arrow keys ### Phase 4 — Panels -- [ ] Left palette: node types grouped by category, SVG thumbnails, drag onto canvas -- [ ] Palette search/filter -- [ ] Right property panel for focused node/edge: grouped, collapsible -- [ ] Editors per property type: text, int, float (with units), enum select, +- [x] Left palette: node types grouped by category, SVG thumbnails, drag onto canvas +- [x] Palette search/filter +- [x] Right property panel for focused node/edge: grouped, collapsible +- [x] Editors per property type: text, int, float (with units), enum select, catalogue item picker, multi catalogue items, color picker, list-of-values editor -- [ ] Presentation group edits geometry live (x, y, angle, size; edge style) -- [ ] Physics group (length, diameter, flow rate, flow type…) stored on model -- [ ] Read-only computed properties (e.g. simulated flow) displayed +- [x] Presentation group edits geometry live (x, y, angle, size; edge style) +- [x] Physics group (length, diameter, flow rate, flow type…) stored on model +- [x] Read-only computed properties (e.g. simulated flow) displayed ### Phase 5 — Configuration & persistence -- [ ] Settings dialog: grid size, snap on/off, arc-on-intersection, router choice, +- [x] Settings dialog: grid size, snap on/off, arc-on-intersection, router choice, color scheme, animation on/off -- [ ] Save / Load project as JSON via Tauri dialog + fs (browser fallback in dev) -- [ ] Export diagram as SVG -- [ ] New / clear document with confirm +- [x] Save / Load project as JSON via Tauri dialog + fs (browser fallback in dev) +- [x] Export diagram as SVG +- [x] New / clear document with confirm ### Phase 6 — Mock calculation & animation -- [ ] Flow solver mock: sources with supply, consumers with demand, mass balance, +- [x] Flow solver mock: sources with supply, consumers with demand, mass balance, proportional split at junctions by pipe cross-section -- [ ] Edge direction from solved flow (may oppose drawing direction) -- [ ] Animate flow: moving dashes along edges, speed ∝ flow volume, +- [x] Edge direction from solved flow (may oppose drawing direction) +- [x] Animate flow: moving dashes along edges, speed ∝ flow volume, stroke width ∝ volume, direction arrows -- [ ] Simulation panel/toggle in toolbar; results written to read-only edge props +- [x] Simulation panel/toggle in toolbar; results written to read-only edge props ### Phase 7 — Tests (vitest) -- [ ] Property system: creation, validation, groups, all 8 types -- [ ] Graph model: add/remove nodes & edges, serialization round-trip -- [ ] Connection validation: relation compatibility matrix scenarios -- [ ] Flow solver: chain, tee split, multi-source, disconnected, conservation -- [ ] History: undo/redo scenarios incl. transaction batching -- [ ] Catalogues: lookup, item resolution -- [ ] Component smoke tests: palette renders groups, property panel renders editors -- [ ] Full suite green: `npm test` (94 tests) +- [x] Property system: creation, validation, groups, all 8 types +- [x] Graph model: add/remove nodes & edges, serialization round-trip +- [x] Connection validation: relation compatibility matrix scenarios +- [x] Flow solver: chain, tee split, multi-source, disconnected, conservation +- [x] History: undo/redo scenarios incl. transaction batching +- [x] Catalogues: lookup, item resolution +- [x] Component smoke tests: palette renders groups, property panel renders editors +- [x] Full suite green: `npm test` (77 tests) ### Phase 8 — Finish -- [ ] README with dev/build instructions -- [ ] `cargo tauri build` debug bundle succeeds (`npm run tauri:build:debug`) -- [ ] Check off all plan items +- [x] README with dev/build instructions +- [x] `cargo tauri build` debug bundle succeeds (`npm run tauri:build:debug`) +- [x] Check off all plan items diff --git a/e2e/visual.mjs b/e2e/visual.mjs new file mode 100644 index 0000000..b99679a --- /dev/null +++ b/e2e/visual.mjs @@ -0,0 +1,74 @@ +/** + * Visual verification: loads the demo document through the dev hook, + * captures screenshots of (1) the routed diagram with jumpover arcs, + * (2) simulation running, (3) arcs disabled, (4) dark color scheme. + * Run: node e2e/visual.mjs (starts the dev server if needed) + */ + +import { chromium } from 'playwright'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { readFileSync } from 'node:fs'; + +const URL = 'http://localhost:1420'; +let devServer = null; + +async function serverUp() { + try { + return (await fetch(URL)).ok; + } catch { + return false; + } +} + +async function main() { + if (!(await serverUp())) { + devServer = spawn('npm', ['run', 'dev'], { stdio: 'ignore', detached: true }); + for (let i = 0; i < 60 && !(await serverUp()); i += 1) await sleep(500); + } + const doc = readFileSync(new globalThis.URL('../examples/demo.pipeline.json', import.meta.url), 'utf8'); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); + page.on('pageerror', (e) => console.error('[pageerror]', String(e))); + await page.goto(URL); + await page.waitForSelector('[data-testid="canvas"] svg'); + await page.waitForFunction(() => !!window.__editor); + + await page.evaluate((json) => { + window.__editor.loadDocumentJSON(json); + window.__editor.zoomToFit(); + }, doc); + await sleep(600); + await page.screenshot({ path: 'e2e/visual-routing.png' }); + console.log('✓ e2e/visual-routing.png (manhattan routing + jumpover arcs)'); + + await page.click('[data-testid="simulate-button"]'); + await sleep(900); + await page.screenshot({ path: 'e2e/visual-simulation.png' }); + console.log('✓ e2e/visual-simulation.png (animated flow, labels)'); + await page.click('[data-testid="simulate-button"]'); + + await page.evaluate(() => { + window.__editor.applyConfig({ ...window.__editor.config, arcOnIntersections: false }); + }); + await sleep(500); + await page.screenshot({ path: 'e2e/visual-no-arcs.png' }); + console.log('✓ e2e/visual-no-arcs.png (arcs disabled)'); + + await page.evaluate(() => { + window.__editor.applyConfig({ ...window.__editor.config, arcOnIntersections: true, colorScheme: 'dark' }); + }); + await sleep(500); + await page.screenshot({ path: 'e2e/visual-dark.png' }); + console.log('✓ e2e/visual-dark.png (dark scheme)'); + + await browser.close(); + if (devServer) process.kill(-devServer.pid); +} + +main().catch((e) => { + console.error(e); + if (devServer) process.kill(-devServer.pid); + process.exit(1); +}); diff --git a/examples/demo.pipeline.json b/examples/demo.pipeline.json new file mode 100644 index 0000000..d31f311 --- /dev/null +++ b/examples/demo.pipeline.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "nodes": [ + { "id": "src1", "typeId": "waterSource", "x": 60, "y": 140, "angle": 0, + "props": { "title": "City main", "supply": 30 } }, + { "id": "src2", "typeId": "waterSource", "x": 60, "y": 460, "angle": 0, + "props": { "title": "Backup well", "supply": 10 } }, + { "id": "pump1", "typeId": "pump", "x": 280, "y": 140, "angle": 0, + "props": { "title": "Booster P-101" } }, + { "id": "power1", "typeId": "powerSupply", "x": 280, "y": 320, "angle": 0, + "props": { "title": "MCC-1" } }, + { "id": "tee1", "typeId": "tee", "x": 520, "y": 140, "angle": 0, + "props": { "title": "T-1" } }, + { "id": "cons1", "typeId": "consumer", "x": 800, "y": 60, "angle": 0, + "props": { "title": "Block A", "demand": 8 } }, + { "id": "cons2", "typeId": "consumer", "x": 800, "y": 300, "angle": 0, + "props": { "title": "Block B", "demand": 4 } } + ], + "edges": [ + { "id": "p1", "sourceNodeId": "src1", "sourcePortId": "out", + "targetNodeId": "pump1", "targetPortId": "in", "relation": "water", + "vertices": [], "props": { "title": "suction" } }, + { "id": "p2", "sourceNodeId": "pump1", "sourcePortId": "out", + "targetNodeId": "tee1", "targetPortId": "a", "relation": "water", + "vertices": [], "props": {} }, + { "id": "p3", "sourceNodeId": "tee1", "sourcePortId": "b", + "targetNodeId": "cons1", "targetPortId": "waterIn", "relation": "water", + "vertices": [], "props": {} }, + { "id": "p4", "sourceNodeId": "tee1", "sourcePortId": "c", + "targetNodeId": "cons2", "targetPortId": "waterIn", "relation": "water", + "vertices": [], "props": {} }, + { "id": "p5", "sourceNodeId": "src2", "sourcePortId": "out", + "targetNodeId": "cons2", "targetPortId": "waterIn", "relation": "water", + "vertices": [{ "x": 640, "y": 80 }], "props": { "title": "backup line" } }, + { "id": "w1", "sourceNodeId": "power1", "sourcePortId": "out", + "targetNodeId": "pump1", "targetPortId": "powerIn", "relation": "power", + "vertices": [], "props": { "lineStyle": "dashed", "targetMarker": "circle" } } + ] +} diff --git a/src/sim/flow.ts b/src/sim/flow.ts index b2ee44e..34610a4 100644 --- a/src/sim/flow.ts +++ b/src/sim/flow.ts @@ -40,11 +40,13 @@ interface Adj { other: string; } +const FLUID_RELATIONS = new Set(['water', 'heat', 'gas', 'sewage']); + /** Undirected adjacency restricted to fluid-carrying relations. */ function buildAdjacency(graph: PipelineGraph): Map { const adj = new Map(); for (const edge of graph.edges.values()) { - if (edge.relation === 'signal') continue; // signal lines carry no flow + if (!FLUID_RELATIONS.has(edge.relation)) continue; // power/signal lines carry no fluid const a = adj.get(edge.sourceNodeId) ?? []; a.push({ edge, other: edge.targetNodeId }); adj.set(edge.sourceNodeId, a); diff --git a/src/ui/Canvas.tsx b/src/ui/Canvas.tsx index a2f51d7..11325b4 100644 --- a/src/ui/Canvas.tsx +++ b/src/ui/Canvas.tsx @@ -32,6 +32,10 @@ export function Canvas({ onReady }: CanvasProps) { onSimulationChange: (running, result) => applySimulationResult(running, result), }); controllerRef.current = controller; + if (import.meta.env.DEV) { + // Test hook for e2e scripts (e2e/*.mjs). + (window as unknown as { __editor?: EditorController }).__editor = controller; + } onReady(controller); return () => { controller.dispose(); diff --git a/tests/flow.test.ts b/tests/flow.test.ts index 0619680..64ecc26 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -98,6 +98,18 @@ describe('mock flow solver', () => { void g.nodes.get('sensor'); }); + it('does not route fluid through power lines', () => { + const g = chain(); + // Wire a power supply to the pump: must stay at zero flow and must not + // become a path or a fluid source. + g.addNode({ id: 'mcc', typeId: 'powerSupply', x: 1, y: 1, angle: 0, props: { supply: 100 } }); + g.addEdge({ id: 'pw', sourceNodeId: 'mcc', sourcePortId: 'out', targetNodeId: 'pump', targetPortId: 'powerIn' }); + const r = solveFlow(g); + expect(r.flows.get('pw')).toBe(0); + expect(r.flows.get('e2')).toBeCloseTo(5); + expect(r.totalSupplied).toBeCloseTo(5); + }); + it('handles an empty graph', () => { const r = solveFlow(new PipelineGraph()); expect(r.maxAbsFlow).toBe(0); diff --git a/tsconfig.json b/tsconfig.json index b99561b..e6e5a5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] }, "include": ["src", "tests"] }