Files
generalFunctions/test/basic/UnitPolicy.basic.test.js
znetsixe 47faf94048 Phase 1 wave 1: domain + nodered + stats infra (additive)
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>
2026-05-10 18:27:29 +02:00

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