'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const EventEmitter = require('events'); const BaseNodeAdapter = require('../../src/nodered/BaseNodeAdapter'); // ---- test doubles --------------------------------------------------------- function makeLogger() { const calls = { warn: [], error: [], info: [], debug: [] }; return { warn: (...a) => calls.warn.push(a.join(' ')), error: (...a) => calls.error.push(a.join(' ')), info: (...a) => calls.info.push(a.join(' ')), debug: (...a) => calls.debug.push(a.join(' ')), _calls: calls, }; } function makeNode(id = 'node-1') { const sends = []; const statuses = []; const handlers = {}; return { id, sends, statuses, handlers, send(arr) { sends.push(arr); }, status(b) { statuses.push(b); }, on(ev, fn) { handlers[ev] = fn; }, warn() {}, error() {}, }; } function makeRED() { return { nodes: { getNode: () => null } }; } // Fake domain — surfaces just enough of the BaseDomain contract that // BaseNodeAdapter touches (config, logger, emitter, getOutput, getStatusBadge, // optionally tick + close). Avoids the JSON-config dependency BaseDomain has. function makeDomain(opts = {}) { const logger = opts.logger || makeLogger(); return class FakeDomain { constructor(config) { this.config = config; this.logger = logger; this.emitter = new EventEmitter(); this.tickCount = 0; this.closed = false; this._output = opts.output || { temperature: 21 }; this._badge = opts.badge || { fill: 'green', shape: 'dot', text: 'OK' }; } tick() { this.tickCount += 1; } getOutput() { return this._output; } getStatusBadge() { return this._badge; } close() { this.closed = true; } }; } // uiConfig field set used by configManager.buildConfig — measurement is // chosen as the config-file name because measurement.json ships in // generalFunctions/src/configs and getConfig() is called during construction. function uiConfigFixture() { return { name: 'm1', unit: 'C', logLevel: 'warn', positionVsParent: 'upstream', hasDistance: true, distance: 5, }; } // ---- 1. Construction with full subclass succeeds -------------------------- test('full subclass constructs and stores wiring on this', () => { const Domain = makeDomain(); class Adapter extends BaseNodeAdapter { static DomainClass = Domain; static commands = []; // Disable the real status interval — would hold the event loop open // past the test and stall `node --test test/basic/` runs. static statusInterval = 0; buildDomainConfig() { return { extra: { foo: 1 } }; } } const node = makeNode(); const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); assert.equal(a.name, 'measurement'); assert.equal(a.node, node); assert.equal(node.source, a.source); assert.equal(a.config.extra.foo, 1); assert.equal(a.config.general.name, 'm1'); node.handlers.close(() => {}); }); // ---- 2-4. Static-field validation ----------------------------------------- test('direct new BaseNodeAdapter() throws abstract error', () => { assert.throws( () => new BaseNodeAdapter({}, makeRED(), makeNode(), 'measurement'), /abstract/, ); }); test('subclass without static DomainClass throws clearly', () => { class Bad extends BaseNodeAdapter { static commands = []; buildDomainConfig() { return {}; } } assert.throws( () => new Bad({}, makeRED(), makeNode(), 'measurement'), /DomainClass is required/, ); }); test('subclass without static commands throws clearly', () => { class Bad extends BaseNodeAdapter { static DomainClass = makeDomain(); buildDomainConfig() { return {}; } } assert.throws( () => new Bad({}, makeRED(), makeNode(), 'measurement'), /commands is required/, ); }); test('static commands = [] is allowed (explicit no-op registry)', () => { class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = []; static statusInterval = 0; // see fix in test #1 buildDomainConfig() { return {}; } } const node = makeNode(); assert.doesNotThrow( () => new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'), ); node.handlers.close(() => {}); }); // ---- 5. Registration message after 100 ms --------------------------------- test('registration message fires on Port 2 after 100 ms with child.register', (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = []; buildDomainConfig() { return {}; } } const node = makeNode('xyz'); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); assert.equal(node.sends.length, 0); t.mock.timers.tick(100); assert.equal(node.sends.length, 1); const [p0, p1, reg] = node.sends[0]; assert.equal(p0, null); assert.equal(p1, null); assert.equal(reg.topic, 'child.register'); assert.equal(reg.payload, 'xyz'); assert.equal(reg.positionVsParent, 'upstream'); assert.equal(reg.distance, 5); }); // ---- 6. Tick mode --------------------------------------------------------- test('static tickInterval > 0 calls source.tick() on schedule and emits outputs', (t) => { t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = []; static tickInterval = 50; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); assert.equal(a.source.tickCount, 0); t.mock.timers.tick(50); assert.equal(a.source.tickCount, 1); t.mock.timers.tick(100); assert.equal(a.source.tickCount, 3); // Every tick triggers an output emission (the first carries the changed // fields; subsequent ones may emit nulls because of delta compression — // but node.send is called either way). assert.ok(node.sends.length >= 3); }); // ---- 7. Event-driven default ---------------------------------------------- test('default (no tick) subscribes to "output-changed" on source.emitter', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = []; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); // Drain the registration tick so we can isolate output emissions. t.mock.timers.tick(100); const before = node.sends.length; a.source.emitter.emit('output-changed'); assert.equal(node.sends.length, before + 1); const last = node.sends[node.sends.length - 1]; assert.equal(last.length, 3); assert.equal(last[2], null); }); // ---- 8. _emitOutputs shape ------------------------------------------------ test('_emitOutputs sends [processMsg, influxMsg, null] with both formatters', () => { class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain({ output: { v: 1 } }); static commands = []; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); node.sends.length = 0; a._emitOutputs(); assert.equal(node.sends.length, 1); const [proc, influx, port2] = node.sends[0]; assert.ok(proc && typeof proc === 'object', 'process msg present'); assert.ok(influx && typeof influx === 'object', 'influxdb msg present'); assert.equal(port2, null); }); // ---- 9-10. Input dispatch ------------------------------------------------- test('input handler dispatches a known topic to the registered handler', async () => { const seen = []; class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = [{ topic: 'set.mode', handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); }, }]; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); let donec = 0; await node.handlers.input({ topic: 'set.mode', payload: 'auto' }, () => {}, () => { donec += 1; }); assert.equal(seen.length, 1); assert.equal(seen[0].source, a.source); assert.equal(seen[0].msg.payload, 'auto'); assert.equal(donec, 1); }); test('input handler with unknown topic warns and does not crash', async () => { const logger = makeLogger(); class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain({ logger }); static commands = []; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); let donec = 0; await node.handlers.input({ topic: 'totally.unknown', payload: 1 }, () => {}, () => { donec += 1; }); assert.equal(donec, 1); assert.ok(logger._calls.warn.some((m) => m.includes('totally.unknown'))); }); // ---- 11. Status updater wiring -------------------------------------------- test('status updater receives static statusInterval', (t) => { t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain({ badge: { fill: 'red', shape: 'ring', text: 'X' } }); static commands = []; static statusInterval = 250; buildDomainConfig() { return {}; } } const node = makeNode(); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); assert.equal(node.statuses.length, 0); t.mock.timers.tick(250); assert.equal(node.statuses.length, 1); assert.deepEqual(node.statuses[0], { fill: 'red', shape: 'ring', text: 'X' }); }); // ---- 12. Close handler ---------------------------------------------------- test('close handler clears tick interval, stops status, clears badge, calls source.close', (t) => { t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = []; static tickInterval = 100; static statusInterval = 100; buildDomainConfig() { return {}; } } const node = makeNode(); const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); t.mock.timers.tick(200); // two ticks fire const ticksAtClose = a.source.tickCount; let donec = 0; node.handlers.close(() => { donec += 1; }); assert.equal(donec, 1); assert.equal(a.source.closed, true); // Final node.status({}) appears in statuses. assert.deepEqual(node.statuses[node.statuses.length - 1], {}); // No further ticks after close. t.mock.timers.tick(1000); assert.equal(a.source.tickCount, ticksAtClose); }); // ---- 13. Hook points fire when defined ------------------------------------ // ---- 14-16. Auto-wired query.units --------------------------------------- test('implicit query.units returns measure+default+accepted for every units-declaring topic', async () => { class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = [ { topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, payloadSchema: { type: 'number' }, handler: () => {}, }, { topic: 'cmd.calibrate.volume', units: { measure: 'volume', default: 'm3' }, payloadSchema: { type: 'number' }, handler: () => {}, }, { topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {}, }, ]; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); const sent = []; await node.handlers.input( { topic: 'query.units' }, (arr) => sent.push(arr), () => {}, ); assert.equal(sent.length, 1); const [p0, p1, p2] = sent[0]; assert.equal(p1, null); assert.equal(p2, null); assert.equal(p0.topic, 'query.units'); assert.equal(p0.payload.node, 'measurement'); const u = p0.payload.units; assert.ok(u['set.demand'], 'set.demand entry present'); assert.equal(u['set.demand'].measure, 'volumeFlowRate'); assert.equal(u['set.demand'].default, 'm3/h'); assert.ok(Array.isArray(u['set.demand'].accepted), 'accepted is an array'); assert.ok(u['set.demand'].accepted.length > 0, 'accepted is non-empty'); assert.ok(u['cmd.calibrate.volume'], 'cmd.calibrate.volume entry present'); assert.equal(u['cmd.calibrate.volume'].measure, 'volume'); assert.equal(u['cmd.calibrate.volume'].default, 'm3'); // Topic without units does not show up. assert.equal(u['set.mode'], undefined); node.handlers.close(() => {}); }); test('implicit query.units returns empty units object when no command declares units', async () => { class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = [ { topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} }, ]; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); const sent = []; await node.handlers.input( { topic: 'query.units' }, (arr) => sent.push(arr), () => {}, ); assert.equal(sent.length, 1); const [p0] = sent[0]; assert.equal(p0.topic, 'query.units'); assert.deepEqual(p0.payload.units, {}); assert.equal(p0.payload.node, 'measurement'); node.handlers.close(() => {}); }); test('explicit query.units descriptor wins over the implicit auto-wired handler', async () => { let customRan = 0; class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = [ { topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, payloadSchema: { type: 'number' }, handler: () => {}, }, { topic: 'query.units', payloadSchema: { type: 'any' }, handler: (source, msg, ctx) => { customRan += 1; if (ctx && typeof ctx.send === 'function') { ctx.send([{ topic: 'query.units', payload: 'CUSTOM' }, null, null]); } }, }, ]; static statusInterval = 0; buildDomainConfig() { return {}; } } const node = makeNode(); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); const sent = []; await node.handlers.input( { topic: 'query.units' }, (arr) => sent.push(arr), () => {}, ); assert.equal(customRan, 1, 'custom handler must have been called once'); assert.equal(sent.length, 1); assert.equal(sent[0][0].payload, 'CUSTOM', 'reply payload comes from the subclass-declared handler, not the implicit one'); node.handlers.close(() => {}); }); test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const trace = []; class Adapter extends BaseNodeAdapter { static DomainClass = makeDomain(); static commands = [{ topic: 'set.x', handler: () => { trace.push('handler'); } }]; static statusInterval = 0; buildDomainConfig() { return {}; } extraSetup() { trace.push('extraSetup'); } extraInputDispatch(msg) { trace.push(`extraInput:${msg.topic}`); } extraClose() { trace.push('extraClose'); } } const node = makeNode(); new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); assert.ok(trace.includes('extraSetup')); await node.handlers.input({ topic: 'set.x', payload: 1 }, () => {}, () => {}); assert.ok(trace.includes('handler')); assert.ok(trace.includes('extraInput:set.x')); // Unknown-topic path also runs extraInputDispatch — by design, it's the // fallback the contract documents. await node.handlers.input({ topic: 'unknown', payload: 1 }, () => {}, () => {}); assert.ok(trace.includes('extraInput:unknown')); node.handlers.close(() => {}); assert.ok(trace.includes('extraClose')); });