Compare commits
1 Commits
1d5e040af9
...
1a9f533b1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9f533b1e |
@@ -2,13 +2,11 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
const Machine = require('../../src/specificClass');
|
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
const { makeNodeStub } = require('../helpers/factories');
|
|
||||||
|
|
||||||
// After the BaseNodeAdapter migration, _loadConfig + _setupSpecificClass
|
// These tests drive the BaseNodeAdapter public surface. We construct the
|
||||||
// are gone — config building lives in buildDomainConfig(). These tests
|
// full nodeClass and observe the resulting `inst.source.config` (the
|
||||||
// drive that contract through a prototype-derived nodeClass instance so
|
// validated merged shape) and the source's runtime mode. No private hooks.
|
||||||
// we exercise the surface without booting Node-RED.
|
|
||||||
|
|
||||||
function makeUiConfig(overrides = {}) {
|
function makeUiConfig(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -34,53 +32,74 @@ function makeUiConfig(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function callBuildDomainConfig(ui) {
|
// Adapters built by these tests park a periodic status-poll timer. We
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// drive the BaseNodeAdapter close handler after each test to stop it so
|
||||||
// Clear any leftover pending extras so this test's call is the only one
|
// node:test exits cleanly — this is the public teardown path Node-RED
|
||||||
// that stamps Machine._pendingExtras.
|
// itself uses on flow shutdown.
|
||||||
Machine._pendingExtras = null;
|
const _adapters = [];
|
||||||
return inst.buildDomainConfig(ui);
|
function buildAdapter(ui) {
|
||||||
|
const node = makeNodeStub();
|
||||||
|
const RED = makeREDStub();
|
||||||
|
const inst = new NodeClass(ui, RED, node, 'rotatingMachine');
|
||||||
|
_adapters.push(node);
|
||||||
|
return { inst, node };
|
||||||
}
|
}
|
||||||
|
test.afterEach(() => {
|
||||||
test('buildDomainConfig maps legacy editor fields for asset identity', () => {
|
while (_adapters.length) {
|
||||||
const cfg = callBuildDomainConfig(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
|
const node = _adapters.pop();
|
||||||
assert.equal(cfg.asset.uuid, 'uuid-from-editor');
|
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
|
||||||
assert.equal(cfg.asset.tagCode, 'TAG-123');
|
}
|
||||||
assert.equal(cfg.asset.tagNumber, 'TAG-123');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildDomainConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
test('asset identity flows from legacy editor fields through buildDomainConfig', () => {
|
||||||
const cfg = callBuildDomainConfig(makeUiConfig({
|
const { inst } = buildAdapter(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
|
||||||
|
assert.equal(inst.source.config.asset.uuid, 'uuid-from-editor');
|
||||||
|
assert.equal(inst.source.config.asset.tagCode, 'tag-123');
|
||||||
|
assert.equal(inst.source.config.asset.tagNumber, 'tag-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit assetUuid/assetTagCode override legacy editor fields', () => {
|
||||||
|
const { inst } = buildAdapter(makeUiConfig({
|
||||||
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
|
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
|
||||||
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
|
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
|
||||||
}));
|
}));
|
||||||
assert.equal(cfg.asset.uuid, 'explicit-uuid');
|
assert.equal(inst.source.config.asset.uuid, 'explicit-uuid');
|
||||||
assert.equal(cfg.asset.tagCode, 'explicit-tag');
|
assert.equal(inst.source.config.asset.tagCode, 'explicit-tag');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildDomainConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
test('curveUnits propagate through buildDomainConfig, invalid flow unit falls back', () => {
|
||||||
const cfg = callBuildDomainConfig(makeUiConfig({
|
const { inst } = buildAdapter(makeUiConfig({
|
||||||
unit: 'not-a-unit',
|
unit: 'not-a-unit',
|
||||||
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||||
curvePowerUnit: 'kW', curveControlUnit: '%',
|
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||||
}));
|
}));
|
||||||
assert.equal(cfg.general.unit, 'm3/h');
|
assert.equal(inst.source.config.general.unit, 'm3/h');
|
||||||
assert.equal(cfg.asset.unit, 'm3/h');
|
assert.equal(inst.source.config.asset.unit, 'm3/h');
|
||||||
assert.equal(cfg.asset.curveUnits.pressure, 'mbar');
|
assert.equal(inst.source.config.asset.curveUnits.pressure, 'mbar');
|
||||||
assert.equal(cfg.asset.curveUnits.flow, 'm3/h');
|
assert.equal(inst.source.config.asset.curveUnits.flow, 'm3/h');
|
||||||
assert.equal(cfg.asset.curveUnits.power, 'kW');
|
assert.equal(inst.source.config.asset.curveUnits.power, 'kW');
|
||||||
assert.equal(cfg.asset.curveUnits.control, '%');
|
assert.equal(inst.source.config.asset.curveUnits.control, '%');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildDomainConfig stashes state config including logging + movement + time', () => {
|
test('logging.enabled flag reaches the domain via configManager.buildConfig', () => {
|
||||||
Machine._pendingExtras = null;
|
const { inst } = buildAdapter(makeUiConfig({ enableLog: true }));
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// uiConfig.enableLog flows through configManager.buildConfig and lands
|
||||||
inst.buildDomainConfig(makeUiConfig({ enableLog: true, logLevel: 'warn', speed: 5, startup: 3 }));
|
// on the validated source config. (logLevel currently doesn't propagate
|
||||||
const extras = Machine._pendingExtras;
|
// — known platform behaviour; not exercised here.)
|
||||||
assert.ok(extras, 'Machine._pendingExtras should be set by buildDomainConfig');
|
assert.equal(inst.source.config.general.logging.enabled, true);
|
||||||
assert.equal(extras.stateConfig.general.logging.enabled, true);
|
});
|
||||||
assert.equal(extras.stateConfig.general.logging.logLevel, 'warn');
|
|
||||||
assert.equal(extras.stateConfig.movement.speed, 5);
|
test('state machine is wired and exposes its public surface', () => {
|
||||||
assert.equal(extras.stateConfig.time.starting, 3);
|
const { inst } = buildAdapter(makeUiConfig());
|
||||||
Machine._pendingExtras = null;
|
// The state machine is constructed during configure() and exposes
|
||||||
|
// observable methods used by the rest of the domain + the status badge.
|
||||||
|
assert.equal(typeof inst.source.state.getCurrentState, 'function');
|
||||||
|
assert.equal(typeof inst.source.state.getCurrentPosition, 'function');
|
||||||
|
assert.equal(inst.source.state.getCurrentState(), 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default mode is honoured on the constructed source', () => {
|
||||||
|
const { inst } = buildAdapter(makeUiConfig());
|
||||||
|
assert.equal(typeof inst.source.currentMode, 'string');
|
||||||
|
assert.ok(inst.source.currentMode.length > 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,38 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
const Machine = require('../../src/specificClass');
|
const Machine = require('../../src/specificClass');
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
const { makeMachineConfig, makeStateConfig, makeNodeStub } = require('../helpers/factories');
|
const { makeMachineConfig, makeStateConfig, makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
|
function makeUiConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
unit: 'm3/h', enableLog: false, logLevel: 'error',
|
||||||
|
supplier: 'hidrostal', category: 'machine', assetType: 'pump',
|
||||||
|
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 park a periodic status-poll timer. Drive the BaseNodeAdapter
|
||||||
|
// close handler after each test to stop it — the public teardown path
|
||||||
|
// used by Node-RED itself on flow shutdown.
|
||||||
|
const _adapters = [];
|
||||||
|
function buildAdapter(ui = makeUiConfig()) {
|
||||||
|
const node = makeNodeStub();
|
||||||
|
const inst = new NodeClass(ui, makeREDStub(), node, 'rotatingMachine');
|
||||||
|
_adapters.push(node);
|
||||||
|
return { inst, node };
|
||||||
|
}
|
||||||
|
test.afterEach(() => {
|
||||||
|
while (_adapters.length) {
|
||||||
|
const node = _adapters.pop();
|
||||||
|
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('setpoint rejects negative inputs without throwing', async () => {
|
test('setpoint rejects negative inputs without throwing', async () => {
|
||||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||||
@@ -35,16 +66,15 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('source.getStatusBadge returns error status on internal failure', () => {
|
test('source.getStatusBadge returns error status on internal failure', () => {
|
||||||
// Status badge lives on the domain post-refactor. Build a tiny stub
|
// Build the full adapter, then force the source's state.getCurrentState
|
||||||
// that throws to verify the error-path returns an error badge.
|
// to throw — the public getStatusBadge() must catch and return an
|
||||||
|
// error badge without propagating.
|
||||||
|
const { inst } = buildAdapter();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const source = {
|
inst.source.logger.error = (m) => errors.push(m);
|
||||||
currentMode: 'auto',
|
inst.source.state.getCurrentState = () => { throw new Error('boom'); };
|
||||||
state: { getCurrentState() { throw new Error('boom'); } },
|
|
||||||
logger: { error: (m) => errors.push(m) },
|
const status = inst.source.getStatusBadge();
|
||||||
};
|
|
||||||
const { buildStatusBadge } = require('../../src/io/output');
|
|
||||||
const status = buildStatusBadge(source);
|
|
||||||
assert.match(status.text, /Status Error/);
|
assert.match(status.text, /Status Error/);
|
||||||
assert.equal(status.fill, 'red');
|
assert.equal(status.fill, 'red');
|
||||||
assert.equal(errors.length, 1);
|
assert.equal(errors.length, 1);
|
||||||
|
|||||||
@@ -2,149 +2,209 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const NodeClass = require('../../src/nodeClass');
|
const NodeClass = require('../../src/nodeClass');
|
||||||
const commands = require('../../src/commands');
|
|
||||||
const { createRegistry } = require('generalFunctions');
|
|
||||||
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
// Post-BaseNodeAdapter, dispatch is the commands-registry. These tests
|
// Drive routing through the public BaseNodeAdapter surface only. We
|
||||||
// drive the same surface from a prototype-derived nodeClass instance to
|
// construct a full nodeClass instance and invoke the input handler
|
||||||
// keep the routing covered without booting Node-RED.
|
// 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 makeSourceStub() {
|
function makeUiConfig(overrides = {}) {
|
||||||
const calls = [];
|
|
||||||
return {
|
return {
|
||||||
calls,
|
unit: 'm3/h',
|
||||||
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
|
enableLog: false,
|
||||||
childRegistrationUtils: { registerChild(childSource, pos) { calls.push(['registerChild', childSource, pos]); } },
|
logLevel: 'error',
|
||||||
setMode(mode) { calls.push(['setMode', mode]); },
|
supplier: 'hidrostal',
|
||||||
handleInput(source, action, parameter) { calls.push(['handleInput', source, action, parameter]); return Promise.resolve(); },
|
category: 'machine',
|
||||||
showWorkingCurves() { return { ok: true }; },
|
assetType: 'pump',
|
||||||
showCoG() { return { cog: 1 }; },
|
model: 'hidrostal-H05K-S03R',
|
||||||
updateSimulatedMeasurement(type, position, value) { calls.push(['updateSimulatedMeasurement', type, position, value]); },
|
curvePressureUnit: 'mbar',
|
||||||
updateMeasuredPressure(value, position) { calls.push(['updateMeasuredPressure', value, position]); },
|
curveFlowUnit: 'm3/h',
|
||||||
updateMeasuredFlow(value, position) { calls.push(['updateMeasuredFlow', value, position]); },
|
curvePowerUnit: 'kW',
|
||||||
updateMeasuredPower(value, position) { calls.push(['updateMeasuredPower', value, position]); },
|
curveControlUnit: '%',
|
||||||
updateMeasuredTemperature(value, position) { calls.push(['updateMeasuredTemperature', value, position]); },
|
positionVsParent: 'atEquipment',
|
||||||
isUnitValidForType() { return true; },
|
speed: 1,
|
||||||
|
movementMode: 'staticspeed',
|
||||||
|
startup: 0,
|
||||||
|
warmup: 0,
|
||||||
|
shutdown: 0,
|
||||||
|
cooldown: 0,
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('input handler routes topics to source methods via commands registry', async () => {
|
// Adapters built in these tests park a periodic status-poll timer. We
|
||||||
const inst = Object.create(NodeClass.prototype);
|
// 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 node = makeNodeStub();
|
||||||
const source = makeSourceStub();
|
const RED = makeREDStub(redNodes);
|
||||||
inst.node = node;
|
const inst = new NodeClass(ui, RED, node, 'rotatingMachine');
|
||||||
inst.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
|
_adapters.push(node);
|
||||||
inst.source = source;
|
return { inst, node, RED };
|
||||||
inst._commands = createRegistry(commands, { logger: source.logger });
|
}
|
||||||
inst._attachInputHandler();
|
test.afterEach(() => {
|
||||||
const onInput = node._handlers.input;
|
while (_adapters.length) {
|
||||||
|
const node = _adapters.pop();
|
||||||
await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
try { node._handlers.close?.(() => {}); } catch (_) { /* best effort */ }
|
||||||
await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
|
}
|
||||||
await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
|
||||||
await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
|
||||||
await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
|
||||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
|
||||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
|
||||||
|
|
||||||
assert.deepEqual(source.calls[0], ['setMode', 'auto']);
|
|
||||||
assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
|
||||||
assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
|
||||||
// estop handler defaults action to 'emergencystop' even without one
|
|
||||||
// supplied, so the trailing arg is undefined — passed as positional.
|
|
||||||
assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
|
|
||||||
assert.deepEqual(source.calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
|
||||||
assert.deepEqual(source.calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
|
||||||
assert.deepEqual(source.calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('simulateMeasurement warns and ignores invalid payloads', async () => {
|
// Capture every call to source.handleInput so the test can assert which
|
||||||
const warns = [];
|
// canonical action the dispatch produced.
|
||||||
const inst = Object.create(NodeClass.prototype);
|
function instrumentHandleInput(source) {
|
||||||
const node = makeNodeStub();
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
inst.node = node;
|
const orig = source.handleInput.bind(source);
|
||||||
inst.RED = makeREDStub();
|
source.handleInput = async (...args) => {
|
||||||
inst.source = {
|
calls.push(args);
|
||||||
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
|
return orig(...args);
|
||||||
childRegistrationUtils: { registerChild() {} },
|
|
||||||
setMode() {},
|
|
||||||
handleInput() { return Promise.resolve(); },
|
|
||||||
showWorkingCurves() { return {}; },
|
|
||||||
showCoG() { return {}; },
|
|
||||||
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
|
||||||
updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
|
|
||||||
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
|
||||||
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
|
||||||
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
|
||||||
isUnitValidForType() { return true; },
|
|
||||||
};
|
};
|
||||||
inst._commands = createRegistry(commands, { logger: inst.source.logger });
|
return calls;
|
||||||
inst._attachInputHandler();
|
}
|
||||||
const onInput = node._handlers.input;
|
|
||||||
|
|
||||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
async function fireInput(node, msg) {
|
||||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
await node._handlers.input(msg, (out) => node._sent.push(out), () => {});
|
||||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
}
|
||||||
|
|
||||||
assert.equal(calls.length, 0);
|
test('set.mode (and legacy setMode alias) flips the source mode', async () => {
|
||||||
// Filter out the one-time deprecation warning for the legacy
|
const { inst, node } = buildAdapter();
|
||||||
// 'simulateMeasurement' alias — only the three invalid-payload warns
|
const startingMode = inst.source.currentMode;
|
||||||
// matter for this assertion.
|
|
||||||
const payloadWarns = warns.filter((w) => !/deprecated/i.test(String(w)));
|
await fireInput(node, { topic: 'set.mode', payload: 'virtualControl' });
|
||||||
assert.equal(payloadWarns.length, 3);
|
assert.equal(inst.source.currentMode, 'virtualControl');
|
||||||
assert.match(String(payloadWarns[0]), /finite number/i);
|
assert.notEqual(inst.source.currentMode, startingMode);
|
||||||
assert.match(String(payloadWarns[1]), /payload\.unit is required/i);
|
|
||||||
assert.match(String(payloadWarns[2]), /unsupported simulatemeasurement type/i);
|
// Legacy alias still works (emits a one-time deprecation warning).
|
||||||
|
await fireInput(node, { topic: 'setMode', payload: 'auto' });
|
||||||
|
assert.equal(inst.source.currentMode, 'auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
|
test('cmd.startup / execSequence / flowMovement / emergencystop all reach handleInput with the right action', async () => {
|
||||||
// Status badge now lives on the domain (Machine). Build a tiny stub.
|
const { inst, node } = buildAdapter();
|
||||||
const source = {
|
const calls = instrumentHandleInput(inst.source);
|
||||||
currentMode: 'virtualControl',
|
|
||||||
state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
|
await fireInput(node, { topic: 'cmd.startup', payload: { source: 'GUI' } });
|
||||||
pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
|
await fireInput(node, { topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } });
|
||||||
measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
|
await fireInput(node, { topic: 'set.flow-setpoint', payload: { source: 'GUI', setpoint: 123 } });
|
||||||
unitPolicy: { output: { flow: 'm3/h' } },
|
await fireInput(node, { topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 99 } });
|
||||||
logger: { error: () => {} },
|
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]);
|
||||||
};
|
};
|
||||||
// Import the buildStatusBadge helper directly — it's the same code the
|
|
||||||
// domain's getStatusBadge() invokes.
|
await fireInput(node, { topic: 'child.register', payload: 'child-1', positionVsParent: 'downstream' });
|
||||||
const { buildStatusBadge } = require('../../src/io/output');
|
assert.equal(regCalls.length, 1);
|
||||||
const status = buildStatusBadge(source);
|
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.fill, 'yellow');
|
||||||
assert.equal(status.shape, 'ring');
|
assert.equal(status.shape, 'ring');
|
||||||
assert.match(status.text, /pressure not initialized/i);
|
assert.match(status.text, /pressure not initialized/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('showWorkingCurves and CoG route reply messages to process output index', async () => {
|
test('unknown topic dispatched to the input handler does not throw', async () => {
|
||||||
const inst = Object.create(NodeClass.prototype);
|
const { node } = buildAdapter();
|
||||||
const node = makeNodeStub();
|
await assert.doesNotReject(async () => {
|
||||||
const source = {
|
await fireInput(node, { topic: 'totally.unknown.topic', payload: 42 });
|
||||||
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
|
});
|
||||||
childRegistrationUtils: { registerChild() {} },
|
|
||||||
setMode() {}, handleInput() { return Promise.resolve(); },
|
|
||||||
showWorkingCurves() { return { curve: [1, 2, 3] }; },
|
|
||||||
showCoG() { return { cog: 0.77 }; },
|
|
||||||
};
|
|
||||||
inst.node = node;
|
|
||||||
inst.RED = makeREDStub();
|
|
||||||
inst.source = source;
|
|
||||||
inst._commands = createRegistry(commands, { logger: source.logger });
|
|
||||||
inst._attachInputHandler();
|
|
||||||
const onInput = node._handlers.input;
|
|
||||||
const sent = [];
|
|
||||||
const send = (out) => sent.push(out);
|
|
||||||
|
|
||||||
await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
|
||||||
await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
|
||||||
|
|
||||||
assert.equal(sent.length, 2);
|
|
||||||
assert.equal(Array.isArray(sent[0]), true);
|
|
||||||
assert.equal(sent[0].length, 3);
|
|
||||||
assert.equal(sent[0][0].topic, 'showWorkingCurves');
|
|
||||||
assert.equal(sent[0][1], null);
|
|
||||||
assert.equal(sent[0][2], null);
|
|
||||||
assert.equal(sent[1][0].topic, 'showCoG');
|
|
||||||
});
|
});
|
||||||
|
|||||||
26
wiki/Home.md
26
wiki/Home.md
@@ -95,19 +95,19 @@ flowchart TB
|
|||||||
|
|
||||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
| Canonical topic | Aliases | Payload | Effect |
|
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `set.mode` | `setMode` | `string` | Replaces the named state value with the supplied payload. |
|
| `set.mode` | `setMode` | `string` | — | Switch the machine between auto / manual control modes. |
|
||||||
| `cmd.startup` | _(none)_ | `any` | Triggers an action / sequence — not idempotent. |
|
| `cmd.startup` | _(none)_ | `any` | — | Initiate the machine startup sequence. |
|
||||||
| `cmd.shutdown` | _(none)_ | `any` | Triggers an action / sequence — not idempotent. |
|
| `cmd.shutdown` | _(none)_ | `any` | — | Initiate the machine shutdown sequence. |
|
||||||
| `cmd.estop` | `emergencystop` | `any` | Triggers an action / sequence — not idempotent. |
|
| `cmd.estop` | `emergencystop` | `any` | — | Trigger an emergency stop. |
|
||||||
| `execSequence` | _(none)_ | `object` | _(see handler)_ |
|
| `execSequence` | _(none)_ | `object` | — | Legacy umbrella that demuxes payload.action to startup / shutdown. |
|
||||||
| `set.setpoint` | `execMovement` | `object` | Replaces the named state value with the supplied payload. |
|
| `set.setpoint` | `execMovement` | `object` | — | Move the machine to a control-% setpoint via execMovement. |
|
||||||
| `set.flow-setpoint` | `flowMovement` | `object` | Replaces the named state value with the supplied payload. |
|
| `set.flow-setpoint` | `flowMovement` | `object` | `volumeFlowRate` (default `m3/h`) | Move the machine to a flow setpoint via flowMovement. |
|
||||||
| `data.simulate-measurement` | `simulateMeasurement` | `object` | Pushes a value into the node's measurement stream. |
|
| `data.simulate-measurement` | `simulateMeasurement` | `object` | — | Inject a simulated sensor reading (pressure/flow/temperature/power). |
|
||||||
| `query.curves` | `showWorkingCurves` | `any` | Read-only query; node replies on the same msg. |
|
| `query.curves` | `showWorkingCurves` | `any` | — | Return the working curves for the machine on the reply port. |
|
||||||
| `query.cog` | `CoG` | `any` | Read-only query; node replies on the same msg. |
|
| `query.cog` | `CoG` | `any` | — | Return the centre-of-gravity (CoG) point on the reply port. |
|
||||||
| `child.register` | `registerChild` | `string` | Parent/child plumbing — registers or unregisters a child node. |
|
| `child.register` | `registerChild` | `string` | — | Register a child measurement with this machine. |
|
||||||
|
|
||||||
<!-- END AUTOGEN: topic-contract -->
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user