B2.3 + P11.1 + P11.2 + monster schema fix

B2.3 LatestWinsGate fireAndWait:
  Added fireAndWait(value, ctx?) returning per-fire settlement promise.
  Supersede resolves with frozen sentinel {superseded: true} (no
  rejection — callers branch on value without try/catch). Dispatch
  errors also resolve (with undefined); error surfaces via gate.lastError.
  LatestWinsGate.js 75 → 116 lines. 12/12 tests pass.

P11.1 convert.possibilities(measure):
  New helper returning sorted+deduped unit names for a measure.
  Cached per measure. Reuses existing convert measures map. Also
  exposed convert.measures() listing all known measures.
  convert/index.js +21 lines. New test file: 90 lines, 12/12 tests.

P11.2 commandRegistry.units field:
  Pre-dispatch normalisation pipeline. descriptor.units = {measure,
  default}; commandRegistry extracts msg.payload + msg.unit (3 shapes),
  validates against measure, converts to default, falls back + warns
  with accepted-list on unknown/wrong-measure. Falls back gracefully
  if convert.possibilities is missing. commandRegistry.js 164 → 237.
  +7 new tests covering all 4 paths.

monster schema fix (P11.2 sibling):
  generalFunctions/src/configs/monster.json was stripping four
  legitimate constraint keys (nominalFlowMin, flowMax, maxRainRef,
  minSampleIntervalSec). Added them with defaults matching the
  legacy nodeClass coercion. Side effect: this also UNBLOCKED the
  monster cooldown-guard test (separate ROOT-CAUSE entry below).

CONTRACTS.md §4 + §8 updated. 144/144 basic tests + 206/206 full
generalFunctions tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-11 17:29:14 +02:00
parent f11754635b
commit 5ea968eabc
8 changed files with 522 additions and 15 deletions

View File

@@ -152,12 +152,14 @@ test('list() returns descriptors without handler functions', () => {
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');
});
@@ -280,3 +282,155 @@ 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);
});