// Basic unit tests for PumpingStation (domain logic, no Node-RED). // Run with: node --test test/basic/specificClass.test.js const test = require('node:test'); const assert = require('node:assert/strict'); const PumpingStation = require('../../src/specificClass'); // Standard config shape. Override any section by passing { section: {...} }. function makeConfig(overrides = {}) { const base = { general: { name: 'TestStation', id: 'ps-test', unit: 'm3/h', logging: { enabled: false, logLevel: 'error' }, flowThreshold: 1e-4, }, functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment', }, basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5, }, hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet', }, control: { mode: 'levelbased', allowedModes: new Set(['levelbased', 'manual']), levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 }, }, safety: { enableDryRunProtection: false, enableOverfillProtection: false, dryRunThresholdPercent: 2, overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0, }, }; for (const k of Object.keys(overrides)) { base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k]) ? { ...base[k], ...overrides[k] } : overrides[k]; } return base; } test('Basin geometry — derived values', async (t) => { const ps = new PumpingStation(makeConfig()); await t.test('surfaceArea = volume / height', () => { assert.equal(ps.basin.surfaceArea, 10); // 50 / 5 }); await t.test('maxVol = height × area ≡ volEmptyBasin', () => { assert.equal(ps.basin.maxVol, 50); assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin); }); await t.test('maxVolAtOverflow = overflowLevel × area', () => { assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10 }); await t.test('minVolAtInflow = inflowLevel × area', () => { assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10 }); await t.test('minVolAtOutflow = outflowLevel × area', () => { assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10 }); await t.test('minVol honours minHeightBasedOn=outlet', () => { assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9); }); await t.test('minVol honours minHeightBasedOn=inlet', () => { const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } })); assert.equal(ps2.basin.minVol, 30); }); }); test('Level ↔ volume roundtrip', async (t) => { const ps = new PumpingStation(makeConfig()); await t.test('_calcVolumeFromLevel multiplies by area', () => { assert.equal(ps._calcVolumeFromLevel(2), 20); }); await t.test('_calcVolumeFromLevel clamps negatives to 0', () => { assert.equal(ps._calcVolumeFromLevel(-3), 0); }); await t.test('_calcLevelFromVolume divides by area', () => { assert.equal(ps._calcLevelFromVolume(20), 2); }); await t.test('_calcLevelFromVolume clamps negatives to 0', () => { assert.equal(ps._calcLevelFromVolume(-10), 0); }); await t.test('roundtrip preserves level', () => { const v = ps._calcVolumeFromLevel(2.7); assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10); }); }); test('Threshold guardrails — _validateThresholdOrdering', async (t) => { await t.test('valid config returns no issues', () => { const ps = new PumpingStation(makeConfig()); assert.equal(ps.thresholdIssues.length, 0); }); await t.test('minLevel > startLevel flagged', () => { const ps = new PumpingStation(makeConfig({ control: { mode: 'levelbased', allowedModes: new Set(['levelbased']), levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 }, }, })); assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel')); }); await t.test('startLevel == maxLevel flagged (must be strict <)', () => { const ps = new PumpingStation(makeConfig({ control: { mode: 'levelbased', allowedModes: new Set(['levelbased']), levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 }, }, })); assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel')); }); await t.test('outflowLevel >= inflowLevel flagged', () => { const ps = new PumpingStation(makeConfig({ basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 }, })); assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel')); }); await t.test('overflowLevel > basinHeight flagged', () => { const ps = new PumpingStation(makeConfig({ basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 }, })); assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel')); }); await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => { // With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3. // dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails. const ps = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' }, safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 }, })); assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel')); }); }); test('Direction derivation — _deriveDirection', async (t) => { const ps = new PumpingStation(makeConfig()); await t.test('positive flow above dead-band → filling', () => { assert.equal(ps._deriveDirection(0.01), 'filling'); }); await t.test('negative flow below dead-band → draining', () => { assert.equal(ps._deriveDirection(-0.01), 'draining'); }); await t.test('flow inside dead-band → steady', () => { assert.equal(ps._deriveDirection(0), 'steady'); assert.equal(ps._deriveDirection(1e-5), 'steady'); assert.equal(ps._deriveDirection(-1e-5), 'steady'); }); }); test('Mode change — changeMode', async (t) => { const ps = new PumpingStation(makeConfig()); await t.test('valid mode swap updates this.mode', () => { ps.changeMode('manual'); assert.equal(ps.mode, 'manual'); }); await t.test('rejected mode leaves this.mode unchanged', () => { ps.changeMode('manual'); ps.changeMode('notamode'); assert.equal(ps.mode, 'manual'); }); }); test('Calibration — predicted volume and level', async (t) => { const ps = new PumpingStation(makeConfig()); await t.test('calibratePredictedVolume rewrites volume series', () => { ps.calibratePredictedVolume(25); const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3'); assert.ok(Math.abs(vol - 25) < 1e-9); }); await t.test('calibratePredictedVolume also writes level (= vol / area)', () => { ps.calibratePredictedVolume(30); const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m'); assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10 }); await t.test('calibratePredictedLevel writes level + volume = level × area', () => { ps.calibratePredictedLevel(2.5); const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m'); const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3'); assert.ok(Math.abs(lvl - 2.5) < 1e-9); assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10 }); }); test('Levelbased control zones — _controlLevelBased', async (t) => { await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => { const ps = new PumpingStation(makeConfig()); let turnOffCalls = 0; ps.machineGroups['mgc1'] = { config: { general: { name: 'mgc1' } }, turnOffAllMachines: () => { turnOffCalls++; }, handleInput: async () => {}, }; ps.calibratePredictedLevel(0.5); // below minLevel=1 await ps._controlLevelBased(); assert.equal(ps.percControl, 0); assert.equal(turnOffCalls, 1); }); await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => { const ps = new PumpingStation(makeConfig()); ps.percControl = 42; // simulated previous demand ps.machineGroups['mgc1'] = { config: { general: { name: 'mgc1' } }, turnOffAllMachines: () => {}, handleInput: async () => { throw new Error('should not be called in dead zone'); }, }; ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2 await ps._controlLevelBased(); assert.equal(ps.percControl, 42); // unchanged }); await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => { const ps = new PumpingStation(makeConfig()); const demands = []; ps.machineGroups['mgc1'] = { config: { general: { name: 'mgc1' } }, turnOffAllMachines: () => {}, handleInput: async (_src, d) => { demands.push(d); }, }; ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4 await ps._controlLevelBased(); // lerp(3, [2,4], [0,100]) = 50 assert.ok(Math.abs(ps.percControl - 50) < 1e-9); assert.equal(demands.length, 1); assert.ok(Math.abs(demands[0] - 50) < 1e-9); }); await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => { const ps = new PumpingStation(makeConfig()); ps.machineGroups['mgc1'] = { config: { general: { name: 'mgc1' } }, turnOffAllMachines: () => {}, handleInput: async () => {}, }; ps.calibratePredictedLevel(4.5); // above maxLevel=4 await ps._controlLevelBased(); assert.ok(ps.percControl >= 100); }); }); test('getOutput — flattens basin + state + demand', async (t) => { const ps = new PumpingStation(makeConfig()); ps.percControl = 37; await t.test('includes basin geometry fields', () => { const out = ps.getOutput(); assert.equal(out.volEmptyBasin, 50); assert.equal(out.maxVolAtOverflow, 45); assert.equal(out.minVolAtInflow, 30); assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9); }); await t.test('includes state fields (direction, flowSource, timeleft)', () => { const out = ps.getOutput(); assert.ok('direction' in out); assert.ok('flowSource' in out); assert.ok('timeleft' in out); }); await t.test('includes percControl', () => { assert.equal(ps.getOutput().percControl, 37); }); }); test('Manual inflow — setManualInflow stores predicted inflow', async (t) => { const ps = new PumpingStation(makeConfig()); ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s'); assert.ok(Math.abs(v - 0.05) < 1e-9); });