specificClass._setupCurves now calls assetResolver.resolveAssetMetadata to derive supplier/type/units from the model id, instead of trusting denormalized fields on the node config. If the model isn't in the registry, installs a null-predictor stub and logs a clear "pick a model from the asset menu" error rather than crashing. rotatingMachine.html: defaults block trimmed (supplier/category/assetType were stale copies of registry data). Tests: - New test/basic/assetMetadata.basic.test.js covers the registry-resolve path and the missing-model fallback. - nodeClass-config / error-paths / nodeClass-routing / factories / abort-deadlock fixtures updated to the trimmed asset shape. - 209/209 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
8.8 KiB
JavaScript
210 lines
8.8 KiB
JavaScript
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 });
|
|
});
|
|
});
|