B3.1 ChildRouter per-listener fan-out (drop emit monkey-patch):
Partial-filter subscriptions enumerate every concrete
<type>.measured.<position> event name (cartesian product over
the canonical POSITIONS list + 19 KNOWN_TYPES) and register a
plain emitter.on() per combo. Multi-parent semantics are trivial:
each ChildRouter's listeners are independent. Drop the wrap/unwrap
bookkeeping in tearDown. ChildRouter.js 184→164 lines.
B3.2 commandRegistry 'none' + description:
Add 'none' to payloadSchema.type — handler still fires; logs warn
if msg.payload is non-empty (catches accidental passes). Add
optional `description` field per descriptor; surfaced via .list()
so wikiGen can render per-topic effect text.
commandRegistry.js 157→164 lines. 23/23 tests pass.
B3.3 UnitPolicy dual-shape:
policy.canonical/output/curve are now BOTH callable methods AND
frozen property bags. policy.canonical('flow') === 'm3/s' and
policy.canonical.flow === 'm3/s' both work. Property bags are
frozen (assign/delete/redefine throw in strict). Drops the
_unitView workaround in MGC + rotatingMachine specificClass.
UnitPolicy.js 149→163 lines, 15/15 tests pass.
CONTRACTS.md §4 + §6 updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
'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,
|
|
});
|
|
assert.deepEqual(list[1], {
|
|
topic: 'cmd.startup',
|
|
aliases: [],
|
|
payloadSchema: null,
|
|
description: 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/);
|
|
});
|