Phase 1 wave 2: BaseDomain + commandRegistry + statusUpdater

- src/domain/BaseDomain.js     — base class for every specificClass; wires emitter/config/logger/measurements/childRouter
- src/nodered/commandRegistry.js — declarative msg.topic dispatch with alias deprecation
- src/nodered/statusUpdater.js — 1Hz status badge poller with error-resilient loop

Additive. 43 new tests; all 99 basic tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-10 18:31:50 +02:00
parent 47faf94048
commit 57b77f905a
6 changed files with 1004 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('events');
const BaseDomain = require('../../src/domain/BaseDomain');
const UnitPolicy = require('../../src/domain/UnitPolicy');
// ── Subclasses ────────────────────────────────────────────────────────
// Minimal subclass — relies on every base default. Uses 'measurement' so the
// configManager finds a real config schema in src/configs/measurement.json.
class PlainMeasurement extends BaseDomain {
static name = 'measurement';
}
// Subclass that records call ordering and exposes hooks.
class TrackingMeasurement extends BaseDomain {
static name = 'measurement';
configure() {
this.calls = this.calls || [];
// Pin the moment at which `configure` runs — these MUST be populated
// before the hook fires.
this.calls.push({
hook: 'configure',
hasConfig: !!this.config,
hasMeasurements: !!this.measurements,
});
}
_init() {
this.calls = this.calls || [];
this.calls.push({ hook: '_init' });
}
}
// Subclass with a UnitPolicy — verify containerOptions reach MeasurementContainer.
class PolicyMeasurement extends BaseDomain {
static name = 'measurement';
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa' },
output: { flow: 'L/s', pressure: 'kPa' },
});
}
// Subclass that declares a child getter in `configure`.
class ParentDomain extends BaseDomain {
static name = 'measurement';
configure() {
this.declareChildGetter('machines', 'machine');
}
}
// ── Helpers ──────────────────────────────────────────────────────────
function makeChild({ id = 'c1', name = id, softwareType = 'machine', category = 'centrifugal' } = {}) {
return {
config: {
general: { id, name },
functionality: { softwareType },
asset: { category, type: 'pump' },
},
measurements: {
emitter: new EventEmitter(),
setChildId() {}, setChildName() {}, setParentRef() {},
},
};
}
// ── Tests ────────────────────────────────────────────────────────────
test('constructs successfully against a real config schema', () => {
const m = new PlainMeasurement({});
assert.ok(m.config?.general?.name);
assert.ok(m.measurements);
assert.ok(m.logger);
assert.ok(m.emitter);
assert.ok(m.childRegistrationUtils);
assert.ok(m.router);
});
test('configure() runs after config + measurements are populated, exactly once', () => {
const m = new TrackingMeasurement({});
const configureCalls = m.calls.filter(c => c.hook === 'configure');
assert.equal(configureCalls.length, 1);
assert.equal(configureCalls[0].hasConfig, true);
assert.equal(configureCalls[0].hasMeasurements, true);
});
test('_init() runs after configure()', () => {
const m = new TrackingMeasurement({});
const order = m.calls.map(c => c.hook);
assert.deepEqual(order, ['configure', '_init']);
});
test('static unitPolicy is honored — defaultUnits reflect output map', () => {
const m = new PolicyMeasurement({});
// PolicyMeasurement declares output.flow='L/s', output.pressure='kPa'
assert.equal(m.measurements.defaultUnits.flow, 'L/s');
assert.equal(m.measurements.defaultUnits.pressure, 'kPa');
// Canonical flow was declared as 'm3/s'
assert.equal(m.measurements.canonicalUnits.flow, 'm3/s');
});
test('without unitPolicy, MeasurementContainer keeps its built-in defaults', () => {
const m = new PlainMeasurement({});
assert.equal(m.unitPolicy, null);
// Built-in defaults from MeasurementContainer.
assert.equal(m.measurements.defaultUnits.flow, 'm3/h');
assert.equal(m.measurements.defaultUnits.pressure, 'mbar');
assert.equal(m.measurements.autoConvert, true);
});
test('declareChildGetter flattens registry slice across categories', () => {
const p = new ParentDomain({});
// Empty before any registration.
assert.deepEqual(p.machines, {});
// Mirror what childRegistrationUtils._storeChild does: child.machine.<cat>=[...]
const a = makeChild({ id: 'pumpA', category: 'centrifugal' });
const b = makeChild({ id: 'pumpB', category: 'positivedisplacement' });
p.child = { machine: { centrifugal: [a], positivedisplacement: [b] } };
const flat = p.machines;
assert.deepEqual(Object.keys(flat).sort(), ['pumpA', 'pumpB']);
assert.equal(flat.pumpA, a);
assert.equal(flat.pumpB, b);
});
test('notifyOutputChanged fires "output-changed" on emitter', () => {
const m = new PlainMeasurement({});
let count = 0;
m.emitter.on('output-changed', () => count++);
m.notifyOutputChanged();
m.notifyOutputChanged();
assert.equal(count, 2);
});
test('context() returns a frozen object with the documented keys', () => {
const m = new PlainMeasurement({});
const ctx = m.context();
assert.ok(Object.isFrozen(ctx));
for (const k of ['config', 'logger', 'measurements', 'emitter', 'child', 'unitPolicy', 'router']) {
assert.ok(k in ctx, `context() missing key '${k}'`);
}
assert.equal(ctx.config, m.config);
assert.equal(ctx.measurements, m.measurements);
});
test('close() removes emitter listeners and tears down router', () => {
const m = new PlainMeasurement({});
let teardownCount = 0;
const origTeardown = m.router.tearDown.bind(m.router);
m.router.tearDown = () => { teardownCount++; origTeardown(); };
m.emitter.on('output-changed', () => {});
assert.equal(m.emitter.listenerCount('output-changed'), 1);
m.close();
assert.equal(teardownCount, 1);
assert.equal(m.emitter.listenerCount('output-changed'), 0);
});
test('registerChild delegates to router.dispatchRegister', () => {
const m = new PlainMeasurement({});
const seen = [];
const origDispatch = m.router.dispatchRegister.bind(m.router);
m.router.dispatchRegister = (child, st) => {
seen.push({ id: child.config.general.id, st });
return origDispatch(child, st);
};
const child = makeChild({ id: 'kid1', softwareType: 'measurement' });
const result = m.registerChild(child, 'measurement');
assert.equal(result, true);
assert.deepEqual(seen, [{ id: 'kid1', st: 'measurement' }]);
});
test('childRegistrationUtils.registerChild flows through router (end-to-end handshake)', async () => {
const m = new PlainMeasurement({});
let routed = null;
m.router.onRegister('measurement', (child, st) => {
routed = { id: child.config.general.id, st };
});
const child = makeChild({ id: 'kid2', softwareType: 'measurement' });
await m.childRegistrationUtils.registerChild(child, 'upstream', 0);
assert.deepEqual(routed, { id: 'kid2', st: 'measurement' });
});
test('direct BaseDomain instantiation throws (abstract)', () => {
assert.throws(() => new BaseDomain({}), /abstract/);
});

