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(); } 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; } canonical(type) { return this._canonical[type] || null; } output(type) { return this._output[type] || null; } curve(type) { return this._curve ? (this._curve[type] || null) : null; } /** * 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 || {}) }); } // 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;