2026-05-10 18:27:29 +02:00
|
|
|
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);
|
|
|
|
|
});
|
2026-05-11 17:13:15 +02:00
|
|
|
|
|
|
|
|
test('multi-parent: two routers on the same child both receive every event and tear down independently', () => {
|
|
|
|
|
// Regression for the pre-2026-05-11 emit-patching stack: two parents
|
|
|
|
|
// subscribing partial-filter wildcards on the same child must compose
|
|
|
|
|
// without stacking wrappers, and either teardown order must work.
|
|
|
|
|
const routerA = new ChildRouter(makeDomain());
|
|
|
|
|
const routerB = new ChildRouter(makeDomain());
|
|
|
|
|
const a = []; const b = [];
|
|
|
|
|
|
|
|
|
|
routerA.onMeasurement('measurement', { type: 'pressure' },
|
|
|
|
|
(data) => a.push(data.value));
|
|
|
|
|
routerB.onMeasurement('measurement', { type: 'pressure' },
|
|
|
|
|
(data) => b.push(data.value));
|
|
|
|
|
|
|
|
|
|
const ch = makeChild();
|
|
|
|
|
routerA.dispatchRegister(ch, 'measurement');
|
|
|
|
|
routerB.dispatchRegister(ch, 'measurement');
|
|
|
|
|
|
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 11);
|
|
|
|
|
emitMeasured(ch, 'pressure', 'downstream', 22);
|
|
|
|
|
assert.deepEqual(a.sort(), [11, 22]);
|
|
|
|
|
assert.deepEqual(b.sort(), [11, 22]);
|
|
|
|
|
|
|
|
|
|
// Tear down B first — A must continue to fire on subsequent events.
|
|
|
|
|
routerB.tearDown();
|
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 33);
|
|
|
|
|
assert.deepEqual(a.sort(), [11, 22, 33]);
|
|
|
|
|
assert.deepEqual(b.sort(), [11, 22], 'B receives nothing after its teardown');
|
|
|
|
|
|
|
|
|
|
// Now tear down A in the reverse order; neither should fire.
|
|
|
|
|
routerA.tearDown();
|
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 44);
|
|
|
|
|
assert.deepEqual(a.sort(), [11, 22, 33], 'A receives nothing after its teardown');
|
|
|
|
|
assert.deepEqual(b.sort(), [11, 22]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('position-only filter fans out across every known type for that position', () => {
|
|
|
|
|
const router = new ChildRouter(makeDomain());
|
|
|
|
|
const hits = [];
|
|
|
|
|
|
|
|
|
|
router.onMeasurement('measurement', { position: 'upstream' },
|
|
|
|
|
(data) => hits.push(data.value));
|
|
|
|
|
|
|
|
|
|
const ch = makeChild();
|
|
|
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
|
|
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
|
|
|
|
emitMeasured(ch, 'flow', 'upstream', 2);
|
|
|
|
|
emitMeasured(ch, 'temperature', 'upstream', 3);
|
|
|
|
|
emitMeasured(ch, 'pressure', 'downstream', 99); // wrong position
|
|
|
|
|
emitPredicted(ch, 'pressure', 'upstream', 99); // wrong variant
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('empty filter ({}) fires for every type/position combination', () => {
|
|
|
|
|
const router = new ChildRouter(makeDomain());
|
|
|
|
|
const hits = [];
|
|
|
|
|
|
|
|
|
|
router.onMeasurement('measurement', {}, (data) => hits.push(data.value));
|
|
|
|
|
|
|
|
|
|
const ch = makeChild();
|
|
|
|
|
router.dispatchRegister(ch, 'measurement');
|
|
|
|
|
|
|
|
|
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
|
|
|
|
emitMeasured(ch, 'flow', 'downstream', 2);
|
|
|
|
|
emitMeasured(ch, 'level', 'atequipment', 3);
|
|
|
|
|
emitPredicted(ch, 'flow', 'upstream', 99); // wrong variant
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
|
|
|
|
});
|