- 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>
196 lines
6.9 KiB
JavaScript
196 lines
6.9 KiB
JavaScript
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/);
|
|
});
|