B3.1 + B3.2 + B3.3: ChildRouter fan-out + commandRegistry 'none' + UnitPolicy dual-shape

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>
This commit is contained in:
znetsixe
2026-05-11 17:13:15 +02:00
parent ff9aec8702
commit f11754635b
6 changed files with 255 additions and 88 deletions

View File

@@ -151,15 +151,62 @@ test('list() returns descriptors without handler functions', () => {
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([{