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