2026-07-02 23:22:21 +02:00

108 lines
5.5 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('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);
});
});