'use strict'; const { test } = require('node:test'); const assert = require('node:assert/strict'); const { createRegistry, CommandRegistry } = require('../../src/nodered/commandRegistry'); 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, }; } test('canonical topic dispatch invokes the handler with (source, msg, ctx)', async () => { const seen = []; const reg = createRegistry([{ topic: 'set.mode', handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); }, }]); const source = { id: 'src' }; const ctx = { tag: 'ctx' }; const msg = { topic: 'set.mode', payload: 'auto' }; await reg.dispatch(msg, source, ctx); assert.equal(seen.length, 1); assert.equal(seen[0].source, source); assert.equal(seen[0].msg, msg); assert.equal(seen[0].ctx, ctx); }); test('alias dispatch invokes handler and logs deprecation warning once', async () => { const logger = makeLogger(); let count = 0; const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => { count += 1; }, }], { logger }); await reg.dispatch({ topic: 'setMode', payload: 'auto' }, {}, {}); await reg.dispatch({ topic: 'setMode', payload: 'manual' }, {}, {}); assert.equal(count, 2); const deprecationWarns = logger._calls.warn.filter((m) => m.includes('deprecated')); assert.equal(deprecationWarns.length, 1); assert.match(deprecationWarns[0], /setMode/); assert.match(deprecationWarns[0], /set\.mode/); }); test('unknown topic logs warn and returns without throwing', async () => { const logger = makeLogger(); const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger }); await reg.dispatch({ topic: 'no.such.topic' }, {}, {}); assert.ok(logger._calls.warn.some((m) => m.includes('unknown topic'))); }); test('payloadSchema scalar rejects mismatched payload', async () => { const logger = makeLogger(); let invoked = false; const reg = createRegistry([{ topic: 'set.demand', payloadSchema: { type: 'number' }, handler: () => { invoked = true; }, }], { logger }); await reg.dispatch({ topic: 'set.demand', payload: 'not-a-number' }, {}, {}); assert.equal(invoked, false); assert.ok(logger._calls.warn.some((m) => m.includes('expected number'))); }); test('payloadSchema object properties enforce per-key typeof', async () => { const logger = makeLogger(); const accepted = []; const reg = createRegistry([{ topic: 'cmd.startup', payloadSchema: { type: 'object', properties: { name: 'string' } }, handler: (_s, msg) => { accepted.push(msg.payload); }, }], { logger }); await reg.dispatch({ topic: 'cmd.startup', payload: { name: 'foo' } }, {}, {}); await reg.dispatch({ topic: 'cmd.startup', payload: { name: 42 } }, {}, {}); assert.deepEqual(accepted, [{ name: 'foo' }]); assert.ok(logger._calls.warn.some((m) => m.includes('payload.name'))); }); test('payloadSchema type any accepts any payload', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'data.measurement', payloadSchema: { type: 'any' }, handler: (_s, msg) => { seen.push(msg.payload); }, }], { logger }); await reg.dispatch({ topic: 'data.measurement', payload: 1 }, {}, {}); await reg.dispatch({ topic: 'data.measurement', payload: 'x' }, {}, {}); await reg.dispatch({ topic: 'data.measurement', payload: { a: 1 } }, {}, {}); await reg.dispatch({ topic: 'data.measurement', payload: null }, {}, {}); assert.equal(seen.length, 4); assert.equal(logger._calls.warn.length, 0); }); test('async handler returns a promise that resolves after the handler completes', async () => { let done = false; const reg = createRegistry([{ topic: 'cmd.calibrate', handler: async () => { await new Promise((r) => setImmediate(r)); done = true; }, }]); const p = reg.dispatch({ topic: 'cmd.calibrate' }, {}, {}); assert.equal(done, false); await p; assert.equal(done, true); }); test('duplicate canonical topic throws at construction', () => { assert.throws(() => createRegistry([ { topic: 'set.mode', handler: () => {} }, { topic: 'set.mode', handler: () => {} }, ]), /duplicate command topic/); }); test('alias collides with another command canonical topic throws', () => { assert.throws(() => createRegistry([ { topic: 'set.mode', handler: () => {} }, { topic: 'cmd.startup', aliases: ['set.mode'], handler: () => {} }, ]), /collides/); }); test('alias collides with another alias throws', () => { assert.throws(() => createRegistry([ { topic: 'set.mode', aliases: ['mode'], handler: () => {} }, { topic: 'cmd.start', aliases: ['mode'], handler: () => {} }, ]), /collides/); }); test('list() returns descriptors without handler functions', () => { const reg = createRegistry([ { topic: 'set.mode', aliases: ['setMode'], payloadSchema: { type: 'string' }, handler: () => {} }, { topic: 'cmd.startup', handler: () => {} }, ]); const list = reg.list(); assert.equal(list.length, 2); assert.deepEqual(list[0], { topic: 'set.mode', aliases: ['setMode'], payloadSchema: { type: 'string' }, description: null, units: null, }); assert.deepEqual(list[1], { topic: 'cmd.startup', aliases: [], payloadSchema: null, description: null, units: null, }); for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor'); }); test("payloadSchema type 'none' invokes handler with no payload and no warning", async () => { const logger = makeLogger(); let invoked = 0; const reg = createRegistry([{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, handler: () => { invoked += 1; }, }], { logger }); await reg.dispatch({ topic: 'cmd.calibrate' }, {}, {}); await reg.dispatch({ topic: 'cmd.calibrate', payload: undefined }, {}, {}); await reg.dispatch({ topic: 'cmd.calibrate', payload: null }, {}, {}); assert.equal(invoked, 3); assert.equal(logger._calls.warn.length, 0); }); test("payloadSchema type 'none' invokes handler with non-empty payload but logs warn", async () => { const logger = makeLogger(); let invoked = 0; const reg = createRegistry([{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, handler: () => { invoked += 1; }, }], { logger }); await reg.dispatch({ topic: 'cmd.calibrate', payload: 'ignored' }, {}, {}); await reg.dispatch({ topic: 'cmd.calibrate', payload: { a: 1 } }, {}, {}); await reg.dispatch({ topic: 'cmd.calibrate', payload: 0 }, {}, {}); assert.equal(invoked, 3); const warns = logger._calls.warn.filter((m) => m.includes('payload ignored')); assert.equal(warns.length, 3); assert.ok(warns[0].includes('cmd.calibrate')); assert.ok(warns[0].includes('trigger-only')); }); test('list() includes description field when present', () => { const reg = createRegistry([ { topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger calibration.', handler: () => {} }, { topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} }, ]); const list = reg.list(); assert.equal(list[0].description, 'Trigger calibration.'); assert.equal(list[1].description, null); }); test('deprecationStats reflects alias hit counts', async () => { const logger = makeLogger(); const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode', 'changemode'], handler: () => {}, }], { logger }); await reg.dispatch({ topic: 'setMode', payload: 'a' }, {}, {}); await reg.dispatch({ topic: 'setMode', payload: 'b' }, {}, {}); await reg.dispatch({ topic: 'changemode', payload: 'c' }, {}, {}); await reg.dispatch({ topic: 'set.mode', payload: 'd' }, {}, {}); assert.deepEqual(reg.deprecationStats(), { setMode: 2, changemode: 1 }); }); test('canonical() resolves alias to canonical topic; passes through canonical', () => { const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]); assert.equal(reg.canonical('setMode'), 'set.mode'); assert.equal(reg.canonical('set.mode'), 'set.mode'); assert.equal(reg.canonical('unknown'), 'unknown'); }); test('has() reports membership for canonical and alias keys', () => { const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]); assert.equal(reg.has('set.mode'), true); assert.equal(reg.has('setMode'), true); assert.equal(reg.has('nope'), false); }); test('CommandRegistry class is exported for advanced cases', () => { const reg = new CommandRegistry([{ topic: 'set.mode', handler: () => {} }]); assert.ok(reg instanceof CommandRegistry); }); test('msg without topic logs warn and does not throw', async () => { const logger = makeLogger(); const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger }); await reg.dispatch({ payload: 'x' }, {}, {}); assert.ok(logger._calls.warn.some((m) => m.includes('no topic'))); }); test('ctx.logger overrides the constructor logger at dispatch time', async () => { const ctorLogger = makeLogger(); const ctxLogger = makeLogger(); const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger: ctorLogger }); await reg.dispatch({ topic: 'unknown' }, {}, { logger: ctxLogger }); assert.equal(ctorLogger._calls.warn.length, 0); assert.ok(ctxLogger._calls.warn.some((m) => m.includes('unknown topic'))); }); test('object schema rejects null payload (typeof null === object guard)', async () => { const logger = makeLogger(); let invoked = false; const reg = createRegistry([{ topic: 'cmd.startup', payloadSchema: { type: 'object' }, handler: () => { invoked = true; }, }], { logger }); await reg.dispatch({ topic: 'cmd.startup', payload: null }, {}, {}); assert.equal(invoked, false); assert.ok(logger._calls.warn.some((m) => m.includes('expected object'))); }); test('constructor throws on missing topic / handler', () => { assert.throws(() => createRegistry([{ handler: () => {} }]), /topic/); assert.throws(() => createRegistry([{ topic: 'set.x' }]), /handler/); }); test('constructor throws when input is not an array', () => { assert.throws(() => createRegistry(null), /array/); assert.throws(() => createRegistry({}), /array/); }); // --------------------------------------------------------------------------- // descriptor.units — Phase 11 pre-dispatch normalisation pipeline // --------------------------------------------------------------------------- test('units: valid unit + correct measure converts to default before handler', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, }], { logger }); await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {}); assert.equal(seen.length, 1); assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6, `expected 3600, got ${seen[0].payload}`); assert.equal(seen[0].unit, 'm3/h'); assert.equal(logger._calls.warn.length, 0); }); test('units: wrong measure warns + lists accepted + falls back to default unit', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, }], { logger }); await reg.dispatch({ topic: 'set.demand', payload: 42, unit: 'mbar' }, {}, {}); assert.equal(seen.length, 1); assert.equal(seen[0].payload, 42); assert.equal(seen[0].unit, 'm3/h'); const warns = logger._calls.warn; assert.equal(warns.length, 1); assert.match(warns[0], /set\.demand/); assert.match(warns[0], /'mbar'/); assert.match(warns[0], /pressure/); assert.match(warns[0], /volumeFlowRate/); assert.match(warns[0], /m3\/h/); // accepted list contains the default assert.match(warns[0], /Treating 42 as m3\/h/); }); test('units: unknown unit warns + lists accepted + falls back to default', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, }], { logger }); await reg.dispatch({ topic: 'set.demand', payload: 7, unit: 'flarbargs' }, {}, {}); assert.equal(seen.length, 1); assert.equal(seen[0].payload, 7); assert.equal(seen[0].unit, 'm3/h'); const warns = logger._calls.warn; assert.equal(warns.length, 1); assert.match(warns[0], /unknown unit 'flarbargs'/); assert.match(warns[0], /m3\/h/); assert.match(warns[0], /Treating 7 as m3\/h/); }); test('units: no unit at all — handler gets raw value tagged with default unit, silent', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, }], { logger }); await reg.dispatch({ topic: 'set.demand', payload: 12 }, {}, {}); assert.equal(seen.length, 1); assert.equal(seen[0].payload, 12); assert.equal(seen[0].unit, 'm3/h'); assert.equal(logger._calls.warn.length, 0); }); test('units: object payload {value, unit} normalises the same as msg.payload+msg.unit', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.pressure', units: { measure: 'pressure', default: 'Pa' }, handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, }], { logger }); await reg.dispatch({ topic: 'set.pressure', payload: { value: 5, unit: 'mbar' } }, {}, {}); assert.equal(seen.length, 1); assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`); assert.equal(seen[0].unit, 'Pa'); assert.equal(logger._calls.warn.length, 0); }); test('units: object payload {value} without unit falls back to default unit silently', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.pressure', units: { measure: 'pressure', default: 'Pa' }, handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, }], { logger }); await reg.dispatch({ topic: 'set.pressure', payload: { value: 100 } }, {}, {}); assert.equal(seen.length, 1); assert.equal(seen[0].payload, 100); assert.equal(seen[0].unit, 'Pa'); assert.equal(logger._calls.warn.length, 0); }); test('units: non-numeric payload (no normalisation applied) passes through to handler', async () => { const logger = makeLogger(); const seen = []; const reg = createRegistry([{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: (_s, msg) => { seen.push(msg.payload); }, }], { logger }); // string payload — not normalisable. Should not crash; handler still fires. await reg.dispatch({ topic: 'set.demand', payload: 'magic' }, {}, {}); assert.equal(seen.length, 1); assert.equal(seen[0], 'magic'); }); test('units: missing default field throws at construction', () => { assert.throws(() => createRegistry([{ topic: 'set.demand', units: { measure: 'volumeFlowRate' }, handler: () => {}, }]), /units requires/); }); test('units: missing measure field throws at construction', () => { assert.throws(() => createRegistry([{ topic: 'set.demand', units: { default: 'm3/h' }, handler: () => {}, }]), /units requires/); }); test('units: descriptor.units surfaces in list() output', () => { const reg = createRegistry([ { topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: () => {} }, { topic: 'set.mode', handler: () => {} }, ]); const list = reg.list(); assert.deepEqual(list[0].units, { measure: 'volumeFlowRate', default: 'm3/h' }); assert.equal(list[1].units, null); });