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:
139
src/domain/BaseDomain.js
Normal file
139
src/domain/BaseDomain.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* BaseDomain — shared specificClass scaffolding.
|
||||
*
|
||||
* Consolidates the constructor boilerplate that every domain (pumpingStation,
|
||||
* measurement, MGC, rotatingMachine, …) repeats today: configManager →
|
||||
* configUtils → logger → MeasurementContainer → childRegistrationUtils →
|
||||
* ChildRouter. Subclasses declare `static name` (matches the JSON config in
|
||||
* generalFunctions/src/configs/<name>.json) and optionally `static unitPolicy`
|
||||
* (a UnitPolicy.declare(...) instance), then implement `configure()` to wire
|
||||
* concern-modules.
|
||||
*
|
||||
* See CONTRACTS.md §3.
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const configManager = require('../configs/index.js');
|
||||
const configUtils = require('../helper/configUtils.js');
|
||||
const Logger = require('../helper/logger.js');
|
||||
const childRegistrationUtils = require('../helper/childRegistrationUtils.js');
|
||||
const { MeasurementContainer } = require('../measurements/index.js');
|
||||
const ChildRouter = require('./ChildRouter.js');
|
||||
|
||||
class BaseDomain {
|
||||
constructor(userConfig = {}) {
|
||||
const ctor = this.constructor;
|
||||
if (ctor === BaseDomain) {
|
||||
throw new Error('BaseDomain is abstract; subclass it and declare static name');
|
||||
}
|
||||
|
||||
this.emitter = new EventEmitter();
|
||||
|
||||
this.configManager = new configManager();
|
||||
this.defaultConfig = this.configManager.getConfig(ctor.name);
|
||||
this.configUtils = new configUtils(this.defaultConfig);
|
||||
this.config = this.configUtils.initConfig(userConfig);
|
||||
|
||||
const loggingCfg = this.config?.general?.logging || {};
|
||||
this.logger = new Logger(
|
||||
loggingCfg.enabled,
|
||||
loggingCfg.logLevel,
|
||||
this.config?.general?.name
|
||||
);
|
||||
|
||||
// Read static unitPolicy via the constructor — `this.constructor`
|
||||
// resolves to the leaf subclass even when this base ctor is the caller.
|
||||
this.unitPolicy = ctor.unitPolicy ?? null;
|
||||
if (this.unitPolicy && typeof this.unitPolicy.setLogger === 'function') {
|
||||
this.unitPolicy.setLogger(this.logger);
|
||||
}
|
||||
|
||||
const containerOptions = this.unitPolicy?.containerOptions
|
||||
? this.unitPolicy.containerOptions()
|
||||
: { autoConvert: true };
|
||||
this.measurements = new MeasurementContainer(containerOptions, this.logger);
|
||||
if (this.config?.general?.id) this.measurements.setChildId(this.config.general.id);
|
||||
if (this.config?.general?.name) this.measurements.setChildName(this.config.general.name);
|
||||
|
||||
this.childRegistrationUtils = new childRegistrationUtils(this);
|
||||
this.router = new ChildRouter(this);
|
||||
|
||||
// childRegistrationUtils calls back into mainClass.registerChild after
|
||||
// storing the child. Routing through `this.router` keeps subclasses free
|
||||
// of register-switch boilerplate while preserving the existing handshake.
|
||||
this.registerChild = (child, softwareType) => {
|
||||
this.router.dispatchRegister(child, softwareType);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (typeof this.configure === 'function') this.configure();
|
||||
if (typeof this._init === 'function') this._init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a read-only getter that flattens `this.child[softwareType]`
|
||||
* (across all categories, or filtered by `category`) into a single
|
||||
* id-keyed object. Lets subclasses expose readable accessors like
|
||||
* `this.machines` while the registry remains the source of truth.
|
||||
*/
|
||||
declareChildGetter(name, softwareType, category) {
|
||||
const key = String(softwareType || '').toLowerCase();
|
||||
Object.defineProperty(this, name, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
const slice = this.child?.[key];
|
||||
if (!slice) return {};
|
||||
const cats = category ? [slice[category] || []] : Object.values(slice);
|
||||
const out = {};
|
||||
for (const list of cats) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const c of list) {
|
||||
const id = c?.config?.general?.id || c?.config?.general?.name;
|
||||
if (id != null) out[id] = c;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen view passed to concern-modules so they don't reach into `this`.
|
||||
* Subclasses may override to add domain-specific keys.
|
||||
*/
|
||||
context() {
|
||||
return Object.freeze({
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
measurements: this.measurements,
|
||||
emitter: this.emitter,
|
||||
child: this.child,
|
||||
unitPolicy: this.unitPolicy,
|
||||
router: this.router,
|
||||
});
|
||||
}
|
||||
|
||||
/** Default output shape — subclasses extend with concern-module snapshots. */
|
||||
getOutput() {
|
||||
return this.measurements.getFlattenedOutput?.() || {};
|
||||
}
|
||||
|
||||
/** Subclasses MUST override. Grey placeholder so adapters never crash. */
|
||||
getStatusBadge() {
|
||||
return { fill: 'grey', shape: 'ring', text: 'no status' };
|
||||
}
|
||||
|
||||
/** Convenience for event-driven nodes — see CONTRACTS.md §3. */
|
||||
notifyOutputChanged() {
|
||||
this.emitter.emit('output-changed');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.router?.tearDown();
|
||||
this.emitter.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseDomain;
|
||||
156
src/nodered/commandRegistry.js
Normal file
156
src/nodered/commandRegistry.js
Normal file
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
// Declarative dispatch for a node's input topics. Each node declares its
|
||||
// commands as an array of descriptors; the registry builds an O(1) lookup
|
||||
// keyed by canonical topic + alias, validates the payload against a small
|
||||
// shape schema, and invokes the handler. Replaces the per-node ~100-line
|
||||
// `switch (msg.topic)` block in nodeClass._attachInputHandler.
|
||||
//
|
||||
// Lightweight on purpose: the schema is a typeof-check ladder, not full
|
||||
// JSON-Schema. Anything richer belongs in the handler itself, which has
|
||||
// access to logger via ctx.
|
||||
|
||||
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any']);
|
||||
|
||||
class CommandRegistry {
|
||||
constructor(commands, options = {}) {
|
||||
if (!Array.isArray(commands)) {
|
||||
throw new TypeError('CommandRegistry requires an array of command descriptors');
|
||||
}
|
||||
this._logger = options.logger || null;
|
||||
this._byKey = new Map(); // topic-or-alias -> descriptor
|
||||
this._canonicalByAlias = new Map();
|
||||
this._descriptors = [];
|
||||
this._deprecationCounts = new Map();
|
||||
this._deprecationLogged = new Set();
|
||||
for (const cmd of commands) this._register(cmd);
|
||||
}
|
||||
|
||||
_register(cmd) {
|
||||
if (!cmd || typeof cmd.topic !== 'string' || cmd.topic.length === 0) {
|
||||
throw new TypeError('command descriptor requires a non-empty string topic');
|
||||
}
|
||||
if (typeof cmd.handler !== 'function') {
|
||||
throw new TypeError(`command '${cmd.topic}' requires a handler function`);
|
||||
}
|
||||
if (this._byKey.has(cmd.topic)) {
|
||||
throw new Error(`duplicate command topic '${cmd.topic}'`);
|
||||
}
|
||||
const aliases = Array.isArray(cmd.aliases) ? cmd.aliases.slice() : [];
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias !== 'string' || alias.length === 0) {
|
||||
throw new TypeError(`command '${cmd.topic}' has an invalid alias`);
|
||||
}
|
||||
if (this._byKey.has(alias)) {
|
||||
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
|
||||
}
|
||||
}
|
||||
const descriptor = {
|
||||
topic: cmd.topic,
|
||||
aliases,
|
||||
payloadSchema: cmd.payloadSchema || null,
|
||||
handler: cmd.handler,
|
||||
};
|
||||
this._byKey.set(cmd.topic, descriptor);
|
||||
for (const alias of aliases) {
|
||||
this._byKey.set(alias, descriptor);
|
||||
this._canonicalByAlias.set(alias, cmd.topic);
|
||||
}
|
||||
this._descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
has(topic) {
|
||||
return typeof topic === 'string' && this._byKey.has(topic);
|
||||
}
|
||||
|
||||
canonical(topic) {
|
||||
if (typeof topic !== 'string') return topic;
|
||||
return this._canonicalByAlias.get(topic) || topic;
|
||||
}
|
||||
|
||||
list() {
|
||||
// Strip handler so callers can safely log / serialise the result
|
||||
// (handler functions are noisy and not contract-relevant).
|
||||
return this._descriptors.map((d) => ({
|
||||
topic: d.topic,
|
||||
aliases: d.aliases.slice(),
|
||||
payloadSchema: d.payloadSchema,
|
||||
}));
|
||||
}
|
||||
|
||||
deprecationStats() {
|
||||
const out = {};
|
||||
for (const [alias, count] of this._deprecationCounts) out[alias] = count;
|
||||
return out;
|
||||
}
|
||||
|
||||
async dispatch(msg, source, ctx) {
|
||||
const log = this._loggerFor(ctx);
|
||||
const topic = msg && typeof msg.topic === 'string' ? msg.topic : null;
|
||||
if (!topic) {
|
||||
log.warn?.('commandRegistry: msg has no topic; ignoring');
|
||||
return;
|
||||
}
|
||||
const descriptor = this._byKey.get(topic);
|
||||
if (!descriptor) {
|
||||
log.warn?.(`commandRegistry: unknown topic '${topic}'`);
|
||||
return;
|
||||
}
|
||||
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
|
||||
if (!this._validatePayload(descriptor, msg, log)) return;
|
||||
return descriptor.handler(source, msg, ctx);
|
||||
}
|
||||
|
||||
_noteAlias(alias, canonical, log) {
|
||||
const prev = this._deprecationCounts.get(alias) || 0;
|
||||
this._deprecationCounts.set(alias, prev + 1);
|
||||
if (this._deprecationLogged.has(alias)) return;
|
||||
this._deprecationLogged.add(alias);
|
||||
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
|
||||
}
|
||||
|
||||
_validatePayload(descriptor, msg, log) {
|
||||
const schema = descriptor.payloadSchema;
|
||||
if (!schema) return true;
|
||||
const payload = msg.payload;
|
||||
const type = schema.type || 'any';
|
||||
if (!SCALAR_TYPES.has(type)) {
|
||||
log.warn?.(`commandRegistry: command '${descriptor.topic}' has unknown schema type '${type}'`);
|
||||
return true;
|
||||
}
|
||||
if (type === 'any') return true;
|
||||
// typeof null === 'object' — explicit null fails an object schema.
|
||||
if (type === 'object') {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
log.warn?.(`commandRegistry: '${descriptor.topic}' expected object payload, got ${payload === null ? 'null' : typeof payload}`);
|
||||
return false;
|
||||
}
|
||||
} else if (typeof payload !== type) {
|
||||
log.warn?.(`commandRegistry: '${descriptor.topic}' expected ${type} payload, got ${typeof payload}`);
|
||||
return false;
|
||||
}
|
||||
if (type === 'object' && schema.properties && typeof schema.properties === 'object') {
|
||||
for (const [key, expected] of Object.entries(schema.properties)) {
|
||||
if (!(key in payload)) continue; // missing keys allowed
|
||||
if (typeof payload[key] !== expected) {
|
||||
log.warn?.(`commandRegistry: '${descriptor.topic}' payload.${key} expected ${expected}, got ${typeof payload[key]}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_loggerFor(ctx) {
|
||||
const candidate = (ctx && ctx.logger) || this._logger;
|
||||
return candidate || NOOP_LOGGER;
|
||||
}
|
||||
}
|
||||
|
||||
const NOOP_LOGGER = { warn() {}, error() {}, info() {}, debug() {} };
|
||||
|
||||
function createRegistry(commands, options) {
|
||||
return new CommandRegistry(commands, options);
|
||||
}
|
||||
|
||||
module.exports = { createRegistry, CommandRegistry };
|
||||
90
src/nodered/statusUpdater.js
Normal file
90
src/nodered/statusUpdater.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* StatusUpdater — periodic Node-RED status badge poller.
|
||||
*
|
||||
* Replaces the per-node `_statusInterval` boilerplate (e.g. pumpingStation
|
||||
* nodeClass lines 160-171) with one class. The adapter constructs it once
|
||||
* with a `node` (Node-RED handle) and a `source` (the domain), and the
|
||||
* loop drives `node.status(source.getStatusBadge())` at a fixed cadence.
|
||||
*
|
||||
* Errors thrown from the domain become a red error badge instead of
|
||||
* crashing the interval — operators see the failure in the editor.
|
||||
*
|
||||
* See CONTRACTS.md §7 for the badge shape; statusBadge.js for the helpers.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { statusBadge } = require('./statusBadge');
|
||||
|
||||
const CLEAR_BADGE = {};
|
||||
|
||||
class StatusUpdater {
|
||||
constructor({ node, source, intervalMs, logger } = {}) {
|
||||
if (!node || typeof node.status !== 'function') {
|
||||
throw new Error('StatusUpdater: node must expose a .status(badge) method');
|
||||
}
|
||||
if (!source || typeof source.getStatusBadge !== 'function') {
|
||||
throw new Error('StatusUpdater: source must expose a .getStatusBadge() method');
|
||||
}
|
||||
this._node = node;
|
||||
this._source = source;
|
||||
this._intervalMs = Number.isFinite(intervalMs) ? intervalMs : 0;
|
||||
this._logger = logger || null;
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
get isRunning() {
|
||||
return this._timer !== null;
|
||||
}
|
||||
|
||||
start() {
|
||||
// intervalMs=0 keeps unit tests / headless harnesses silent.
|
||||
if (this._intervalMs <= 0) return;
|
||||
if (this._timer !== null) return;
|
||||
this._timer = setInterval(() => this._tick(), this._intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer !== null) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
// Wipe the badge so a stale label doesn't linger in the editor
|
||||
// after the node is closed/redeployed.
|
||||
try { this._node.status(CLEAR_BADGE); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
_tick() {
|
||||
let badge;
|
||||
try {
|
||||
badge = this._source.getStatusBadge();
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
if (this._logger && typeof this._logger.error === 'function') {
|
||||
this._logger.error(`StatusUpdater: getStatusBadge threw: ${msg}`);
|
||||
}
|
||||
this._safeApply(statusBadge.error(msg));
|
||||
return;
|
||||
}
|
||||
if (badge == null) {
|
||||
this._safeApply(CLEAR_BADGE);
|
||||
return;
|
||||
}
|
||||
this._safeApply(badge);
|
||||
}
|
||||
|
||||
_safeApply(badge) {
|
||||
try {
|
||||
this._node.status(badge);
|
||||
} catch (err) {
|
||||
// node.status itself failing is exotic (e.g. node already
|
||||
// closed). Log once per tick; the next tick will retry.
|
||||
if (this._logger && typeof this._logger.error === 'function') {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
this._logger.error(`StatusUpdater: node.status threw: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { StatusUpdater };
|
||||
195
test/basic/BaseDomain.basic.test.js
Normal file
195
test/basic/BaseDomain.basic.test.js
Normal 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/);
|
||||
});
|
||||
235
test/basic/commandRegistry.basic.test.js
Normal file
235
test/basic/commandRegistry.basic.test.js
Normal 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/);
|
||||
});
|
||||
189
test/basic/statusUpdater.basic.test.js
Normal file
189
test/basic/statusUpdater.basic.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user