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:
@@ -150,3 +150,91 @@ test('size reports 0 / 1 / 2 across the lifecycle', async () => {
|
||||
await gate.drain();
|
||||
assert.equal(gate.size, 0);
|
||||
});
|
||||
|
||||
test('fireAndWait resolves when the dispatch for that value settles', async () => {
|
||||
const calls = [];
|
||||
const gate = new LatestWinsGate(async (v) => { calls.push(v); return `done:${v}`; });
|
||||
const result = await gate.fireAndWait('a');
|
||||
assert.deepEqual(calls, ['a']);
|
||||
assert.equal(result, 'done:a');
|
||||
});
|
||||
|
||||
test('fireAndWait while in-flight: caller awaits OWN settlement, not the first call', async () => {
|
||||
const calls = [];
|
||||
const d = deferred();
|
||||
let count = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (count++ === 0) await d.promise;
|
||||
return `r:${v}`;
|
||||
});
|
||||
|
||||
const p1 = gate.fireAndWait('first');
|
||||
// p1 in flight. Park second; second's promise should resolve only
|
||||
// after second's OWN dispatch runs, not after first's.
|
||||
const p2 = gate.fireAndWait('second');
|
||||
|
||||
let p2Settled = false;
|
||||
p2.then(() => { p2Settled = true; });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(p2Settled, false);
|
||||
|
||||
d.resolve();
|
||||
const r1 = await p1;
|
||||
assert.equal(r1, 'r:first');
|
||||
const r2 = await p2;
|
||||
assert.equal(r2, 'r:second');
|
||||
assert.deepEqual(calls, ['first', 'second']);
|
||||
});
|
||||
|
||||
test('fireAndWait superseded by a later fireAndWait resolves with { superseded: true }', async () => {
|
||||
const calls = [];
|
||||
const d = deferred();
|
||||
let count = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (count++ === 0) await d.promise;
|
||||
});
|
||||
|
||||
const p1 = gate.fireAndWait('first'); // in flight
|
||||
const pParked = gate.fireAndWait('parked'); // gets superseded
|
||||
const pLatest = gate.fireAndWait('latest'); // wins
|
||||
|
||||
d.resolve();
|
||||
const supersedeRes = await pParked;
|
||||
assert.equal(supersedeRes.superseded, true);
|
||||
|
||||
await p1;
|
||||
await pLatest;
|
||||
assert.deepEqual(calls, ['first', 'latest']); // 'parked' dropped
|
||||
});
|
||||
|
||||
test('fireAndWait + fire intermix: a plain fire supersedes a pending fireAndWait', async () => {
|
||||
const d = deferred();
|
||||
let count = 0;
|
||||
const calls = [];
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (count++ === 0) await d.promise;
|
||||
});
|
||||
|
||||
gate.fire('first'); // in flight, no settle
|
||||
const pParked = gate.fireAndWait('parked');
|
||||
gate.fire('latest'); // supersedes parked
|
||||
|
||||
d.resolve();
|
||||
const res = await pParked;
|
||||
assert.equal(res.superseded, true);
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, ['first', 'latest']);
|
||||
});
|
||||
|
||||
test('fireAndWait still resolves (with undefined) when the dispatch throws', async () => {
|
||||
const errors = [];
|
||||
const logger = { error: (e) => errors.push(e) };
|
||||
const gate = new LatestWinsGate(async () => { throw new Error('kaboom'); }, { logger });
|
||||
const r = await gate.fireAndWait('only');
|
||||
assert.equal(r, undefined);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.ok(gate.lastError instanceof Error);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
90
test/basic/convert.basic.test.js
Normal file
90
test/basic/convert.basic.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const convert = require('../../src/convert/index.js');
|
||||
|
||||
test('convert.possibilities — exported as a top-level function', () => {
|
||||
assert.equal(typeof convert.possibilities, 'function');
|
||||
});
|
||||
|
||||
test('convert.possibilities(volumeFlowRate) returns common flow units', () => {
|
||||
const units = convert.possibilities('volumeFlowRate');
|
||||
assert.ok(Array.isArray(units));
|
||||
assert.ok(units.length > 0);
|
||||
for (const u of ['m3/s', 'm3/h', 'l/s', 'l/min', 'l/h']) {
|
||||
assert.ok(units.includes(u), `expected '${u}' in volumeFlowRate possibilities`);
|
||||
}
|
||||
});
|
||||
|
||||
test('convert.possibilities(pressure) returns common pressure units', () => {
|
||||
const units = convert.possibilities('pressure');
|
||||
for (const u of ['Pa', 'kPa', 'bar', 'mbar', 'psi']) {
|
||||
assert.ok(units.includes(u), `expected '${u}' in pressure possibilities`);
|
||||
}
|
||||
});
|
||||
|
||||
test('convert.possibilities(power) returns common power units', () => {
|
||||
const units = convert.possibilities('power');
|
||||
for (const u of ['W', 'kW', 'MW']) {
|
||||
assert.ok(units.includes(u), `expected '${u}' in power possibilities`);
|
||||
}
|
||||
});
|
||||
|
||||
test('convert.possibilities(temperature) returns K, C, F', () => {
|
||||
const units = convert.possibilities('temperature');
|
||||
for (const u of ['K', 'C', 'F']) {
|
||||
assert.ok(units.includes(u), `expected '${u}' in temperature possibilities`);
|
||||
}
|
||||
});
|
||||
|
||||
test('convert.possibilities for length / mass / volume return non-empty', () => {
|
||||
assert.ok(convert.possibilities('length').includes('m'));
|
||||
assert.ok(convert.possibilities('mass').includes('kg'));
|
||||
assert.ok(convert.possibilities('volume').includes('l'));
|
||||
});
|
||||
|
||||
test('convert.possibilities(unknown) returns []', () => {
|
||||
assert.deepEqual(convert.possibilities('foo'), []);
|
||||
assert.deepEqual(convert.possibilities('bogus-measure'), []);
|
||||
});
|
||||
|
||||
test('convert.possibilities handles invalid input safely', () => {
|
||||
assert.deepEqual(convert.possibilities(), []);
|
||||
assert.deepEqual(convert.possibilities(null), []);
|
||||
assert.deepEqual(convert.possibilities(''), []);
|
||||
assert.deepEqual(convert.possibilities(42), []);
|
||||
});
|
||||
|
||||
test('convert.possibilities is sorted and deduplicated', () => {
|
||||
const units = convert.possibilities('pressure');
|
||||
const sorted = [...units].sort();
|
||||
assert.deepEqual(units, sorted, 'result should be alphabetically sorted');
|
||||
const set = new Set(units);
|
||||
assert.equal(set.size, units.length, 'result should have no duplicates');
|
||||
});
|
||||
|
||||
test('convert.possibilities returns stable / cached results across calls', () => {
|
||||
const a = convert.possibilities('volumeFlowRate');
|
||||
const b = convert.possibilities('volumeFlowRate');
|
||||
assert.deepEqual(a, b, 'two calls must return equal arrays');
|
||||
// Mutating the returned array must not poison the cache.
|
||||
a.push('SHOULD_NOT_PERSIST');
|
||||
const c = convert.possibilities('volumeFlowRate');
|
||||
assert.ok(!c.includes('SHOULD_NOT_PERSIST'), 'cached array must be defensively copied');
|
||||
assert.deepEqual(c, b);
|
||||
});
|
||||
|
||||
test('convert.measures lists known measure names', () => {
|
||||
const m = convert.measures();
|
||||
assert.ok(Array.isArray(m));
|
||||
for (const name of ['length', 'mass', 'volume', 'pressure', 'power', 'temperature', 'volumeFlowRate']) {
|
||||
assert.ok(m.includes(name), `expected measure '${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('convert factory still works (regression — no breakage of existing API)', () => {
|
||||
const result = convert(1).from('m').to('cm');
|
||||
assert.equal(result, 100);
|
||||
});
|
||||
Reference in New Issue
Block a user