diff --git a/src/configs/measurement.json b/src/configs/measurement.json index 3840967..085c5c9 100644 --- a/src/configs/measurement.json +++ b/src/configs/measurement.json @@ -439,6 +439,16 @@ "description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode." } }, + "calibration": { + "stabilityThreshold": { + "default": 0.01, + "rules": { + "type": "number", + "min": 0, + "description": "Absolute standard-deviation ceiling (in scaling-units, i.e. the same range as absMin..absMax) below which the rolling window is considered stable enough to trust for calibration / repeatability. A buffer with stdDev <= threshold is treated as stable; anything above aborts calibrate() and evaluateRepeatability() with a warning. Default 0.01 fits the [50,100] absMin/absMax default range; tighten or relax to match your sensor's expected noise floor." + } + } + }, "outlierDetection": { "enabled": { "default": false, diff --git a/src/configs/monster.json b/src/configs/monster.json index 58875ca..9dd5d26 100644 --- a/src/configs/monster.json +++ b/src/configs/monster.json @@ -251,6 +251,34 @@ "type": "number", "description": "Minimum inner diameter of the intake tubing in millimeters." } + }, + "nominalFlowMin": { + "default": 0, + "rules": { + "type": "number", + "description": "Lower bound of expected inflow rate (m3/h). Used together with flowMax to scale the rain-driven flow prediction." + } + }, + "flowMax": { + "default": 0, + "rules": { + "type": "number", + "description": "Upper bound of expected inflow rate (m3/h). Used together with nominalFlowMin to scale the rain-driven flow prediction." + } + }, + "maxRainRef": { + "default": 10, + "rules": { + "type": "number", + "description": "Reference rain index that maps to the flowMax end of the prediction band." + } + }, + "minSampleIntervalSec": { + "default": 60, + "rules": { + "type": "number", + "description": "Cooldown between consecutive sample pulses (seconds). Pulses raised faster than this are recorded as missedSamples." + } } } } diff --git a/src/convert/index.js b/src/convert/index.js index 54a6812..6606274 100644 --- a/src/convert/index.js +++ b/src/convert/index.js @@ -301,4 +301,26 @@ convert = function (value) { return new Converter(value); }; +/** + * Top-level helper: list accepted unit names for a measure. + * Cached per measure. Unknown measures return []. + */ +var _possibilitiesCache = Object.create(null); +convert.possibilities = function (measure) { + if (!measure || typeof measure !== 'string') return []; + if (_possibilitiesCache[measure]) return _possibilitiesCache[measure].slice(); + if (!measures[measure]) { + _possibilitiesCache[measure] = []; + return []; + } + var units = Converter.prototype.possibilities.call({ origin: { measure: measure } }, measure); + var deduped = Array.from(new Set(units)).sort(); + _possibilitiesCache[measure] = deduped; + return deduped.slice(); +}; + +convert.measures = function () { + return keys(measures).slice(); +}; + module.exports = convert; diff --git a/src/domain/LatestWinsGate.js b/src/domain/LatestWinsGate.js index 2402833..8f80165 100644 --- a/src/domain/LatestWinsGate.js +++ b/src/domain/LatestWinsGate.js @@ -2,9 +2,22 @@ // Serialises an async dispatch so that high-frequency callers cannot stack // up overlapping invocations. Intermediate values are dropped — only the -// most recent fire() during an in-flight dispatch is replayed afterwards. -// Extracted from machineGroupControl's _dispatchInFlight + _delayedCall -// pattern so MGC, pumpingStation, valveGroupControl etc. can share it. +// most recent fire()/fireAndWait() during an in-flight dispatch is replayed +// afterwards. Extracted from machineGroupControl's _dispatchInFlight + +// _delayedCall pattern so MGC, pumpingStation, valveGroupControl etc. can +// share it. +// +// fire(value) — never blocks; returns void. +// fireAndWait(value) — returns a promise that settles when THIS value's +// dispatch runs to completion. If a later fireAndWait +// arrives during the in-flight call and supersedes +// this one in the pending slot, the returned promise +// RESOLVES with { superseded: true } instead of +// rejecting — callers can branch on a sentinel +// without try/catch. The dispatch's own return value +// (when not superseded) is forwarded as the resolution. + +const SUPERSEDED = Object.freeze({ superseded: true }); class LatestWinsGate { constructor(asyncDispatchFn, options = {}) { @@ -14,7 +27,7 @@ class LatestWinsGate { this._dispatch = asyncDispatchFn; this._logger = options.logger || null; this._inFlight = false; - this._pending = null; // { value, ctx } | null + this._pending = null; // { value, ctx, settle? } | null this._drainResolvers = []; // resolved when idle again this.lastError = null; } @@ -25,14 +38,31 @@ class LatestWinsGate { return this._pending ? 2 : 1; } - // Never blocks the caller. If a dispatch is in flight, the latest - // value is parked; older parked values are silently overwritten. + // Never blocks. If a dispatch is in flight, the latest value is parked; + // older parked values are silently overwritten. fire(value, ctx) { if (this._inFlight) { - this._pending = { value, ctx }; + this._supersedePending(); + this._pending = { value, ctx, settle: null }; return; } - this._run(value, ctx); + this._run(value, ctx, null); + } + + // Returns a promise that resolves when THIS fire's dispatch settles. + // If this fire gets overwritten while parked, resolves with the + // SUPERSEDED sentinel ({ superseded: true }) — callers branch on + // result.superseded === true without try/catch. + fireAndWait(value, ctx) { + return new Promise((resolve) => { + const settle = resolve; + if (this._inFlight) { + this._supersedePending(); + this._pending = { value, ctx, settle }; + return; + } + this._run(value, ctx, settle); + }); } drain() { @@ -40,18 +70,28 @@ class LatestWinsGate { return new Promise((resolve) => { this._drainResolvers.push(resolve); }); } - _run(value, ctx) { + _supersedePending() { + const prev = this._pending; + if (prev && typeof prev.settle === 'function') prev.settle(SUPERSEDED); + this._pending = null; + } + + _run(value, ctx, settle) { this._inFlight = true; - // Kick the dispatch on a microtask so fire() always returns - // synchronously, even if _dispatch resolves immediately. + // Kick the dispatch on a microtask so fire()/fireAndWait() always + // return synchronously, even if _dispatch resolves immediately. Promise.resolve() .then(() => this._dispatch(value, ctx)) - .catch((err) => { + .then((result) => { + if (typeof settle === 'function') settle(result); + }, (err) => { this.lastError = err; if (this._logger && typeof this._logger.error === 'function') { this._logger.error(err); } - // Swallow: an error must not deadlock the gate. + // Resolve (not reject) so fireAndWait callers don't need + // try/catch. Dispatch errors stay observable via lastError. + if (typeof settle === 'function') settle(undefined); }) .then(() => this._afterDispatch()); } @@ -59,9 +99,9 @@ class LatestWinsGate { _afterDispatch() { this._inFlight = false; if (this._pending) { - const { value, ctx } = this._pending; + const { value, ctx, settle } = this._pending; this._pending = null; - this._run(value, ctx); + this._run(value, ctx, settle); return; } // Idle — release any drain() waiters. @@ -71,4 +111,6 @@ class LatestWinsGate { } } +LatestWinsGate.SUPERSEDED = SUPERSEDED; + module.exports = LatestWinsGate; diff --git a/src/nodered/commandRegistry.js b/src/nodered/commandRegistry.js index 8fe7cae..d4764fb 100644 --- a/src/nodered/commandRegistry.js +++ b/src/nodered/commandRegistry.js @@ -10,8 +10,32 @@ // JSON-Schema. Anything richer belongs in the handler itself, which has // access to logger via ctx. +const convert = require('../convert'); + const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any', 'none']); +function _acceptedList(measure) { + if (convert && typeof convert.possibilities === 'function') { + const list = convert.possibilities(measure); + if (Array.isArray(list) && list.length) return list.join(', '); + } + return '(see convert docs)'; +} + +function _describeUnit(unit) { + try { return convert().describe(unit); } catch (_) { return null; } +} + +function _extractValueAndUnit(msg) { + if (!msg || typeof msg !== 'object') return null; + const p = msg.payload; + if (typeof p === 'number') return { value: p, unit: msg.unit }; + if (p && typeof p === 'object' && typeof p.value === 'number') { + return { value: p.value, unit: p.unit ?? msg.unit }; + } + return null; +} + class CommandRegistry { constructor(commands, options = {}) { if (!Array.isArray(commands)) { @@ -45,11 +69,13 @@ class CommandRegistry { throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`); } } + const units = this._validateUnits(cmd); const descriptor = { topic: cmd.topic, aliases, payloadSchema: cmd.payloadSchema || null, description: typeof cmd.description === 'string' ? cmd.description : null, + units, handler: cmd.handler, }; this._byKey.set(cmd.topic, descriptor); @@ -60,6 +86,17 @@ class CommandRegistry { this._descriptors.push(descriptor); } + _validateUnits(cmd) { + if (cmd.units === undefined || cmd.units === null) return null; + const { measure, default: def } = cmd.units; + if (typeof measure !== 'string' || measure.length === 0 || + typeof def !== 'string' || def.length === 0) { + throw new TypeError( + `command '${cmd.topic}' units requires { measure: string, default: string }`); + } + return { measure, default: def }; + } + has(topic) { return typeof topic === 'string' && this._byKey.has(topic); } @@ -77,6 +114,7 @@ class CommandRegistry { aliases: d.aliases.slice(), payloadSchema: d.payloadSchema, description: d.description, + units: d.units ? { measure: d.units.measure, default: d.units.default } : null, })); } @@ -99,6 +137,7 @@ class CommandRegistry { return; } if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log); + if (descriptor.units) this._normaliseUnits(descriptor, msg, log); if (!this._validatePayload(descriptor, msg, log)) return; return descriptor.handler(source, msg, ctx); } @@ -111,6 +150,40 @@ class CommandRegistry { log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`); } + _normaliseUnits(descriptor, msg, log) { + const { measure, default: defaultUnit } = descriptor.units; + const extracted = _extractValueAndUnit(msg); + if (!extracted) return; // unknown shape — let payload validator handle it + let { value, unit } = extracted; + if (unit === undefined || unit === null || unit === '') { + // No unit supplied — assume default, silent. + msg.payload = value; + msg.unit = defaultUnit; + return; + } + const desc = _describeUnit(unit); + if (!desc) { + log.warn?.(`${descriptor.topic}: unknown unit '${unit}'. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`); + msg.payload = value; + msg.unit = defaultUnit; + return; + } + if (desc.measure !== measure) { + log.warn?.(`${descriptor.topic}: unit '${unit}' is ${desc.measure}, expected ${measure}. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`); + msg.payload = value; + msg.unit = defaultUnit; + return; + } + try { + msg.payload = convert(value).from(unit).to(defaultUnit); + msg.unit = defaultUnit; + } catch (err) { + log.warn?.(`${descriptor.topic}: failed to convert ${value} ${unit} -> ${defaultUnit} (${err.message}). Treating as ${defaultUnit}.`); + msg.payload = value; + msg.unit = defaultUnit; + } + } + _validatePayload(descriptor, msg, log) { const schema = descriptor.payloadSchema; if (!schema) return true; diff --git a/test/basic/LatestWinsGate.basic.test.js b/test/basic/LatestWinsGate.basic.test.js index 4ae8630..c7cd90e 100644 --- a/test/basic/LatestWinsGate.basic.test.js +++ b/test/basic/LatestWinsGate.basic.test.js @@ -150,3 +150,91 @@ test('size reports 0 / 1 / 2 across the lifecycle', async () => { await gate.drain(); assert.equal(gate.size, 0); }); + +test('fireAndWait resolves when the dispatch for that value settles', async () => { + const calls = []; + const gate = new LatestWinsGate(async (v) => { calls.push(v); return `done:${v}`; }); + const result = await gate.fireAndWait('a'); + assert.deepEqual(calls, ['a']); + assert.equal(result, 'done:a'); +}); + +test('fireAndWait while in-flight: caller awaits OWN settlement, not the first call', async () => { + const calls = []; + const d = deferred(); + let count = 0; + const gate = new LatestWinsGate(async (v) => { + calls.push(v); + if (count++ === 0) await d.promise; + return `r:${v}`; + }); + + const p1 = gate.fireAndWait('first'); + // p1 in flight. Park second; second's promise should resolve only + // after second's OWN dispatch runs, not after first's. + const p2 = gate.fireAndWait('second'); + + let p2Settled = false; + p2.then(() => { p2Settled = true; }); + await Promise.resolve(); await Promise.resolve(); + assert.equal(p2Settled, false); + + d.resolve(); + const r1 = await p1; + assert.equal(r1, 'r:first'); + const r2 = await p2; + assert.equal(r2, 'r:second'); + assert.deepEqual(calls, ['first', 'second']); +}); + +test('fireAndWait superseded by a later fireAndWait resolves with { superseded: true }', async () => { + const calls = []; + const d = deferred(); + let count = 0; + const gate = new LatestWinsGate(async (v) => { + calls.push(v); + if (count++ === 0) await d.promise; + }); + + const p1 = gate.fireAndWait('first'); // in flight + const pParked = gate.fireAndWait('parked'); // gets superseded + const pLatest = gate.fireAndWait('latest'); // wins + + d.resolve(); + const supersedeRes = await pParked; + assert.equal(supersedeRes.superseded, true); + + await p1; + await pLatest; + assert.deepEqual(calls, ['first', 'latest']); // 'parked' dropped +}); + +test('fireAndWait + fire intermix: a plain fire supersedes a pending fireAndWait', async () => { + const d = deferred(); + let count = 0; + const calls = []; + const gate = new LatestWinsGate(async (v) => { + calls.push(v); + if (count++ === 0) await d.promise; + }); + + gate.fire('first'); // in flight, no settle + const pParked = gate.fireAndWait('parked'); + gate.fire('latest'); // supersedes parked + + d.resolve(); + const res = await pParked; + assert.equal(res.superseded, true); + await gate.drain(); + assert.deepEqual(calls, ['first', 'latest']); +}); + +test('fireAndWait still resolves (with undefined) when the dispatch throws', async () => { + const errors = []; + const logger = { error: (e) => errors.push(e) }; + const gate = new LatestWinsGate(async () => { throw new Error('kaboom'); }, { logger }); + const r = await gate.fireAndWait('only'); + assert.equal(r, undefined); + assert.equal(errors.length, 1); + assert.ok(gate.lastError instanceof Error); +}); diff --git a/test/basic/commandRegistry.basic.test.js b/test/basic/commandRegistry.basic.test.js index 333171d..9697680 100644 --- a/test/basic/commandRegistry.basic.test.js +++ b/test/basic/commandRegistry.basic.test.js @@ -152,12 +152,14 @@ test('list() returns descriptors without handler functions', () => { aliases: ['setMode'], payloadSchema: { type: 'string' }, description: null, + units: null, }); assert.deepEqual(list[1], { topic: 'cmd.startup', aliases: [], payloadSchema: null, description: null, + units: null, }); for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor'); }); @@ -280,3 +282,155 @@ test('constructor throws when input is not an array', () => { assert.throws(() => createRegistry(null), /array/); assert.throws(() => createRegistry({}), /array/); }); + +// --------------------------------------------------------------------------- +// descriptor.units — Phase 11 pre-dispatch normalisation pipeline +// --------------------------------------------------------------------------- + +test('units: valid unit + correct measure converts to default before handler', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, + }], { logger }); + + await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {}); + assert.equal(seen.length, 1); + assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6, `expected 3600, got ${seen[0].payload}`); + assert.equal(seen[0].unit, 'm3/h'); + assert.equal(logger._calls.warn.length, 0); +}); + +test('units: wrong measure warns + lists accepted + falls back to default unit', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, + }], { logger }); + + await reg.dispatch({ topic: 'set.demand', payload: 42, unit: 'mbar' }, {}, {}); + assert.equal(seen.length, 1); + assert.equal(seen[0].payload, 42); + assert.equal(seen[0].unit, 'm3/h'); + const warns = logger._calls.warn; + assert.equal(warns.length, 1); + assert.match(warns[0], /set\.demand/); + assert.match(warns[0], /'mbar'/); + assert.match(warns[0], /pressure/); + assert.match(warns[0], /volumeFlowRate/); + assert.match(warns[0], /m3\/h/); // accepted list contains the default + assert.match(warns[0], /Treating 42 as m3\/h/); +}); + +test('units: unknown unit warns + lists accepted + falls back to default', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, + }], { logger }); + + await reg.dispatch({ topic: 'set.demand', payload: 7, unit: 'flarbargs' }, {}, {}); + assert.equal(seen.length, 1); + assert.equal(seen[0].payload, 7); + assert.equal(seen[0].unit, 'm3/h'); + const warns = logger._calls.warn; + assert.equal(warns.length, 1); + assert.match(warns[0], /unknown unit 'flarbargs'/); + assert.match(warns[0], /m3\/h/); + assert.match(warns[0], /Treating 7 as m3\/h/); +}); + +test('units: no unit at all — handler gets raw value tagged with default unit, silent', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, + }], { logger }); + + await reg.dispatch({ topic: 'set.demand', payload: 12 }, {}, {}); + assert.equal(seen.length, 1); + assert.equal(seen[0].payload, 12); + assert.equal(seen[0].unit, 'm3/h'); + assert.equal(logger._calls.warn.length, 0); +}); + +test('units: object payload {value, unit} normalises the same as msg.payload+msg.unit', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.pressure', + units: { measure: 'pressure', default: 'Pa' }, + handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, + }], { logger }); + + await reg.dispatch({ topic: 'set.pressure', payload: { value: 5, unit: 'mbar' } }, {}, {}); + assert.equal(seen.length, 1); + assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`); + assert.equal(seen[0].unit, 'Pa'); + assert.equal(logger._calls.warn.length, 0); +}); + +test('units: object payload {value} without unit falls back to default unit silently', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.pressure', + units: { measure: 'pressure', default: 'Pa' }, + handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); }, + }], { logger }); + + await reg.dispatch({ topic: 'set.pressure', payload: { value: 100 } }, {}, {}); + assert.equal(seen.length, 1); + assert.equal(seen[0].payload, 100); + assert.equal(seen[0].unit, 'Pa'); + assert.equal(logger._calls.warn.length, 0); +}); + +test('units: non-numeric payload (no normalisation applied) passes through to handler', async () => { + const logger = makeLogger(); + const seen = []; + const reg = createRegistry([{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + handler: (_s, msg) => { seen.push(msg.payload); }, + }], { logger }); + + // string payload — not normalisable. Should not crash; handler still fires. + await reg.dispatch({ topic: 'set.demand', payload: 'magic' }, {}, {}); + assert.equal(seen.length, 1); + assert.equal(seen[0], 'magic'); +}); + +test('units: missing default field throws at construction', () => { + assert.throws(() => createRegistry([{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate' }, + handler: () => {}, + }]), /units requires/); +}); + +test('units: missing measure field throws at construction', () => { + assert.throws(() => createRegistry([{ + topic: 'set.demand', + units: { default: 'm3/h' }, + handler: () => {}, + }]), /units requires/); +}); + +test('units: descriptor.units surfaces in list() output', () => { + const reg = createRegistry([ + { topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: () => {} }, + { topic: 'set.mode', handler: () => {} }, + ]); + const list = reg.list(); + assert.deepEqual(list[0].units, { measure: 'volumeFlowRate', default: 'm3/h' }); + assert.equal(list[1].units, null); +}); diff --git a/test/basic/convert.basic.test.js b/test/basic/convert.basic.test.js new file mode 100644 index 0000000..7faf33b --- /dev/null +++ b/test/basic/convert.basic.test.js @@ -0,0 +1,90 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const convert = require('../../src/convert/index.js'); + +test('convert.possibilities — exported as a top-level function', () => { + assert.equal(typeof convert.possibilities, 'function'); +}); + +test('convert.possibilities(volumeFlowRate) returns common flow units', () => { + const units = convert.possibilities('volumeFlowRate'); + assert.ok(Array.isArray(units)); + assert.ok(units.length > 0); + for (const u of ['m3/s', 'm3/h', 'l/s', 'l/min', 'l/h']) { + assert.ok(units.includes(u), `expected '${u}' in volumeFlowRate possibilities`); + } +}); + +test('convert.possibilities(pressure) returns common pressure units', () => { + const units = convert.possibilities('pressure'); + for (const u of ['Pa', 'kPa', 'bar', 'mbar', 'psi']) { + assert.ok(units.includes(u), `expected '${u}' in pressure possibilities`); + } +}); + +test('convert.possibilities(power) returns common power units', () => { + const units = convert.possibilities('power'); + for (const u of ['W', 'kW', 'MW']) { + assert.ok(units.includes(u), `expected '${u}' in power possibilities`); + } +}); + +test('convert.possibilities(temperature) returns K, C, F', () => { + const units = convert.possibilities('temperature'); + for (const u of ['K', 'C', 'F']) { + assert.ok(units.includes(u), `expected '${u}' in temperature possibilities`); + } +}); + +test('convert.possibilities for length / mass / volume return non-empty', () => { + assert.ok(convert.possibilities('length').includes('m')); + assert.ok(convert.possibilities('mass').includes('kg')); + assert.ok(convert.possibilities('volume').includes('l')); +}); + +test('convert.possibilities(unknown) returns []', () => { + assert.deepEqual(convert.possibilities('foo'), []); + assert.deepEqual(convert.possibilities('bogus-measure'), []); +}); + +test('convert.possibilities handles invalid input safely', () => { + assert.deepEqual(convert.possibilities(), []); + assert.deepEqual(convert.possibilities(null), []); + assert.deepEqual(convert.possibilities(''), []); + assert.deepEqual(convert.possibilities(42), []); +}); + +test('convert.possibilities is sorted and deduplicated', () => { + const units = convert.possibilities('pressure'); + const sorted = [...units].sort(); + assert.deepEqual(units, sorted, 'result should be alphabetically sorted'); + const set = new Set(units); + assert.equal(set.size, units.length, 'result should have no duplicates'); +}); + +test('convert.possibilities returns stable / cached results across calls', () => { + const a = convert.possibilities('volumeFlowRate'); + const b = convert.possibilities('volumeFlowRate'); + assert.deepEqual(a, b, 'two calls must return equal arrays'); + // Mutating the returned array must not poison the cache. + a.push('SHOULD_NOT_PERSIST'); + const c = convert.possibilities('volumeFlowRate'); + assert.ok(!c.includes('SHOULD_NOT_PERSIST'), 'cached array must be defensively copied'); + assert.deepEqual(c, b); +}); + +test('convert.measures lists known measure names', () => { + const m = convert.measures(); + assert.ok(Array.isArray(m)); + for (const name of ['length', 'mass', 'volume', 'pressure', 'power', 'temperature', 'volumeFlowRate']) { + assert.ok(m.includes(name), `expected measure '${name}'`); + } +}); + +test('convert factory still works (regression — no breakage of existing API)', () => { + const result = convert(1).from('m').to('cm'); + assert.equal(result, 100); +});