// Unit tests for the pure distribution math extracted out of equalFlowControl. // Decoupling target: the algorithm should be testable without a full MGC. 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { computeEqualFlowDistribution } = require('../../src/control/strategies.js'); // Tiny helpers to make synthetic machines. The pure function still calls // filterOutUnavailableMachines, which reads machine.state.getCurrentState() // and machine.isValidActionForMode() — stub both so the algorithm sees the // machine as available. groupFlow/groupCalcPower are injected. function mkMachine(id, capability = { min: 0.01, max: 0.10, power: (flow) => flow * 1000 }, state = 'operational') { return { id, machine: { __testCapability: capability, state: { getCurrentState: () => state }, isValidActionForMode: () => true, }, }; } const dummyLogger = { warn() {}, error() {}, debug() {}, info() {} }; // Default injected helpers: read from the synthetic machine's __testCapability. const groupFlow = (m) => ({ currentFxyYMin: m.__testCapability.min, currentFxyYMax: m.__testCapability.max, }); const groupCalcPower = (m, flow) => m.__testCapability.power(flow); function basicArgs(overrides = {}) { const m = { a: mkMachine('a').machine, b: mkMachine('b').machine, c: mkMachine('c').machine }; return { machines: m, Qd: 0.06, dynamicTotals: { flow: { min: 0.01, max: 0.30 } }, activeTotals: { flow: { min: 0.03, max: 0.30 } }, priorityList: ['a', 'b', 'c'], isMachineActive: () => true, groupFlow, groupCalcPower, logger: dummyLogger, ...overrides, }; } test('default case: distributes Qd equally across active machines', () => { const r = computeEqualFlowDistribution(basicArgs({ Qd: 0.06 })); // 3 active pumps, demand 0.06 → 0.02 per pump. assert.equal(r.flowDistribution.length, 3); for (const entry of r.flowDistribution) { assert.ok(Math.abs(entry.flow - 0.02) < 1e-12, `entry.flow=${entry.flow}`); } assert.ok(Math.abs(r.totalFlow - 0.06) < 1e-12); // power(flow) = flow * 1000 in the test capability → 0.02 * 1000 = 20 W per pump. assert.ok(Math.abs(r.totalPower - 60) < 1e-9); }); test('Qd above active capacity: starts additional priority machines until covered', () => { // Only one machine "active" to start with; demand exceeds its envelope. // Algorithm should bring more priority machines online via the high-demand branch. const active = new Set(['a']); const args = basicArgs({ Qd: 0.18, // above any single pump's max (0.10) activeTotals: { flow: { min: 0.01, max: 0.10 } }, isMachineActive: (id) => active.has(id), }); const r = computeEqualFlowDistribution(args); // The algorithm reduces Qd iteratively (Qd /= i) until it fits per-pump max. // We don't assert exact splits — only that flowDistribution is non-empty // and totalFlow is finite, since the legacy algorithm is preserved as-is. assert.ok(r.flowDistribution.length >= 1); assert.ok(Number.isFinite(r.totalFlow)); assert.ok(Number.isFinite(r.totalPower)); }); test('Qd below active min flow: routes excess machines to flow=0 and redistributes', () => { // demand below active min — algorithm shuts off lowest-priority machine(s) // and redistributes Qd across the remainder. const args = basicArgs({ Qd: 0.015, dynamicTotals: { flow: { min: 0.01, max: 0.30 } }, activeTotals: { flow: { min: 0.03, max: 0.30 } }, // active min > Qd }); const r = computeEqualFlowDistribution(args); const offCount = r.flowDistribution.filter(e => e.flow === 0).length; assert.ok(offCount >= 1, `expected ≥1 machine to be shut off, got distribution: ${JSON.stringify(r.flowDistribution)}`); const totalServed = r.flowDistribution.filter(e => e.flow > 0).reduce((s, e) => s + e.flow, 0); assert.ok(Math.abs(totalServed - 0.015) < 1e-12, `served flow ${totalServed} should equal Qd 0.015`); }); test('totalCog is always 0 for equalFlow — preserves legacy contract', () => { // The historical algorithm sets totalCog = 0 in this strategy (BEP-Gravitation // is the only optimizer that produces a meaningful per-combination cog). // Pinned here so a future "improvement" doesn't silently introduce a fake value. const r = computeEqualFlowDistribution(basicArgs()); assert.equal(r.totalCog, 0); }); test('isMachineActive is consulted for COUNT but not for SELECTION (legacy quirk)', () => { // Pins pre-existing behaviour of the default branch: it counts how many // machines are active (countActive) to decide how to split Qd, but then // iterates the FIRST countActive machines in priority order — which may // include inactive ones. So 2 of 3 active + Qd within range → first 2 in // priorityList both get flow, regardless of which are actually active. // // This is a latent bug that pre-dates the strategies decoupling refactor. // Documenting it here so a future cleanup is a deliberate change with a // failing-then-passing test, not a silent semantic shift. const active = new Set(['a', 'c']); const r = computeEqualFlowDistribution(basicArgs({ Qd: 0.06, isMachineActive: (id) => active.has(id), })); // Today: machinesInPriorityOrder[0]='a', [1]='b' → 'a' and 'b' both get 0.03. // 'c' (active but third in priority order) gets nothing. const aFlow = r.flowDistribution.find(e => e.machineId === 'a')?.flow; const bFlow = r.flowDistribution.find(e => e.machineId === 'b')?.flow; const cFlow = r.flowDistribution.find(e => e.machineId === 'c')?.flow; assert.equal(aFlow, 0.03, 'a (priority 0, active)'); assert.equal(bFlow, 0.03, 'b (priority 1, INACTIVE — receives flow anyway, bug)'); assert.equal(cFlow, undefined, 'c (priority 2, active — does NOT receive flow, bug)'); }); test('priorityList controls iteration order', () => { // The order in flowDistribution should match priorityList — i.e., machine 'c' // appears before machine 'a' when priorityList = ['c', 'b', 'a']. const r = computeEqualFlowDistribution(basicArgs({ priorityList: ['c', 'b', 'a'], })); assert.equal(r.flowDistribution[0].machineId, 'c'); });