// Basic tests for the pumpingStation commands registry. // Run with: node --test test/basic/commands.basic.test.js 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { createRegistry } = require('generalFunctions'); const commands = require('../../src/commands'); // --- helpers --------------------------------------------------------------- function makeLogger() { const calls = { warn: [], error: [], info: [], debug: [] }; return { calls, warn: (m) => calls.warn.push(String(m)), error: (m) => calls.error.push(String(m)), info: (m) => calls.info.push(String(m)), debug: (m) => calls.debug.push(String(m)), }; } function makeSource({ mode = 'manual' } = {}) { const calls = { changeMode: [], calibratePredictedVolume: [], calibratePredictedLevel: [], setManualInflow: [], forwardDemandToChildren: [], registerChild: [], }; const source = { mode, logger: makeLogger(), changeMode: (m) => calls.changeMode.push(m), calibratePredictedVolume: (v) => calls.calibratePredictedVolume.push(v), calibratePredictedLevel: (v) => calls.calibratePredictedLevel.push(v), setManualInflow: (v, ts, u) => calls.setManualInflow.push({ v, ts, u }), forwardDemandToChildren: async (d) => { calls.forwardDemandToChildren.push(d); }, childRegistrationUtils: { registerChild: (childSource, position) => calls.registerChild.push({ childSource, position }), }, }; return { source, calls }; } function makeCtx({ child = null, logger = makeLogger() } = {}) { return { logger, RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } }, node: {}, send: () => {}, }; } function makeRegistry(logger) { return createRegistry(commands, { logger }); } // --- tests ----------------------------------------------------------------- test('canonical topics dispatch to their handlers', async () => { const { source, calls } = makeSource(); const reg = makeRegistry(makeLogger()); await reg.dispatch({ topic: 'set.mode', payload: 'levelbased' }, source, makeCtx()); assert.deepEqual(calls.changeMode, ['levelbased']); await reg.dispatch({ topic: 'cmd.calibrate.volume', payload: '12.5' }, source, makeCtx()); assert.deepEqual(calls.calibratePredictedVolume, [12.5]); await reg.dispatch({ topic: 'cmd.calibrate.level', payload: 1.25 }, source, makeCtx()); assert.deepEqual(calls.calibratePredictedLevel, [1.25]); // Registry normalises to the descriptor's `units.default` (m3/h) before // the handler runs. 0.5 m3/s -> 1800 m3/h. await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s' }, source, makeCtx()); assert.equal(calls.setManualInflow.length, 1); assert.equal(calls.setManualInflow[0].v, 1800); assert.equal(calls.setManualInflow[0].u, 'm3/h'); await reg.dispatch({ topic: 'set.demand', payload: 100 }, source, makeCtx()); assert.deepEqual(calls.forwardDemandToChildren, [100]); }); test('child.register canonical resolves child via RED.nodes.getNode', async () => { const { source, calls } = makeSource(); const child = { id: 'child-1', source: { tag: 'child-domain' } }; const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'child.register', payload: 'child-1', positionVsParent: 'upstream' }, source, makeCtx({ child }) ); assert.equal(calls.registerChild.length, 1); assert.equal(calls.registerChild[0].childSource, child.source); assert.equal(calls.registerChild[0].position, 'upstream'); }); test('aliases dispatch to the same handler and log a one-time deprecation', async () => { const { source, calls } = makeSource(); const ctxLogger = makeLogger(); const reg = makeRegistry(ctxLogger); await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger })); await reg.dispatch({ topic: 'changemode', payload: 'manual' }, source, makeCtx({ logger: ctxLogger })); assert.deepEqual(calls.changeMode, ['manual', 'manual']); const deprecWarns = ctxLogger.calls.warn.filter((m) => m.includes("'changemode' is deprecated")); assert.equal(deprecWarns.length, 1, 'deprecation warning should log exactly once'); assert.equal(reg.deprecationStats().changemode, 2); // q_in alias also routes to setInflow. await reg.dispatch({ topic: 'q_in', payload: 0.25, unit: 'm3/s' }, source, makeCtx({ logger: ctxLogger })); assert.equal(calls.setManualInflow.length, 1); }); test('child.register with unknown child id logs warn and does not throw', async () => { const { source, calls } = makeSource(); const ctxLogger = makeLogger(); const reg = makeRegistry(makeLogger()); await assert.doesNotReject(() => reg.dispatch( { topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' }, source, makeCtx({ logger: ctxLogger }) ) ); assert.equal(calls.registerChild.length, 0); assert.ok( ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')), `expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}` ); }); test('set.inflow accepts number payload and { value, unit, timestamp } object payload', async () => { const { source, calls } = makeSource(); const reg = makeRegistry(makeLogger()); // After registry units-normalisation the handler always sees a number in // the descriptor's default unit (m3/h). 0.5 m3/s -> 1800 m3/h. await reg.dispatch({ topic: 'set.inflow', payload: 0.5, unit: 'm3/s', timestamp: 1000 }, source, makeCtx()); assert.deepEqual(calls.setManualInflow[0], { v: 1800, ts: 1000, u: 'm3/h' }); // Object payload `{ value, unit }` is flattened to a number; 2 m3/h stays // 2 m3/h. The timestamp travels on the msg envelope after normalisation // (the per-payload `timestamp` field is not preserved by the flatten). await reg.dispatch( { topic: 'set.inflow', payload: { value: 2, unit: 'm3/h' }, timestamp: 2000 }, source, makeCtx() ); assert.deepEqual(calls.setManualInflow[1], { v: 2, ts: 2000, u: 'm3/h' }); }); test('set.demand in non-manual mode logs debug and does not call forwardDemandToChildren', async () => { const { source, calls } = makeSource({ mode: 'levelbased' }); const ctxLogger = makeLogger(); const reg = makeRegistry(makeLogger()); await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx({ logger: ctxLogger })); assert.equal(calls.forwardDemandToChildren.length, 0); assert.ok( ctxLogger.calls.debug.some((m) => m.includes('set.demand') && m.includes('levelbased')), `expected debug about ignoring demand, got: ${JSON.stringify(ctxLogger.calls.debug)}` ); }); test('set.demand with non-numeric payload logs warn and does not call', async () => { const { source, calls } = makeSource({ mode: 'manual' }); const ctxLogger = makeLogger(); const reg = makeRegistry(makeLogger()); await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger })); assert.equal(calls.forwardDemandToChildren.length, 0); assert.ok( ctxLogger.calls.warn.some((m) => m.includes('set.demand') && m.includes('oops')), `expected warn about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.warn)}` ); });