const test = require('node:test'); const assert = require('node:assert/strict'); const NodeClass = require('../../src/nodeClass'); const { makeNodeStub, makeREDStub } = require('../helpers/factories'); // Drive routing through the public BaseNodeAdapter surface only. We // construct a full nodeClass instance and invoke the input handler // installed by the base on `node.on('input', ...)`. Side-effects are // observed via `node._sent`, the registered child registry on the // source, and instrumented domain methods. function makeUiConfig(overrides = {}) { // Post-AssetResolver: editor saves only model + unit + uuid/tagCode. // supplier/category/assetType are derived at runtime. return { unit: 'm3/h', enableLog: false, logLevel: 'error', model: 'hidrostal-H05K-S03R', curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h', curvePowerUnit: 'kW', curveControlUnit: '%', positionVsParent: 'atEquipment', speed: 1, movementMode: 'staticspeed', startup: 0, warmup: 0, shutdown: 0, cooldown: 0, ...overrides, }; } // Adapters built in these tests park a periodic status-poll timer. We // drive the BaseNodeAdapter close handler after each test so the timer // stops and node:test exits cleanly — this is the public teardown path // Node-RED itself uses on flow shutdown. const _adapters = []; function buildAdapter({ ui = makeUiConfig(), redNodes = {} } = {}) { const node = makeNodeStub(); const RED = makeREDStub(redNodes); const inst = new NodeClass(ui, RED, node, 'rotatingMachine'); _adapters.push(node); return { inst, node, RED }; } test.afterEach(() => { while (_adapters.length) { const node = _adapters.pop(); try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ } } }); // Capture every call to source.handleInput so the test can assert which // canonical action the dispatch produced. function instrumentHandleInput(source) { const calls = []; const orig = source.handleInput.bind(source); source.handleInput = async (...args) => { calls.push(args); return orig(...args); }; return calls; } async function fireInput(node, msg) { await node._handlers.input(msg, (out) => node._sent.push(out), () => {}); } test('set.mode (and legacy setMode alias) flips the source mode', async () => { const { inst, node } = buildAdapter(); const startingMode = inst.source.currentMode; await fireInput(node, { topic: 'set.mode', payload: 'virtualControl' }); assert.equal(inst.source.currentMode, 'virtualControl'); assert.notEqual(inst.source.currentMode, startingMode); // Legacy alias still works (emits a one-time deprecation warning). await fireInput(node, { topic: 'setMode', payload: 'auto' }); assert.equal(inst.source.currentMode, 'auto'); }); test('cmd.startup / execSequence / flowMovement / emergencystop all reach handleInput with the right action', async () => { const { inst, node } = buildAdapter(); const calls = instrumentHandleInput(inst.source); await fireInput(node, { topic: 'cmd.startup', payload: { source: 'GUI' } }); await fireInput(node, { topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }); await fireInput(node, { topic: 'set.flow-setpoint', payload: { source: 'GUI', setpoint: 123 } }); await fireInput(node, { topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 99 } }); await fireInput(node, { topic: 'cmd.estop', payload: { source: 'GUI' } }); await fireInput(node, { topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }); // Each call is [source, action, parameter?]. estop calls handleInput // with only two args; the rest pass a third. assert.equal(calls.length, 6); assert.deepEqual(calls[0], ['GUI', 'execSequence', 'startup']); assert.deepEqual(calls[1], ['GUI', 'execSequence', 'startup']); assert.deepEqual(calls[2], ['GUI', 'flowMovement', 123]); assert.deepEqual(calls[3], ['GUI', 'flowMovement', 99]); assert.deepEqual(calls[4], ['GUI', 'emergencystop']); assert.deepEqual(calls[5], ['GUI', 'emergencystop']); }); test('child.register / registerChild resolves the sibling node and registers it', async () => { // The handler reads child via RED.nodes.getNode(payload).source; we // pre-seed RED's lookup with a domain stub that owns a .source. const fakeChildSource = { config: { functionality: { positionVsParent: 'downstream' } } }; const { inst, node } = buildAdapter({ redNodes: { 'child-1': { source: fakeChildSource } }, }); const regCalls = []; inst.source.childRegistrationUtils.registerChild = (childSource, pos) => { regCalls.push([childSource, pos]); }; await fireInput(node, { topic: 'child.register', payload: 'child-1', positionVsParent: 'downstream' }); assert.equal(regCalls.length, 1); assert.equal(regCalls[0][0], fakeChildSource); assert.equal(regCalls[0][1], 'downstream'); // Missing child is a no-op (no throw, just a warn). await fireInput(node, { topic: 'child.register', payload: 'no-such-id', positionVsParent: 'upstream' }); assert.equal(regCalls.length, 1); }); test('data.simulate-measurement validates payload and rejects invalid combinations', async () => { const { inst, node } = buildAdapter(); const warns = []; inst.source.logger.warn = (m) => warns.push(String(m)); const dispatched = []; inst.source.updateSimulatedMeasurement = (type, pos, val) => dispatched.push(['sim', type, pos, val]); inst.source.updateMeasuredPower = (val, pos) => dispatched.push(['power', val, pos]); // 1. non-numeric value await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'pressure', position: 'upstream', value: 'NaN-string', unit: 'mbar' } }); // 2. missing unit await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'flow', position: 'upstream', value: 12 } }); // 3. unsupported type await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }); assert.equal(dispatched.length, 0); const payloadWarns = warns.filter((w) => !/deprecated/i.test(w)); assert.equal(payloadWarns.length, 3); assert.match(payloadWarns[0], /finite number/i); // simulator validates type before unit, so "unknown" trips first. assert.ok(payloadWarns.slice(1).some((w) => /unsupported simulatemeasurement type/i.test(w))); assert.ok(payloadWarns.slice(1).some((w) => /payload\.unit is required/i.test(w))); }); test('data.simulate-measurement routes valid power to updateMeasuredPower', async () => { const { inst, node } = buildAdapter(); const dispatched = []; inst.source.updateMeasuredPower = (val, pos) => dispatched.push([val, pos]); await fireInput(node, { topic: 'data.simulate-measurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' }, }); assert.equal(dispatched.length, 1); assert.equal(dispatched[0][0], 7.5); assert.equal(dispatched[0][1], 'atEquipment'); }); test('query.curves / query.cog send a reply on the process output port', async () => { const { inst, node } = buildAdapter(); inst.source.showWorkingCurves = () => ({ curve: [1, 2, 3] }); inst.source.showCoG = () => ({ cog: 0.77 }); // Drop earlier non-reply emissions so the assertion has a clean slice. node._sent.length = 0; await fireInput(node, { topic: 'query.curves', payload: { request: true } }); await fireInput(node, { topic: 'query.cog', payload: { request: true } }); assert.equal(node._sent.length, 2); assert.ok(Array.isArray(node._sent[0])); assert.equal(node._sent[0].length, 3); assert.equal(node._sent[0][0].topic, 'showWorkingCurves'); assert.equal(node._sent[0][1], null); assert.equal(node._sent[0][2], null); assert.deepEqual(node._sent[0][0].payload, { curve: [1, 2, 3] }); assert.equal(node._sent[1][0].topic, 'showCoG'); assert.deepEqual(node._sent[1][0].payload, { cog: 0.77 }); }); test('status badge: source.getStatusBadge() warns when pressure is not initialized', () => { const { inst } = buildAdapter(); // Drive into an operational state that requires pressure initialisation; // then assert the badge reflects the warning. inst.source.state.stateManager.currentState = 'operational'; // Force pressureInit to report uninitialised, regardless of construction. inst.source.pressureInit.getStatus = () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false, }); const status = inst.source.getStatusBadge(); assert.equal(status.fill, 'yellow'); assert.equal(status.shape, 'ring'); assert.match(status.text, /pressure not initialized/i); }); test('unknown topic dispatched to the input handler does not throw', async () => { const { node } = buildAdapter(); await assert.doesNotReject(async () => { await fireInput(node, { topic: 'totally.unknown.topic', payload: 42 }); }); });