From 57b77f905ae95db6938ad814f79e15e740137525 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Sun, 10 May 2026 18:31:50 +0200 Subject: [PATCH] Phase 1 wave 2: BaseDomain + commandRegistry + statusUpdater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/domain/BaseDomain.js | 139 ++++++++++++++ src/nodered/commandRegistry.js | 156 +++++++++++++++ src/nodered/statusUpdater.js | 90 +++++++++ test/basic/BaseDomain.basic.test.js | 195 +++++++++++++++++++ test/basic/commandRegistry.basic.test.js | 235 +++++++++++++++++++++++ test/basic/statusUpdater.basic.test.js | 189 ++++++++++++++++++ 6 files changed, 1004 insertions(+) create mode 100644 src/domain/BaseDomain.js create mode 100644 src/nodered/commandRegistry.js create mode 100644 src/nodered/statusUpdater.js create mode 100644 test/basic/BaseDomain.basic.test.js create mode 100644 test/basic/commandRegistry.basic.test.js create mode 100644 test/basic/statusUpdater.basic.test.js diff --git a/src/domain/BaseDomain.js b/src/domain/BaseDomain.js new file mode 100644 index 0000000..1dcae55 --- /dev/null +++ b/src/domain/BaseDomain.js @@ -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/.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; diff --git a/src/nodered/commandRegistry.js b/src/nodered/commandRegistry.js new file mode 100644 index 0000000..b93dda2 --- /dev/null +++ b/src/nodered/commandRegistry.js @@ -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 }; diff --git a/src/nodered/statusUpdater.js b/src/nodered/statusUpdater.js new file mode 100644 index 0000000..130d3c5 --- /dev/null +++ b/src/nodered/statusUpdater.js @@ -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 }; diff --git a/test/basic/BaseDomain.basic.test.js b/test/basic/BaseDomain.basic.test.js new file mode 100644 index 0000000..e4795e8 --- /dev/null +++ b/test/basic/BaseDomain.basic.test.js @@ -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.=[...] + 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/); +}); diff --git a/test/basic/commandRegistry.basic.test.js b/test/basic/commandRegistry.basic.test.js new file mode 100644 index 0000000..b27f02c --- /dev/null +++ b/test/basic/commandRegistry.basic.test.js @@ -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/); +}); diff --git a/test/basic/statusUpdater.basic.test.js b/test/basic/statusUpdater.basic.test.js new file mode 100644 index 0000000..a184ed9 --- /dev/null +++ b/test/basic/statusUpdater.basic.test.js @@ -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); +});