Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:
- src/domain/UnitPolicy.js — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js — standardised {level, flags, message, source}
- src/nodered/statusBadge.js — compose / error / idle / byState / text helpers
- src/stats/index.js — mean / stdDev / median / mad / lerp
All additive — no existing exports change shape.
56 unit tests pass under node:test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
6.2 KiB
JavaScript
146 lines
6.2 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const UnitPolicy = require('../../src/domain/UnitPolicy.js');
|
|
|
|
function makeFakeLogger() {
|
|
const calls = { warn: [], info: [], error: [], debug: [] };
|
|
return {
|
|
calls,
|
|
warn: (m) => calls.warn.push(m),
|
|
info: (m) => calls.info.push(m),
|
|
error: (m) => calls.error.push(m),
|
|
debug: (m) => calls.debug.push(m),
|
|
};
|
|
}
|
|
|
|
const baseSpec = {
|
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
|
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
|
curve: { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' },
|
|
};
|
|
|
|
test('declare returns a policy whose canonical/output match the input', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
assert.equal(policy.canonical('flow'), 'm3/s');
|
|
assert.equal(policy.canonical('pressure'), 'Pa');
|
|
assert.equal(policy.canonical('power'), 'W');
|
|
assert.equal(policy.canonical('temperature'), 'K');
|
|
assert.equal(policy.output('flow'), 'm3/h');
|
|
assert.equal(policy.output('pressure'), 'mbar');
|
|
assert.equal(policy.output('power'), 'kW');
|
|
assert.equal(policy.output('temperature'), 'C');
|
|
assert.equal(policy.curve('flow'), 'm3/h');
|
|
assert.equal(policy.curve('control'), '%');
|
|
});
|
|
|
|
test('declare throws when canonical or output is missing', () => {
|
|
assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/);
|
|
assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/);
|
|
});
|
|
|
|
test('resolve returns the candidate when it matches the expected measure', () => {
|
|
const logger = makeFakeLogger();
|
|
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
|
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s', 'general.flow'), 'm3/h');
|
|
assert.equal(policy.resolve('bar', 'pressure', 'mbar', 'asset.pressure'), 'bar');
|
|
assert.equal(policy.resolve('kW', 'power', 'W', 'asset.power'), 'kW');
|
|
// No warnings on valid inputs.
|
|
assert.equal(logger.calls.warn.length, 0);
|
|
});
|
|
|
|
test('resolve falls back when given an invalid candidate, warns once', () => {
|
|
const logger = makeFakeLogger();
|
|
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
|
|
|
// Wrong measure family (mass unit declared as a flow unit).
|
|
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
|
// Same call again — the warn-once memo must suppress.
|
|
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
|
assert.equal(logger.calls.warn.length, 1);
|
|
assert.match(logger.calls.warn[0], /Invalid general\.flow unit 'kg'/);
|
|
|
|
// A different invalid candidate logs a separate warning.
|
|
assert.equal(policy.resolve('not-a-unit', 'pressure', 'Pa', 'asset.pressure'), 'Pa');
|
|
assert.equal(logger.calls.warn.length, 2);
|
|
});
|
|
|
|
test('resolve falls back to the default when candidate is empty/whitespace', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
assert.equal(policy.resolve('', 'flow', 'm3/s'), 'm3/s');
|
|
assert.equal(policy.resolve(' ', 'flow', 'm3/s'), 'm3/s');
|
|
assert.equal(policy.resolve(undefined, 'flow', 'm3/s'), 'm3/s');
|
|
});
|
|
|
|
test('resolve accepts type-name shorthand as well as convert-module measure', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
// 'flow' shorthand should map to volumeFlowRate, not be passed through raw.
|
|
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s'), 'm3/h');
|
|
assert.equal(policy.resolve('m3/h', 'volumeFlowRate', 'm3/s'), 'm3/h');
|
|
});
|
|
|
|
test('convert is a no-op when from === to (still coerces to Number)', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
assert.equal(policy.convert('5', 'm3/h', 'm3/h'), 5);
|
|
assert.equal(typeof policy.convert(5, 'm3/h', 'm3/h'), 'number');
|
|
// Missing units also no-op.
|
|
assert.equal(policy.convert(7, '', 'm3/h'), 7);
|
|
assert.equal(policy.convert(7, 'm3/h', null), 7);
|
|
});
|
|
|
|
test('convert across compatible units returns the expected numeric', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
// 1 m3/s -> 3600 m3/h
|
|
assert.equal(policy.convert(1, 'm3/s', 'm3/h'), 3600);
|
|
// 1 bar -> 100000 Pa
|
|
assert.equal(policy.convert(1, 'bar', 'Pa'), 100000);
|
|
// 1 kW -> 1000 W
|
|
assert.equal(policy.convert(1, 'kW', 'W'), 1000);
|
|
});
|
|
|
|
test('convert throws when value is not finite', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
assert.throws(() => policy.convert('not-a-number', 'm3/h', 'm3/s'), /not finite/);
|
|
assert.throws(() => policy.convert(NaN, 'm3/h', 'm3/s'), /not finite/);
|
|
assert.throws(() => policy.convert(Infinity, 'm3/h', 'm3/s'), /not finite/);
|
|
});
|
|
|
|
test('containerOptions returns the exact shape consumed by MeasurementContainer', () => {
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
const opts = policy.containerOptions();
|
|
|
|
assert.deepEqual(opts.defaultUnits, baseSpec.output);
|
|
assert.deepEqual(opts.preferredUnits, baseSpec.output);
|
|
assert.deepEqual(opts.canonicalUnits, baseSpec.canonical);
|
|
assert.equal(opts.storeCanonical, true);
|
|
assert.equal(opts.strictUnitValidation, true);
|
|
assert.equal(opts.throwOnInvalidUnit, true);
|
|
assert.deepEqual(opts.requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
|
|
|
// Mutating the returned bag must not leak back into the policy.
|
|
opts.defaultUnits.flow = 'tampered';
|
|
opts.requireUnitForTypes.push('volume');
|
|
assert.equal(policy.output('flow'), 'm3/h');
|
|
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
|
});
|
|
|
|
test('containerOptions honours custom requireUnitForTypes from declare', () => {
|
|
const policy = UnitPolicy.declare({
|
|
...baseSpec,
|
|
requireUnitForTypes: ['flow', 'pressure'],
|
|
});
|
|
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure']);
|
|
});
|
|
|
|
test('containerOptions output works with a real MeasurementContainer', () => {
|
|
const { MeasurementContainer } = require('../../src/measurements/index.js');
|
|
const policy = UnitPolicy.declare(baseSpec);
|
|
const mc = new MeasurementContainer(policy.containerOptions());
|
|
// No throw on construction — proves the option bag is a valid input shape.
|
|
assert.equal(mc.storeCanonical, true);
|
|
assert.equal(mc.strictUnitValidation, true);
|
|
assert.equal(mc.throwOnInvalidUnit, true);
|
|
assert.equal(mc.canonicalUnits.flow, 'm3/s');
|
|
assert.equal(mc.defaultUnits.flow, 'm3/h');
|
|
});
|