View File

@@ -0,0 +1,235 @@
'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' },
});
assert.deepEqual(list[1], {
topic: 'cmd.startup',
aliases: [],
payloadSchema: null,
});
for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor');
});
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/);
});

View File

@@ -0,0 +1,189 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { StatusUpdater } = require('../../src/nodered/statusUpdater');
function makeNode() {
const calls = [];
return {
calls,
status(badge) { calls.push(badge); },
};
}
function makeSource(initial) {
return {
badge: initial,
throwOnNext: false,
getStatusBadge() {
if (this.throwOnNext) {
this.throwOnNext = false;
throw new Error('boom');
}
return this.badge;
},
};
}
function makeLogger() {
const errors = [];
return {
errors,
error(msg) { errors.push(msg); },
};
}
test('start() schedules a tick that applies the source badge', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
u.start();
assert.equal(node.calls.length, 0);
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 1);
assert.deepEqual(node.calls[0], { fill: 'green', shape: 'dot', text: 'OK' });
u.stop();
});
test('multiple ticks reflect the latest badge from the source', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'A' });
const u = new StatusUpdater({ node, source, intervalMs: 500 });
u.start();
t.mock.timers.tick(500);
source.badge = { fill: 'yellow', shape: 'dot', text: 'B' };
t.mock.timers.tick(500);
source.badge = { fill: 'red', shape: 'ring', text: 'C' };
t.mock.timers.tick(500);
assert.equal(node.calls.length, 3);
assert.equal(node.calls[0].text, 'A');
assert.equal(node.calls[1].text, 'B');
assert.equal(node.calls[2].text, 'C');
u.stop();
});
test('source returns null → node.status({}) is called', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource(null);
const u = new StatusUpdater({ node, source, intervalMs: 100 });
u.start();
t.mock.timers.tick(100);
assert.equal(node.calls.length, 1);
assert.deepEqual(node.calls[0], {});
u.stop();
});
test('source throw → error logged, error badge applied, next tick still runs', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const logger = makeLogger();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
source.throwOnNext = true;
const u = new StatusUpdater({ node, source, intervalMs: 1000, logger });
u.start();
t.mock.timers.tick(1000);
assert.equal(logger.errors.length, 1, 'error logged once');
assert.match(logger.errors[0], /boom/);
assert.deepEqual(node.calls[0], { fill: 'red', shape: 'ring', text: '⚠ boom' });
// Subsequent tick: source recovers, normal badge resumes.
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 2);
assert.deepEqual(node.calls[1], { fill: 'green', shape: 'dot', text: 'OK' });
u.stop();
});
test('stop() halts the interval AND clears the badge', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 500 });
u.start();
t.mock.timers.tick(500);
assert.equal(node.calls.length, 1);
u.stop();
assert.equal(u.isRunning, false);
// stop() pushes a clear-badge call.
assert.equal(node.calls.length, 2);
assert.deepEqual(node.calls[1], {});
// No further ticks after stop.
t.mock.timers.tick(5000);
assert.equal(node.calls.length, 2);
});
test('start() called twice does not schedule two intervals', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
u.start();
u.start();
u.start();
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 1, 'one tick per interval period');
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 2);
u.stop();
});
test('intervalMs: 0 makes start() a no-op', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 0 });
u.start();
assert.equal(u.isRunning, false);
t.mock.timers.tick(10000);
assert.equal(node.calls.length, 0);
});
test('intervalMs omitted is also treated as a no-op', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source });
u.start();
assert.equal(u.isRunning, false);
t.mock.timers.tick(10000);
assert.equal(node.calls.length, 0);
});
test('constructor throws if node.status is missing', () => {
const source = makeSource(null);
assert.throws(
() => new StatusUpdater({ node: {}, source, intervalMs: 1000 }),
/node must expose a \.status/,
);
assert.throws(
() => new StatusUpdater({ node: null, source, intervalMs: 1000 }),
/node must expose a \.status/,
);
});
test('constructor throws if source.getStatusBadge is missing', () => {
const node = makeNode();
assert.throws(
() => new StatusUpdater({ node, source: {}, intervalMs: 1000 }),
/source must expose a \.getStatusBadge/,
);
assert.throws(
() => new StatusUpdater({ node, source: null, intervalMs: 1000 }),
/source must expose a \.getStatusBadge/,
);
});
test('isRunning getter reflects timer lifecycle', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource(null);
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
assert.equal(u.isRunning, false);
u.start();
assert.equal(u.isRunning, true);
u.stop();
assert.equal(u.isRunning, false);
});