// Unit tests for the level-based control strategy. // Run with: node --test test/basic/control-levelBased.basic.test.js const test = require('node:test'); const assert = require('node:assert/strict'); const levelBased = require('../../src/control/levelBased'); function makeMeasurements(levelMeters) { // Minimal MeasurementContainer stand-in. The strategy only calls // getUnit('level') and a chain ending in getCurrentValue(unit). const chain = { type() { return chain; }, variant() { return chain; }, position() { return chain; }, getCurrentValue() { return Number.isFinite(levelMeters) ? levelMeters : null; }, }; return { getUnit: () => 'm', type: () => chain, }; } function makeGroup(name) { const calls = { setDemand: [], handleInput: [], turnOff: 0 }; return { config: { general: { name } }, setDemand: async (value, unit) => { calls.setDemand.push([value, unit]); }, handleInput: async (...args) => { calls.handleInput.push(args); }, turnOffAllMachines: () => { calls.turnOff += 1; }, _calls: calls, }; } function makeCtx(levelMeters, opts = {}) { const groups = { a: makeGroup('A'), b: makeGroup('B'), c: makeGroup('C'), }; return { measurements: makeMeasurements(levelMeters), config: { control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } }, }, logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} }, machineGroups: groups, machines: {}, levelVariants: ['measured', 'predicted'], }; } test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => { const ctx = makeCtx(0.5); const state = { percControl: 42 }; await levelBased.run(ctx, state); assert.equal(state.percControl, 0); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group'); assert.equal(g._calls.setDemand.length, 0, 'no demand sent in stop zone'); } }); // Pre-engagement: pumps haven't reached startLevel yet, so the rising-edge // hysteresis gate hasn't armed. Explicit turnOff (NOT a setDemand(0)), so // MGC doesn't kick a pump on at flow.min before the gate is ever passed. test('minLevel ≤ level < startLevel (not yet armed) → explicit turnOff', async () => { const ctx = makeCtx(1.5); const state = { percControl: 17 }; await levelBased.run(ctx, state); assert.equal(state.percControl, 0, 'percControl held at 0 before engagement'); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 1, 'engagement gate calls turnOff'); assert.equal(g._calls.setDemand.length, 0, 'no setDemand before engagement'); } }); test('level == startLevel → percControl == 0 dispatched as setDemand (0 % = min flow, NOT off)', async () => { const ctx = makeCtx(2); const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 0); // Critical: at startLevel pumps are engaged at min flow, NOT turned off. // The bug we're fixing: the previous soft-turnOff at pct≤0 stopped pumps // at this boundary even though the hysteresis was armed. for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 0, 'do not turnOff at startLevel'); assert.equal(g._calls.setDemand.length, 1, 'forward 0 % to MGC'); assert.deepEqual(g._calls.setDemand[0], [0, '%']); } }); test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => { const ctx = makeCtx(4); const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 100); }); test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => { const ctx = makeCtx(10); const state = { percControl: null }; await levelBased.run(ctx, state); // interpolate_lin_single_point clamps via limit_input(o_min, o_max). assert.equal(state.percControl, 100); }); test('percControl forwarded to every group via setDemand(pct, "%")', async () => { const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50% const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 50); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.setDemand.length, 1, 'one forward per group'); assert.deepEqual(g._calls.setDemand[0], [50, '%']); assert.equal(g._calls.handleInput.length, 0, 'no raw handleInput — % goes through setDemand'); assert.equal(g._calls.turnOff, 0); } }); test('inflowLevel does NOT shape the curve — ramp foot = startLevel regardless', async () => { // startLevel=2, inflowLevel=3, maxLevel=4. Level=2.5 sits between // startLevel and inflowLevel. Pre-fix this was a 0 % "hold zone"; now // the ramp is anchored at startLevel so level=2.5 → 25 %. const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 } }); ctx.basin = { inflowLevel: 3 }; const state = { percControl: null }; await levelBased.run(ctx, state); assert.ok(Math.abs(state.percControl - 25) < 1e-9, `expected ~25 % (ramp foot at startLevel, NOT inflowLevel); got ${state.percControl}`); }); test('holdLevel > startLevel opts into a hold band [startLevel, holdLevel] at 0 %', async () => { // Same geometry but operator raises holdLevel to 3 so the ramp's 0 % // foot moves up. Level=2.5 should now sit in the hold band: pumps are // engaged but emit 0 % (= MGC's flow.min, NOT turn-off). const ctx = makeCtx(2.5, { levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4 }, }); const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 0, '0 % in the configurable hold band'); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 0, 'engaged — must not turnOff in hold band'); assert.deepEqual(g._calls.setDemand[0], [0, '%']); } }); test('falling-edge keep-alive [stopLevel, startLevel] keeps pumps spinning', async () => { // stopLevel = 0.5, startLevel = 2. Once armed (level ≥ startLevel), the // band [0.5, 2) stays engaged at deadZoneKeepAlivePercent (default 1 %). const ctx = makeCtx(1.5, { levelbased: { minLevel: 0.1, startLevel: 2, stopLevel: 0.5, maxLevel: 4 }, }); // Pre-arm: simulate that level previously crossed startLevel. ctx.host = { _stopHystRunning: true }; const state = { percControl: null }; await levelBased.run(ctx, state); assert.equal(state.percControl, 1, 'keep-alive emits 1 % in the [stop, start) band'); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 0); assert.deepEqual(g._calls.setDemand[0], [1, '%']); } }); test('no valid level → warns and returns without mutating percControl or calling groups', async () => { const ctx = makeCtx(NaN); let warned = false; ctx.logger.warn = () => { warned = true; }; const state = { percControl: 7 }; await levelBased.run(ctx, state); assert.equal(warned, true); assert.equal(state.percControl, 7); for (const g of Object.values(ctx.machineGroups)) { assert.equal(g._calls.turnOff, 0); assert.equal(g._calls.handleInput.length, 0); } }); // Regression: a station engaged above startLevel but with no machine group // registered (e.g. the Port 2 parent↔group registration was dropped by a // partial redeploy) computes a real demand that goes nowhere. The strategy // must surface this once, not fail silently. See the 2026-05-27 "PS not // reacting to level" trace. test('engaged with NO machine group registered → warns once (throttled via host)', async () => { const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged ctx.machineGroups = {}; // registration lost ctx.host = {}; const warns = []; ctx.logger.warn = (m) => warns.push(m); const state = { percControl: 0 }; await levelBased.run(ctx, state); assert.ok(state.percControl > 0, 'demand is computed even though there is no group'); assert.equal(warns.length, 1, 'warns exactly once'); assert.match(warns[0], /no machine group is registered/i); assert.equal(ctx.host._warnedNoMachineGroup, true); // Subsequent ticks while still group-less stay quiet (no log spam). await levelBased.run(ctx, state); assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick'); }); test('warning re-arms after a group reappears then disappears again', async () => { const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); ctx.host = {}; const warns = []; ctx.logger.warn = (m) => warns.push(m); const state = { percControl: 0 }; ctx.machineGroups = {}; await levelBased.run(ctx, state); assert.equal(warns.length, 1); // Group registers again → flag clears, no new warning. ctx.machineGroups = { a: makeGroup('A') }; await levelBased.run(ctx, state); assert.equal(warns.length, 1); assert.equal(ctx.host._warnedNoMachineGroup, false); // Group lost again → warns once more. ctx.machineGroups = {}; await levelBased.run(ctx, state); assert.equal(warns.length, 2, 're-armed after recovery'); });