Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:
- src/domain/UnitPolicy.js — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js — standardised {level, flags, message, source}
- src/nodered/statusBadge.js — compose / error / idle / byState / text helpers
- src/stats/index.js — mean / stdDev / median / mad / lerp
All additive — no existing exports change shape.
56 unit tests pass under node:test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
6.9 KiB
JavaScript
198 lines
6.9 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('events');
|
|
|
|
const ChildRouter = require('../../src/domain/ChildRouter');
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────
|
|
|
|
function makeDomain() {
|
|
const logs = [];
|
|
return {
|
|
logger: {
|
|
debug: (...a) => logs.push(['debug', ...a]),
|
|
info: (...a) => logs.push(['info', ...a]),
|
|
warn: (...a) => logs.push(['warn', ...a]),
|
|
error: (...a) => logs.push(['error', ...a]),
|
|
},
|
|
_logs: logs,
|
|
};
|
|
}
|
|
|
|
function makeChild({ id = 'c1', name = id, softwareType = 'measurement' } = {}) {
|
|
return {
|
|
config: {
|
|
general: { id, name },
|
|
functionality: { softwareType },
|
|
asset: { type: 'pressure' },
|
|
},
|
|
measurements: { emitter: new EventEmitter() },
|
|
};
|
|
}
|
|
|
|
function emitMeasured(child, type, position, value, extra = {}) {
|
|
child.measurements.emitter.emit(`${type}.measured.${position}`, { value, ...extra });
|
|
}
|
|
|
|
function emitPredicted(child, type, position, value, extra = {}) {
|
|
child.measurements.emitter.emit(`${type}.predicted.${position}`, { value, ...extra });
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────
|
|
|
|
test('onRegister fires for the matching softwareType', () => {
|
|
const domain = makeDomain();
|
|
const router = new ChildRouter(domain);
|
|
const seen = [];
|
|
|
|
router.onRegister('measurement', (child, st) => seen.push({ id: child.config.general.id, st }));
|
|
|
|
const ch = makeChild({ id: 'm1' });
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].id, 'm1');
|
|
assert.equal(seen[0].st, 'measurement');
|
|
});
|
|
|
|
test('onMeasurement with full filter only fires for matching events', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const hits = [];
|
|
|
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
|
(data, child) => hits.push({ v: data.value, id: child.config.general.id }));
|
|
|
|
const ch = makeChild({ id: 'p-up' });
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 100);
|
|
emitMeasured(ch, 'pressure', 'downstream', 200); // ignored: wrong position
|
|
emitMeasured(ch, 'flow', 'upstream', 5); // ignored: wrong type
|
|
emitPredicted(ch, 'pressure', 'upstream', 999); // ignored: wrong variant
|
|
|
|
assert.deepEqual(hits, [{ v: 100, id: 'p-up' }]);
|
|
});
|
|
|
|
test('onMeasurement without position filter fires for all positions of the type', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const hits = [];
|
|
|
|
router.onMeasurement('measurement', { type: 'pressure' },
|
|
(data) => hits.push(data.value));
|
|
|
|
const ch = makeChild();
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
|
emitMeasured(ch, 'pressure', 'downstream', 2);
|
|
emitMeasured(ch, 'pressure', 'atequipment', 3);
|
|
emitMeasured(ch, 'flow', 'upstream', 99); // ignored: wrong type
|
|
emitPredicted(ch, 'pressure', 'upstream', 50); // ignored: wrong variant
|
|
|
|
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
|
});
|
|
|
|
test('onPrediction works analogously to onMeasurement', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const hits = [];
|
|
|
|
router.onPrediction('machinegroup', { type: 'flow', position: 'downstream' },
|
|
(data) => hits.push(data.value));
|
|
|
|
const ch = makeChild({ softwareType: 'machinegroupcontrol' });
|
|
router.dispatchRegister(ch, 'machinegroupcontrol');
|
|
|
|
emitPredicted(ch, 'flow', 'downstream', 42);
|
|
emitPredicted(ch, 'flow', 'upstream', 7); // ignored: wrong position
|
|
emitMeasured(ch, 'flow', 'downstream', 99); // ignored: wrong variant
|
|
|
|
assert.deepEqual(hits, [42]);
|
|
});
|
|
|
|
test('software-type alias resolution: onRegister("machine") matches softwareType="rotatingmachine"', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const seen = [];
|
|
|
|
router.onRegister('machine', (child) => seen.push(child.config.general.id));
|
|
|
|
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
|
router.dispatchRegister(rm, 'rotatingmachine');
|
|
|
|
assert.deepEqual(seen, ['rm-1']);
|
|
});
|
|
|
|
test('alias resolution also flows through measurement subscriptions', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const hits = [];
|
|
|
|
// Declare with the canonical 'machine' alias.
|
|
router.onMeasurement('machine', { type: 'flow', position: 'downstream' },
|
|
(data) => hits.push(data.value));
|
|
|
|
// Child reports the raw, non-canonical softwareType.
|
|
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
|
router.dispatchRegister(rm, 'rotatingmachine');
|
|
|
|
emitMeasured(rm, 'flow', 'downstream', 17);
|
|
assert.deepEqual(hits, [17]);
|
|
});
|
|
|
|
test('tearDown removes listeners — re-emitting after tearDown does not invoke handler', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const hits = [];
|
|
|
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
|
(data) => hits.push(['concrete', data.value]));
|
|
router.onMeasurement('measurement', { type: 'pressure' }, // wildcard branch
|
|
(data) => hits.push(['wild', data.value]));
|
|
|
|
const ch = makeChild();
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
|
assert.equal(hits.length, 2);
|
|
|
|
router.tearDown();
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 2);
|
|
emitMeasured(ch, 'pressure', 'downstream', 3);
|
|
assert.equal(hits.length, 2, 'no further hits after tearDown');
|
|
|
|
// Original emit should be restored after teardown — sanity-check it still works
|
|
// for unrelated listeners on the same emitter.
|
|
let other = 0;
|
|
ch.measurements.emitter.on('flow.measured.upstream', () => other++);
|
|
emitMeasured(ch, 'flow', 'upstream', 9);
|
|
assert.equal(other, 1);
|
|
});
|
|
|
|
test('multiple onMeasurement subscriptions for same softwareType all fire', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const a = []; const b = []; const c = [];
|
|
|
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
|
(d) => a.push(d.value));
|
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
|
(d) => b.push(d.value)); // duplicate concrete sub
|
|
router.onMeasurement('measurement', { type: 'pressure' },
|
|
(d) => c.push(d.value)); // wildcard-position sub
|
|
|
|
const ch = makeChild();
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 7);
|
|
|
|
assert.deepEqual(a, [7]);
|
|
assert.deepEqual(b, [7]);
|
|
assert.deepEqual(c, [7]);
|
|
});
|
|
|
|
test('chainable API returns the router instance', () => {
|
|
const router = new ChildRouter(makeDomain());
|
|
const r = router
|
|
.onRegister('measurement', () => {})
|
|
.onMeasurement('measurement', { type: 'flow' }, () => {})
|
|
.onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {});
|
|
assert.equal(r, router);
|
|
});
|