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>
164 lines
5.7 KiB
JavaScript
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;
|