Files
generalFunctions/test/basic/UnitPolicy.basic.test.js
znetsixe f11754635b B3.1 + B3.2 + B3.3: ChildRouter fan-out + commandRegistry 'none' + UnitPolicy dual-shape
B3.1 ChildRouter per-listener fan-out (drop emit monkey-patch):
  Partial-filter subscriptions enumerate every concrete
  <type>.measured.<position> event name (cartesian product over
  the canonical POSITIONS list + 19 KNOWN_TYPES) and register a
  plain emitter.on() per combo. Multi-parent semantics are trivial:
  each ChildRouter's listeners are independent. Drop the wrap/unwrap
  bookkeeping in tearDown. ChildRouter.js 184→164 lines.

B3.2 commandRegistry 'none' + description:
  Add 'none' to payloadSchema.type — handler still fires; logs warn
  if msg.payload is non-empty (catches accidental passes). Add
  optional `description` field per descriptor; surfaced via .list()
  so wikiGen can render per-topic effect text.
  commandRegistry.js 157→164 lines. 23/23 tests pass.

B3.3 UnitPolicy dual-shape:
  policy.canonical/output/curve are now BOTH callable methods AND
  frozen property bags. policy.canonical('flow') === 'm3/s' and
  policy.canonical.flow === 'm3/s' both work. Property bags are
  frozen (assign/delete/redefine throw in strict). Drops the
  _unitView workaround in MGC + rotatingMachine specificClass.
  UnitPolicy.js 149→163 lines, 15/15 tests pass.

CONTRACTS.md §4 + §6 updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:13:15 +02:00

193 lines
8.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('canonical/output/curve are also frozen property bags (dot access)', () => {
const policy = UnitPolicy.declare(baseSpec);
// Property-access form — equivalent to the method-call form above.
assert.equal(policy.canonical.flow, 'm3/s');
assert.equal(policy.canonical.pressure, 'Pa');
assert.equal(policy.output.flow, 'm3/h');
assert.equal(policy.output.temperature, 'C');
assert.equal(policy.curve.flow, 'm3/h');
assert.equal(policy.curve.control, '%');
// Method-call form keeps working alongside it.
assert.equal(policy.canonical('flow'), 'm3/s');
assert.equal(policy.output('power'), 'kW');
});
test('canonical/output/curve property bags are frozen — no assignment / delete / redefine', () => {
'use strict';
const policy = UnitPolicy.declare(baseSpec);
// Existing own-properties are non-writable.
assert.throws(() => { policy.canonical.flow = 'tampered'; }, TypeError);
// Existing own-properties are non-configurable: delete throws.
assert.throws(() => { delete policy.canonical.pressure; }, TypeError);
// Redefining an existing prop throws.
assert.throws(
() => Object.defineProperty(policy.canonical, 'flow', { value: 'tampered' }),
TypeError
);
// Object.isFrozen reports the accessor as frozen.
assert.equal(Object.isFrozen(policy.canonical), true);
assert.equal(Object.isFrozen(policy.output), true);
assert.equal(Object.isFrozen(policy.curve), true);
// Original values survive the failed attempts.
assert.equal(policy.canonical.flow, 'm3/s');
assert.equal(policy.canonical.pressure, 'Pa');
});
test('curve property bag is present (empty) even when no curve was declared', () => {
const policy = UnitPolicy.declare({
canonical: baseSpec.canonical,
output: baseSpec.output,
});
// Method form returns null for unknown types.
assert.equal(policy.curve('flow'), null);
// Property form is an empty frozen function — accessing missing keys is undefined.
assert.equal(policy.curve.flow, undefined);
assert.equal(Object.isFrozen(policy.curve), true);
});
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');
});