From 62f389a51f0ee6e23551ba99ccdbdbe8d99c1bae Mon Sep 17 00:00:00 2001 From: znetsixe Date: Sun, 10 May 2026 18:59:50 +0200 Subject: [PATCH] Phase 1 wave 3 + barrel: BaseNodeAdapter + index.js exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/nodered/BaseNodeAdapter.js — base class for every nodeClass.js Lifecycle: config build → domain instantiate → child.register on Port 2 → tick (opt-in) or 'output-changed' subscription (default event-driven) → status updater → input dispatch via commandRegistry → close handler with clean teardown. - index.js — additive exports of all Phase 1 modules: UnitPolicy, ChildRouter, LatestWinsGate, HealthStatus, BaseDomain, statusBadge, StatusUpdater, createRegistry, CommandRegistry, BaseNodeAdapter, stats. Existing exports unchanged. 113 unit tests pass under node:test. Co-Authored-By: Claude Opus 4.7 (1M context) --- index.js | 29 +- src/nodered/BaseNodeAdapter.js | 176 ++++++++++++ test/basic/BaseNodeAdapter.basic.test.js | 330 +++++++++++++++++++++++ 3 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 src/nodered/BaseNodeAdapter.js create mode 100644 test/basic/BaseNodeAdapter.basic.test.js diff --git a/index.js b/index.js index b4a8e34..6ff9cd9 100644 --- a/index.js +++ b/index.js @@ -35,6 +35,21 @@ const { loadModel } = require('./datasets/assetData/modelData/index.js'); const { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js'); const Fysics = require('./src/convert/fysics.js'); +// Refactor platform infrastructure (additive — see .claude/refactor/CONTRACTS.md). +// Domain-side +const UnitPolicy = require('./src/domain/UnitPolicy.js'); +const ChildRouter = require('./src/domain/ChildRouter.js'); +const LatestWinsGate = require('./src/domain/LatestWinsGate.js'); +const HealthStatus = require('./src/domain/HealthStatus.js'); +const BaseDomain = require('./src/domain/BaseDomain.js'); +// Node-RED-side +const { statusBadge } = require('./src/nodered/statusBadge.js'); +const { StatusUpdater } = require('./src/nodered/statusUpdater.js'); +const { createRegistry, CommandRegistry } = require('./src/nodered/commandRegistry.js'); +const BaseNodeAdapter = require('./src/nodered/BaseNodeAdapter.js'); +// Stats helpers +const stats = require('./src/stats/index.js'); + // Export everything module.exports = { predict, @@ -63,5 +78,17 @@ module.exports = { POSITIONS, POSITION_VALUES, isValidPosition, - Fysics + Fysics, + // refactor infra (Phase 1) + UnitPolicy, + ChildRouter, + LatestWinsGate, + HealthStatus, + BaseDomain, + statusBadge, + StatusUpdater, + createRegistry, + CommandRegistry, + BaseNodeAdapter, + stats }; diff --git a/src/nodered/BaseNodeAdapter.js b/src/nodered/BaseNodeAdapter.js new file mode 100644 index 0000000..12a2ae4 --- /dev/null +++ b/src/nodered/BaseNodeAdapter.js @@ -0,0 +1,176 @@ +/** + * BaseNodeAdapter — shared nodeClass scaffolding. + * + * Consolidates the boilerplate every node's nodeClass.js repeats today + * (config build → domain instantiate → registration delay → tick loop → + * status loop → input dispatch → close handler). Subclasses declare what + * varies (DomainClass, commands, output strategy) via static fields and + * override `buildDomainConfig(uiConfig, nodeId)` to produce the per-node + * config slice. + * + * See CONTRACTS.md §2; OPEN_QUESTIONS.md (event-driven default + tick + * fire-and-forget resolution, 2026-05-10). + */ + +'use strict'; + +const ConfigManager = require('../configs/index.js'); +const OutputUtils = require('../helper/outputUtils.js'); +const { createRegistry } = require('./commandRegistry.js'); +const { StatusUpdater } = require('./statusUpdater.js'); + +const REGISTRATION_DELAY_MS = 100; + +class BaseNodeAdapter { + constructor(uiConfig, RED, nodeInstance, nameOfNode) { + const ctor = this.constructor; + if (ctor === BaseNodeAdapter) { + throw new Error('BaseNodeAdapter is abstract; subclass it and declare static DomainClass + commands'); + } + if (typeof ctor.DomainClass !== 'function') { + throw new Error(`${ctor.name}: static DomainClass is required (a class to instantiate)`); + } + if (!Array.isArray(ctor.commands)) { + throw new Error(`${ctor.name}: static commands is required (array of descriptors; use [] for none)`); + } + if (typeof this.buildDomainConfig !== 'function') { + throw new Error(`${ctor.name}: must implement buildDomainConfig(uiConfig, nodeId)`); + } + + this.node = nodeInstance; + this.RED = RED; + this.name = nameOfNode; + + const cfgMgr = new ConfigManager(); + this.defaultConfig = cfgMgr.getConfig(this.name); + this.config = cfgMgr.buildConfig( + this.name, + uiConfig, + this.node.id, + this.buildDomainConfig(uiConfig, this.node.id) || {}, + ); + + this.source = new ctor.DomainClass(this.config); + // Sibling-node lookup uses RED.nodes.getNode(id).source — see existing + // pumpingStation/measurement nodeClass _attachInputHandler patterns. + this.node.source = this.source; + + this._output = new OutputUtils(); + this._commands = createRegistry(ctor.commands, { logger: this.source?.logger }); + + this._tickInterval = null; + this._outputChangedListener = null; + this._scheduleRegistration(); + this._wireOutputs(); + + this._statusUpdater = new StatusUpdater({ + node: this.node, + source: this.source, + intervalMs: ctor.statusInterval ?? 1000, + logger: this.source?.logger, + }); + this._statusUpdater.start(); + + this._attachInputHandler(); + this._attachCloseHandler(); + + if (typeof this.extraSetup === 'function') this.extraSetup(); + } + + _scheduleRegistration() { + // Delayed so siblings have finished constructing before the parent + // receives the registration message. + setTimeout(() => { + this.node.send([ + null, + null, + { + topic: 'child.register', + payload: this.node.id, + positionVsParent: this.config?.functionality?.positionVsParent ?? 'atEquipment', + distance: this.config?.functionality?.distance ?? null, + }, + ]); + }, REGISTRATION_DELAY_MS); + } + + _wireOutputs() { + const ctor = this.constructor; + const interval = ctor.tickInterval; + if (typeof interval === 'number' && interval > 0) { + this._tickInterval = setInterval(() => { + // Fire-and-forget per OPEN_QUESTIONS 2026-05-10. Domain owns + // its own serialisation via LatestWinsGate when needed. + try { this.source.tick?.(); } + catch (err) { this.source?.logger?.error?.(`tick threw: ${err.message}`); } + this._emitOutputs(); + }, interval); + return; + } + // Event-driven default: domain emits 'output-changed' when its + // public output state shifts; adapter pushes outputs in response. + const emitter = this.source?.emitter; + if (emitter && typeof emitter.on === 'function') { + this._outputChangedListener = () => this._emitOutputs(); + emitter.on('output-changed', this._outputChangedListener); + } + } + + _emitOutputs() { + if (typeof this.source.getOutput !== 'function') return; + const raw = this.source.getOutput(); + const cfg = this.source.config || this.config; + const processMsg = this._output.formatMsg(raw, cfg, 'process'); + const influxMsg = this._output.formatMsg(raw, cfg, 'influxdb'); + this.node.send([processMsg, influxMsg, null]); + } + + _attachInputHandler() { + this.node.on('input', async (msg, send, done) => { + try { + await this._commands.dispatch(msg, this.source, { + node: this.node, + RED: this.RED, + send, + logger: this.source?.logger, + }); + if (typeof this.extraInputDispatch === 'function') { + await this.extraInputDispatch(msg, send, done); + } + } catch (err) { + this.source?.logger?.error?.(err.message); + } finally { + if (typeof done === 'function') done(); + } + }); + } + + _attachCloseHandler() { + this.node.on('close', (done) => { + try { + if (this._tickInterval) { + clearInterval(this._tickInterval); + this._tickInterval = null; + } + if (this._outputChangedListener && this.source?.emitter?.off) { + this.source.emitter.off('output-changed', this._outputChangedListener); + this._outputChangedListener = null; + } + this._statusUpdater?.stop(); + this.source?.close?.(); + if (typeof this.extraClose === 'function') this.extraClose(); + try { this.node.status({}); } catch (_) { /* best effort */ } + } catch (err) { + this.source?.logger?.error?.(`close handler threw: ${err.message}`); + } finally { + if (typeof done === 'function') done(); + } + }); + } +} + +// Defaults overridable via subclass static fields. +BaseNodeAdapter.tickInterval = null; +BaseNodeAdapter.statusInterval = 1000; + +module.exports = BaseNodeAdapter; diff --git a/test/basic/BaseNodeAdapter.basic.test.js b/test/basic/BaseNodeAdapter.basic.test.js new file mode 100644 index 0000000..808d141 --- /dev/null +++ b/test/basic/BaseNodeAdapter.basic.test.js @@ -0,0 +1,330 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const EventEmitter = require('events'); + +const BaseNodeAdapter = require('../../src/nodered/BaseNodeAdapter'); + +// ---- test doubles --------------------------------------------------------- + +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, + }; +} + +function makeNode(id = 'node-1') { + const sends = []; + const statuses = []; + const handlers = {}; + return { + id, + sends, + statuses, + handlers, + send(arr) { sends.push(arr); }, + status(b) { statuses.push(b); }, + on(ev, fn) { handlers[ev] = fn; }, + warn() {}, + error() {}, + }; +} + +function makeRED() { + return { nodes: { getNode: () => null } }; +} + +// Fake domain — surfaces just enough of the BaseDomain contract that +// BaseNodeAdapter touches (config, logger, emitter, getOutput, getStatusBadge, +// optionally tick + close). Avoids the JSON-config dependency BaseDomain has. +function makeDomain(opts = {}) { + const logger = opts.logger || makeLogger(); + return class FakeDomain { + constructor(config) { + this.config = config; + this.logger = logger; + this.emitter = new EventEmitter(); + this.tickCount = 0; + this.closed = false; + this._output = opts.output || { temperature: 21 }; + this._badge = opts.badge || { fill: 'green', shape: 'dot', text: 'OK' }; + } + tick() { this.tickCount += 1; } + getOutput() { return this._output; } + getStatusBadge() { return this._badge; } + close() { this.closed = true; } + }; +} + +// uiConfig field set used by configManager.buildConfig — measurement is +// chosen as the config-file name because measurement.json ships in +// generalFunctions/src/configs and getConfig() is called during construction. +function uiConfigFixture() { + return { + name: 'm1', unit: 'C', logLevel: 'warn', + positionVsParent: 'upstream', hasDistance: true, distance: 5, + }; +} + +// ---- 1. Construction with full subclass succeeds -------------------------- + +test('full subclass constructs and stores wiring on this', () => { + const Domain = makeDomain(); + class Adapter extends BaseNodeAdapter { + static DomainClass = Domain; + static commands = []; + buildDomainConfig() { return { extra: { foo: 1 } }; } + } + const node = makeNode(); + const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + assert.equal(a.name, 'measurement'); + assert.equal(a.node, node); + assert.equal(node.source, a.source); + assert.equal(a.config.extra.foo, 1); + assert.equal(a.config.general.name, 'm1'); +}); + +// ---- 2-4. Static-field validation ----------------------------------------- + +test('direct new BaseNodeAdapter() throws abstract error', () => { + assert.throws( + () => new BaseNodeAdapter({}, makeRED(), makeNode(), 'measurement'), + /abstract/, + ); +}); + +test('subclass without static DomainClass throws clearly', () => { + class Bad extends BaseNodeAdapter { static commands = []; buildDomainConfig() { return {}; } } + assert.throws( + () => new Bad({}, makeRED(), makeNode(), 'measurement'), + /DomainClass is required/, + ); +}); + +test('subclass without static commands throws clearly', () => { + class Bad extends BaseNodeAdapter { + static DomainClass = makeDomain(); + buildDomainConfig() { return {}; } + } + assert.throws( + () => new Bad({}, makeRED(), makeNode(), 'measurement'), + /commands is required/, + ); +}); + +test('static commands = [] is allowed (explicit no-op registry)', () => { + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = []; + buildDomainConfig() { return {}; } + } + assert.doesNotThrow( + () => new Adapter({}, makeRED(), makeNode(), 'measurement'), + ); +}); + +// ---- 5. Registration message after 100 ms --------------------------------- + +test('registration message fires on Port 2 after 100 ms with child.register', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = []; + buildDomainConfig() { return {}; } + } + const node = makeNode('xyz'); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + assert.equal(node.sends.length, 0); + t.mock.timers.tick(100); + assert.equal(node.sends.length, 1); + const [p0, p1, reg] = node.sends[0]; + assert.equal(p0, null); + assert.equal(p1, null); + assert.equal(reg.topic, 'child.register'); + assert.equal(reg.payload, 'xyz'); + assert.equal(reg.positionVsParent, 'upstream'); + assert.equal(reg.distance, 5); +}); + +// ---- 6. Tick mode --------------------------------------------------------- + +test('static tickInterval > 0 calls source.tick() on schedule and emits outputs', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = []; + static tickInterval = 50; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + assert.equal(a.source.tickCount, 0); + t.mock.timers.tick(50); + assert.equal(a.source.tickCount, 1); + t.mock.timers.tick(100); + assert.equal(a.source.tickCount, 3); + // Every tick triggers an output emission (the first carries the changed + // fields; subsequent ones may emit nulls because of delta compression — + // but node.send is called either way). + assert.ok(node.sends.length >= 3); +}); + +// ---- 7. Event-driven default ---------------------------------------------- + +test('default (no tick) subscribes to "output-changed" on source.emitter', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = []; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + // Drain the registration tick so we can isolate output emissions. + t.mock.timers.tick(100); + const before = node.sends.length; + a.source.emitter.emit('output-changed'); + assert.equal(node.sends.length, before + 1); + const last = node.sends[node.sends.length - 1]; + assert.equal(last.length, 3); + assert.equal(last[2], null); +}); + +// ---- 8. _emitOutputs shape ------------------------------------------------ + +test('_emitOutputs sends [processMsg, influxMsg, null] with both formatters', () => { + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain({ output: { v: 1 } }); + static commands = []; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + node.sends.length = 0; + a._emitOutputs(); + assert.equal(node.sends.length, 1); + const [proc, influx, port2] = node.sends[0]; + assert.ok(proc && typeof proc === 'object', 'process msg present'); + assert.ok(influx && typeof influx === 'object', 'influxdb msg present'); + assert.equal(port2, null); +}); + +// ---- 9-10. Input dispatch ------------------------------------------------- + +test('input handler dispatches a known topic to the registered handler', async () => { + const seen = []; + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = [{ + topic: 'set.mode', + handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); }, + }]; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + let donec = 0; + await node.handlers.input({ topic: 'set.mode', payload: 'auto' }, () => {}, () => { donec += 1; }); + assert.equal(seen.length, 1); + assert.equal(seen[0].source, a.source); + assert.equal(seen[0].msg.payload, 'auto'); + assert.equal(donec, 1); +}); + +test('input handler with unknown topic warns and does not crash', async () => { + const logger = makeLogger(); + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain({ logger }); + static commands = []; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + let donec = 0; + await node.handlers.input({ topic: 'totally.unknown', payload: 1 }, () => {}, () => { donec += 1; }); + assert.equal(donec, 1); + assert.ok(logger._calls.warn.some((m) => m.includes('totally.unknown'))); +}); + +// ---- 11. Status updater wiring -------------------------------------------- + +test('status updater receives static statusInterval', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain({ badge: { fill: 'red', shape: 'ring', text: 'X' } }); + static commands = []; + static statusInterval = 250; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + assert.equal(node.statuses.length, 0); + t.mock.timers.tick(250); + assert.equal(node.statuses.length, 1); + assert.deepEqual(node.statuses[0], { fill: 'red', shape: 'ring', text: 'X' }); +}); + +// ---- 12. Close handler ---------------------------------------------------- + +test('close handler clears tick interval, stops status, clears badge, calls source.close', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = []; + static tickInterval = 100; + static statusInterval = 100; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + t.mock.timers.tick(200); // two ticks fire + const ticksAtClose = a.source.tickCount; + let donec = 0; + node.handlers.close(() => { donec += 1; }); + assert.equal(donec, 1); + assert.equal(a.source.closed, true); + // Final node.status({}) appears in statuses. + assert.deepEqual(node.statuses[node.statuses.length - 1], {}); + // No further ticks after close. + t.mock.timers.tick(1000); + assert.equal(a.source.tickCount, ticksAtClose); +}); + +// ---- 13. Hook points fire when defined ------------------------------------ + +test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const trace = []; + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = [{ topic: 'set.x', handler: () => { trace.push('handler'); } }]; + static statusInterval = 0; + buildDomainConfig() { return {}; } + extraSetup() { trace.push('extraSetup'); } + extraInputDispatch(msg) { trace.push(`extraInput:${msg.topic}`); } + extraClose() { trace.push('extraClose'); } + } + const node = makeNode(); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + assert.ok(trace.includes('extraSetup')); + await node.handlers.input({ topic: 'set.x', payload: 1 }, () => {}, () => {}); + assert.ok(trace.includes('handler')); + assert.ok(trace.includes('extraInput:set.x')); + // Unknown-topic path also runs extraInputDispatch — by design, it's the + // fallback the contract documents. + await node.handlers.input({ topic: 'unknown', payload: 1 }, () => {}, () => {}); + assert.ok(trace.includes('extraInput:unknown')); + node.handlers.close(() => {}); + assert.ok(trace.includes('extraClose')); +});