Phase 1 wave 3 + barrel: BaseNodeAdapter + index.js exports
- 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) <noreply@anthropic.com>
This commit is contained in:
29
index.js
29
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
|
||||
};
|
||||
|
||||
176
src/nodered/BaseNodeAdapter.js
Normal file
176
src/nodered/BaseNodeAdapter.js
Normal file
@@ -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;
|
||||
330
test/basic/BaseNodeAdapter.basic.test.js
Normal file
330
test/basic/BaseNodeAdapter.basic.test.js
Normal file
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user