Files
generalFunctions/test/basic/BaseDomain.basic.test.js
znetsixe 57b77f905a 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>
2026-05-10 18:31:50 +02:00

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/);
});