feat(commandRegistry): unify command envelope — origin, unit shorthand, always-convert
Shared command-dispatch layer used by every EVOLV node:
- Always-convert: numeric strings ("60") and {value:"60"} now normalise +
convert like numbers; closes the gap where strings reached handlers raw.
- unit: 'm3/h' shorthand on descriptors; measure is derived from the unit
(legacy units:{measure,default} still accepted, measure re-derived).
Unrecognised declared unit throws at construction.
- msg.origin stamped on every dispatch (parent|GUI|fysical, default parent).
- Opt-in gated:true arbitration: accept only if origin in
source.config.mode.allowedSources[currentMode]; advisory allow-all when a
node has no mode model. Handles Set- or array-valued allowedSources.
+18 registry tests (45 total, green). All consumer nodes verified green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -394,43 +394,163 @@ test('units: object payload {value} without unit falls back to default unit sile
|
||||
assert.equal(logger._calls.warn.length, 0);
|
||||
});
|
||||
|
||||
test('units: non-numeric payload (no normalisation applied) passes through to handler', async () => {
|
||||
test('units: non-NUMERIC string payload (not normalisable) passes through to handler', async () => {
|
||||
const logger = makeLogger();
|
||||
const seen = [];
|
||||
const reg = createRegistry([{
|
||||
topic: 'set.demand',
|
||||
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||
unit: 'm3/h',
|
||||
handler: (_s, msg) => { seen.push(msg.payload); },
|
||||
}], { logger });
|
||||
|
||||
// string payload — not normalisable. Should not crash; handler still fires.
|
||||
// non-numeric string — 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', () => {
|
||||
test('units: NUMERIC string payload is always converted (closes the string gap)', async () => {
|
||||
const logger = makeLogger();
|
||||
const seen = [];
|
||||
const reg = createRegistry([{
|
||||
topic: 'set.demand',
|
||||
unit: 'm3/h',
|
||||
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||
}], { logger });
|
||||
|
||||
// "1" m3/s must convert to 3600 m3/h — same as the numeric-payload case.
|
||||
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: { value: numeric-string } object payload is converted too', async () => {
|
||||
const seen = [];
|
||||
const reg = createRegistry([{
|
||||
topic: 'set.pressure',
|
||||
unit: 'Pa',
|
||||
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||
}]);
|
||||
await reg.dispatch({ topic: 'set.pressure', payload: { value: '5', unit: 'mbar' } }, {}, {});
|
||||
assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`);
|
||||
assert.equal(seen[0].unit, 'Pa');
|
||||
});
|
||||
|
||||
test('unit: shorthand declares the unit and DERIVES the measure', async () => {
|
||||
const seen = [];
|
||||
const reg = createRegistry([{
|
||||
topic: 'set.demand',
|
||||
unit: 'm3/h',
|
||||
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||
}]);
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {});
|
||||
assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6);
|
||||
assert.equal(seen[0].unit, 'm3/h');
|
||||
});
|
||||
|
||||
test('units: { default } without measure derives the measure (measure no longer required)', () => {
|
||||
const reg = createRegistry([{
|
||||
topic: 'set.demand',
|
||||
units: { default: 'm3/h' },
|
||||
handler: () => {},
|
||||
}]);
|
||||
assert.deepEqual(reg.list()[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
|
||||
});
|
||||
|
||||
test('units: legacy { measure, default } still works; measure is re-derived from the unit', () => {
|
||||
const reg = createRegistry([{
|
||||
topic: 'set.demand',
|
||||
units: { measure: 'totallyWrong', default: 'm3/h' },
|
||||
handler: () => {},
|
||||
}]);
|
||||
// declared measure ignored — derived from the unit so it can never drift.
|
||||
assert.deepEqual(reg.list()[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
|
||||
});
|
||||
|
||||
test('units: missing unit/default throws at construction', () => {
|
||||
assert.throws(() => createRegistry([{
|
||||
topic: 'set.demand',
|
||||
units: { measure: 'volumeFlowRate' },
|
||||
handler: () => {},
|
||||
}]), /units requires/);
|
||||
}]), /requires a unit string/);
|
||||
});
|
||||
|
||||
test('units: missing measure field throws at construction', () => {
|
||||
test('units: unrecognised declared unit throws at construction (descriptor bug caught early)', () => {
|
||||
assert.throws(() => createRegistry([{
|
||||
topic: 'set.demand',
|
||||
units: { default: 'm3/h' },
|
||||
unit: 'flarbargs',
|
||||
handler: () => {},
|
||||
}]), /units requires/);
|
||||
}]), /convert does not recognise/);
|
||||
});
|
||||
|
||||
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: () => {} },
|
||||
{ topic: 'set.demand', unit: '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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// origin — command provenance + mode-gated control-authority arbitration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('origin: defaults to parent and is stamped on msg before the handler runs', async () => {
|
||||
const seen = [];
|
||||
const reg = createRegistry([{ topic: 'set.mode', handler: (_s, msg) => { seen.push(msg.origin); } }]);
|
||||
await reg.dispatch({ topic: 'set.mode', payload: 'auto' }, {}, {});
|
||||
assert.equal(seen[0], 'parent');
|
||||
});
|
||||
|
||||
test('origin: explicit msg.origin is preserved and trimmed', async () => {
|
||||
const seen = [];
|
||||
const reg = createRegistry([{ topic: 'set.mode', handler: (_s, msg) => { seen.push(msg.origin); } }]);
|
||||
await reg.dispatch({ topic: 'set.mode', payload: 'auto', origin: ' GUI ' }, {}, {});
|
||||
assert.equal(seen[0], 'GUI');
|
||||
});
|
||||
|
||||
test('origin: defaultOrigin descriptor overrides the global parent default', async () => {
|
||||
const seen = [];
|
||||
const reg = createRegistry([{ topic: 'set.x', defaultOrigin: 'fysical', handler: (_s, msg) => { seen.push(msg.origin); } }]);
|
||||
await reg.dispatch({ topic: 'set.x' }, {}, {});
|
||||
assert.equal(seen[0], 'fysical');
|
||||
});
|
||||
|
||||
test('origin gating: non-gated command is never blocked, even with a mode model', async () => {
|
||||
let invoked = false;
|
||||
const reg = createRegistry([{ topic: 'set.x', handler: () => { invoked = true; } }]);
|
||||
const source = { currentMode: 'fysicalControl', config: { mode: { allowedSources: { fysicalControl: ['fysical'] } } } };
|
||||
await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, source, {});
|
||||
assert.equal(invoked, true);
|
||||
});
|
||||
|
||||
test('origin gating: gated command rejects an origin disallowed by the current mode', async () => {
|
||||
const logger = makeLogger();
|
||||
let invoked = false;
|
||||
const reg = createRegistry([{ topic: 'set.x', gated: true, handler: () => { invoked = true; } }], { logger });
|
||||
const source = { currentMode: 'fysicalControl', config: { mode: { allowedSources: { fysicalControl: ['fysical'] } } } };
|
||||
await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, source, {});
|
||||
assert.equal(invoked, false);
|
||||
assert.ok(logger._calls.warn.some((m) => m.includes("origin 'GUI' not allowed in mode 'fysicalControl'")));
|
||||
});
|
||||
|
||||
test('origin gating: gated command accepts an allowed origin (Set or array allowedSources)', async () => {
|
||||
let count = 0;
|
||||
const reg = createRegistry([{ topic: 'set.x', gated: true, handler: () => { count += 1; } }]);
|
||||
const arraySrc = { currentMode: 'auto', config: { mode: { allowedSources: { auto: ['parent', 'GUI', 'fysical'] } } } };
|
||||
const setSrc = { currentMode: 'auto', config: { mode: { allowedSources: { auto: new Set(['parent', 'GUI', 'fysical']) } } } };
|
||||
await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, arraySrc, {});
|
||||
await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, setSrc, {});
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
test('origin gating: gated command on a node WITHOUT a mode model is advisory (allow-all)', async () => {
|
||||
let invoked = false;
|
||||
const reg = createRegistry([{ topic: 'set.x', gated: true, handler: () => { invoked = true; } }]);
|
||||
await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, { id: 'no-mode' }, {});
|
||||
assert.equal(invoked, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user