Files
generalFunctions/src/domain/UnitPolicy.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

164 lines
5.7 KiB
JavaScript

const convert = require('../convert/index.js');
// Map MeasurementContainer measurement-type names to convert-module
// "measure" families. Mirrors MeasurementContainer.measureMap so a policy
// declared with the type names domains use ('flow', 'pressure', ...) can be
// validated against the same convert-module families MeasurementContainer
// uses internally.
const TYPE_TO_MEASURE = Object.freeze({
pressure: 'pressure',
atmpressure: 'pressure',
flow: 'volumeFlowRate',
power: 'power',
hydraulicpower: 'power',
reactivepower: 'reactivePower',
apparentpower: 'apparentPower',
temperature: 'temperature',
volume: 'volume',
length: 'length',
mass: 'mass',
energy: 'energy',
reactiveenergy: 'reactiveEnergy',
});
const DEFAULT_REQUIRED_TYPES = Object.freeze(['flow', 'pressure', 'power', 'temperature']);
class UnitPolicy {
constructor({ canonical, output, curve, requireUnitForTypes, logger } = {}) {
this._canonical = freezeShallow(canonical);
this._output = freezeShallow(output);
this._curve = curve ? freezeShallow(curve) : null;
this._requireUnitForTypes = Object.freeze(
Array.isArray(requireUnitForTypes) ? [...requireUnitForTypes] : [...DEFAULT_REQUIRED_TYPES]
);
this._logger = logger || null;
// Warn-once memo: same (label, candidate) pair only logs the first time.
this._warned = new Set();
// Dual-shape accessors: each of canonical/output/curve is BOTH a method
// (legacy `policy.canonical('flow')`) AND a frozen property bag
// (`policy.canonical.flow`). The function carries the frozen map's own
// properties via Object.defineProperty so consumers can pick either form.
this.canonical = makeAccessor(this._canonical);
this.output = makeAccessor(this._output);
this.curve = makeAccessor(this._curve || {});
}
static declare(spec = {}) {
if (!spec.canonical || typeof spec.canonical !== 'object') {
throw new Error('UnitPolicy.declare: canonical units map is required');
}
if (!spec.output || typeof spec.output !== 'object') {
throw new Error('UnitPolicy.declare: output units map is required');
}
return new UnitPolicy(spec);
}
setLogger(logger) {
this._logger = logger || null;
return this;
}
/**
* Validate a user-supplied unit string against `expectedMeasure`. On any
* mismatch return `fallback` and warn once for this (label, candidate)
* pair. On success return the trimmed candidate.
*/
resolve(candidate, expectedMeasure, fallback, label = 'unit') {
const fallbackUnit = String(fallback || '').trim();
const raw = typeof candidate === 'string' ? candidate.trim() : '';
if (!raw) return fallbackUnit;
try {
const desc = convert().describe(raw);
const measure = resolveMeasure(expectedMeasure);
if (measure && desc.measure !== measure) {
throw new Error(`expected ${measure} but got ${desc.measure}`);
}
return raw;
} catch (error) {
this._warnOnce(label, raw, `Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallbackUnit}'.`);
return fallbackUnit;
}
}
/**
* Strict numeric conversion. Throws if value is not finite.
* No-ops (still returning a Number) when from/to are missing or equal.
*/
convert(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
throw new Error(`${contextLabel}: value '${value}' is not finite`);
}
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
return convert(numeric).from(fromUnit).to(toUnit);
}
/**
* Returns the option bag for `new MeasurementContainer(options, logger)`.
* Exact shape required by MeasurementContainer; see
* src/measurements/MeasurementContainer.js constructor.
*/
containerOptions() {
const defaultUnits = { ...this._output };
const preferredUnits = { ...this._output };
const canonicalUnits = { ...this._canonical };
return {
defaultUnits,
preferredUnits,
canonicalUnits,
storeCanonical: true,
strictUnitValidation: true,
throwOnInvalidUnit: true,
requireUnitForTypes: [...this._requireUnitForTypes],
};
}
_warnOnce(label, candidate, message) {
const key = `${label}::${candidate}`;
if (this._warned.has(key)) return;
this._warned.add(key);
if (this._logger && typeof this._logger.warn === 'function') {
this._logger.warn(message);
} else {
// Last-resort fallback so misconfigurations don't go silent in
// domains that haven't wired a logger yet.
console.warn(message);
}
}
}
function freezeShallow(obj) {
return Object.freeze({ ...(obj || {}) });
}
// Build a function that doubles as a frozen property bag. `accessor(type)`
// returns the unit for that type (legacy method shape). `accessor.flow` etc.
// return the unit directly (new property shape). Own-properties are
// non-writable, non-configurable; attempts to assign / delete / redefine
// throw in strict mode — proving the bag is genuinely frozen.
function makeAccessor(map) {
const fn = (type) => map[type] || null;
for (const key of Object.keys(map)) {
Object.defineProperty(fn, key, {
value: map[key],
writable: false,
enumerable: true,
configurable: false,
});
}
return Object.freeze(fn);
}
// Accepts either the convert-module measure family ('volumeFlowRate') or one
// of our type names ('flow') and returns the convert-module measure.
function resolveMeasure(expected) {
if (!expected) return null;
const lower = String(expected).trim().toLowerCase();
if (TYPE_TO_MEASURE[lower]) return TYPE_TO_MEASURE[lower];
return expected;
}
module.exports = UnitPolicy;