Ilya Ashikhmin dd547f7ea1 fix(sim): exclude power lines from fluid routing
Power and signal edges no longer act as fluid paths in the mock solver
(a water demand was previously routed through the pump's power cable).

Also: bundled demo diagram, visual e2e script with dev-mode editor hook,
plan checklist completed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:34:44 +02:00

120 lines
6.1 KiB
TypeScript

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('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);
expect(r.totalDemand).toBe(0);
expect(r.flows.size).toBe(0);
});
});