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); }); 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]); });