src/curves/ loader + normalizer (with cross-pressure anomaly
detection) + reverseCurve helper
src/prediction/ predictors (predictFlow/Power/Ctrl) +
groupPredictors (lazy group-scope views) +
OperatingPoint (pressure-driven prediction setpoints)
src/drift/ DriftAssessor (per-metric drift) + PredictionHealth
(composes flow/power/pressure into HealthStatus +
confidence sibling — see OPEN_QUESTIONS 2026-05-10)
src/pressure/ VirtualPressureChildren (dashboard-sim) +
PressureInitialization (real-vs-virtual tracking) +
PressureRouter (dispatches by position)
src/state/ stateBindings (state.emitter listener helper) +
isOperationalState
src/measurement/ measurementHandlers (dispatcher for flow/power/temp/pressure)
src/flow/ flowController (handleInput body — execSequence,
execMovement, flowMovement, emergencystop)
src/display/ workingCurves (showWorkingCurves + showCoG admin)
src/commands/ canonical names: set.mode, cmd.startup/shutdown/estop,
set.setpoint, set.flow-setpoint,
data.simulate-measurement, query.curves, query.cog,
child.register. execSequence demuxes by payload.action
to canonical cmd.* handlers.
CONTRACT.md inputs/outputs/events/children surface
110 basic tests pass (100 new + 10 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P5 wave 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
11 KiB
JavaScript
276 lines
11 KiB
JavaScript
// Basic tests for the rotatingMachine 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({ name = 'rm-1', unitValid = true } = {}) {
|
|
const calls = {
|
|
setMode: [],
|
|
handleInput: [],
|
|
registerChild: [],
|
|
sim: [],
|
|
updatePressure: [],
|
|
updateFlow: [],
|
|
updateTemp: [],
|
|
updatePower: [],
|
|
showWorkingCurves: 0,
|
|
showCoG: 0,
|
|
};
|
|
const source = {
|
|
logger: makeLogger(),
|
|
config: { general: { name } },
|
|
setMode: (m) => calls.setMode.push(m),
|
|
handleInput: async (src, action, parameter) => {
|
|
calls.handleInput.push({ src, action, parameter });
|
|
},
|
|
isUnitValidForType: () => unitValid,
|
|
updateSimulatedMeasurement: (type, position, value, ctx) =>
|
|
calls.sim.push({ type, position, value, ctx }),
|
|
updateMeasuredPressure: (v, p, c) => calls.updatePressure.push({ v, p, c }),
|
|
updateMeasuredFlow: (v, p, c) => calls.updateFlow.push({ v, p, c }),
|
|
updateMeasuredTemperature: (v, p, c) => calls.updateTemp.push({ v, p, c }),
|
|
updateMeasuredPower: (v, p, c) => calls.updatePower.push({ v, p, c }),
|
|
showWorkingCurves: () => { calls.showWorkingCurves++; return { curves: 'mock' }; },
|
|
showCoG: () => { calls.showCoG++; return { cog: 'mock' }; },
|
|
childRegistrationUtils: {
|
|
registerChild: (childSource, position) =>
|
|
calls.registerChild.push({ childSource, position }),
|
|
},
|
|
};
|
|
return { source, calls };
|
|
}
|
|
|
|
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
|
|
return {
|
|
logger,
|
|
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
|
|
node: {},
|
|
send: sendSpy || (() => {}),
|
|
};
|
|
}
|
|
|
|
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: 'GUI' }, source, makeCtx());
|
|
assert.deepEqual(calls.setMode, ['GUI']);
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'cmd.startup', payload: { source: 'GUI' } }, source, makeCtx());
|
|
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'startup' });
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'cmd.shutdown', payload: { source: 'GUI' } }, source, makeCtx());
|
|
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'cmd.estop', payload: { source: 'GUI', action: 'emergencystop' } }, source, makeCtx());
|
|
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'emergencystop', parameter: undefined });
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'set.setpoint', payload: { source: 'GUI', action: 'execMovement', setpoint: '75' } },
|
|
source, makeCtx());
|
|
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execMovement', parameter: 75 });
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'set.flow-setpoint', payload: { source: 'GUI', action: 'flowMovement', setpoint: '12' } },
|
|
source, makeCtx());
|
|
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'flowMovement', parameter: 12 });
|
|
});
|
|
|
|
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: 'setMode', payload: 'GUI' }, source, makeCtx({ logger: ctxLogger }));
|
|
await reg.dispatch({ topic: 'setMode', payload: 'virtualControl' }, source, makeCtx({ logger: ctxLogger }));
|
|
assert.deepEqual(calls.setMode, ['GUI', 'virtualControl']);
|
|
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
|
|
assert.equal(warns.length, 1);
|
|
|
|
await reg.dispatch({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
warns = ctxLogger.calls.warn.filter((m) => m.includes("'emergencystop' is deprecated"));
|
|
assert.equal(warns.length, 1);
|
|
|
|
await reg.dispatch({ topic: 'execMovement', payload: { source: 'GUI', action: 'execMovement', setpoint: 50 } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
warns = ctxLogger.calls.warn.filter((m) => m.includes("'execMovement' is deprecated"));
|
|
assert.equal(warns.length, 1);
|
|
|
|
await reg.dispatch({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 5 } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
warns = ctxLogger.calls.warn.filter((m) => m.includes("'flowMovement' is deprecated"));
|
|
assert.equal(warns.length, 1);
|
|
});
|
|
|
|
test('execSequence with payload.action=startup reaches cmd.startup handler', async () => {
|
|
const { source, calls } = makeSource();
|
|
const ctxLogger = makeLogger();
|
|
const reg = makeRegistry(ctxLogger);
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
|
|
assert.equal(calls.handleInput.length, 1);
|
|
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'startup' });
|
|
// Registry logs the legacy-topic deprecation (no canonical alias, but
|
|
// the demux handler accepts both startup/shutdown actions).
|
|
});
|
|
|
|
test('execSequence with payload.action=shutdown reaches cmd.shutdown handler', async () => {
|
|
const { source, calls } = makeSource();
|
|
const reg = makeRegistry(makeLogger());
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'execSequence', payload: { source: 'GUI', action: 'shutdown' } },
|
|
source, makeCtx());
|
|
|
|
assert.equal(calls.handleInput.length, 1);
|
|
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
|
|
});
|
|
|
|
test('execSequence with unknown action logs warn and does not call handleInput', async () => {
|
|
const { source, calls } = makeSource();
|
|
const ctxLogger = makeLogger();
|
|
const reg = makeRegistry(makeLogger());
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'execSequence', payload: { source: 'GUI', action: 'frobnicate' } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
assert.equal(calls.handleInput.length, 0);
|
|
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('execSequence') && m.includes('frobnicate')),
|
|
`expected warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`);
|
|
});
|
|
|
|
test('data.simulate-measurement happy path dispatches to the right updater', async () => {
|
|
const { source, calls } = makeSource();
|
|
const reg = makeRegistry(makeLogger());
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'data.simulate-measurement',
|
|
payload: { type: 'pressure', position: 'upstream', value: 1013, unit: 'mbar' } },
|
|
source, makeCtx());
|
|
assert.equal(calls.sim.length, 1);
|
|
assert.equal(calls.sim[0].type, 'pressure');
|
|
assert.equal(calls.sim[0].value, 1013);
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'data.simulate-measurement',
|
|
payload: { type: 'flow', value: 30, unit: 'm3/h' } },
|
|
source, makeCtx());
|
|
assert.equal(calls.updateFlow.length, 1);
|
|
});
|
|
|
|
test('data.simulate-measurement validation: bad type / missing unit / non-finite value', async () => {
|
|
const { source, calls } = makeSource();
|
|
const ctxLogger = makeLogger();
|
|
const reg = makeRegistry(makeLogger());
|
|
|
|
// unsupported type
|
|
await reg.dispatch(
|
|
{ topic: 'data.simulate-measurement', payload: { type: 'voltage', value: 1, unit: 'V' } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('Unsupported simulateMeasurement type: voltage')));
|
|
|
|
// missing unit
|
|
await reg.dispatch(
|
|
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 1013 } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('unit is required')));
|
|
|
|
// non-finite value
|
|
await reg.dispatch(
|
|
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 'abc', unit: 'mbar' } },
|
|
source, makeCtx({ logger: ctxLogger }));
|
|
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('must be a finite number')));
|
|
|
|
// nothing was forwarded to the source
|
|
assert.equal(calls.sim.length, 0);
|
|
assert.equal(calls.updateFlow.length, 0);
|
|
assert.equal(calls.updatePressure.length, 0);
|
|
});
|
|
|
|
test('query.curves and query.cog reply on Port 0 via ctx.send', async () => {
|
|
const { source, calls } = makeSource();
|
|
const sent = [];
|
|
const ctx = makeCtx({ sendSpy: (ports) => sent.push(ports) });
|
|
const reg = makeRegistry(makeLogger());
|
|
|
|
await reg.dispatch({ topic: 'query.curves' }, source, ctx);
|
|
await reg.dispatch({ topic: 'query.cog' }, source, ctx);
|
|
|
|
assert.equal(calls.showWorkingCurves, 1);
|
|
assert.equal(calls.showCoG, 1);
|
|
assert.equal(sent.length, 2);
|
|
// First port carries the reply; Ports 1 & 2 are null.
|
|
assert.equal(sent[0][0].topic, 'showWorkingCurves');
|
|
assert.deepEqual(sent[0][0].payload, { curves: 'mock' });
|
|
assert.equal(sent[0][1], null);
|
|
assert.equal(sent[0][2], null);
|
|
assert.equal(sent[1][0].topic, 'showCoG');
|
|
assert.deepEqual(sent[1][0].payload, { cog: 'mock' });
|
|
});
|
|
|
|
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
|
const { source, calls } = makeSource();
|
|
const child = { id: 'm-1', source: { tag: 'm-domain' } };
|
|
const reg = makeRegistry(makeLogger());
|
|
|
|
await reg.dispatch(
|
|
{ topic: 'child.register', payload: 'm-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('child.register with unknown 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)}`
|
|
);
|
|
});
|