Compare commits
1 Commits
f11754635b
...
5ea968eabc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea968eabc |
@@ -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."
|
"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": {
|
"outlierDetection": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"default": false,
|
"default": false,
|
||||||
|
|||||||
@@ -251,6 +251,34 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Minimum inner diameter of the intake tubing in millimeters."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,4 +301,26 @@ convert = function (value) {
|
|||||||
return new Converter(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;
|
module.exports = convert;
|
||||||
|
|||||||
@@ -2,9 +2,22 @@
|
|||||||
|
|
||||||
// Serialises an async dispatch so that high-frequency callers cannot stack
|
// Serialises an async dispatch so that high-frequency callers cannot stack
|
||||||
// up overlapping invocations. Intermediate values are dropped — only the
|
// up overlapping invocations. Intermediate values are dropped — only the
|
||||||
// most recent fire() during an in-flight dispatch is replayed afterwards.
|
// most recent fire()/fireAndWait() during an in-flight dispatch is replayed
|
||||||
// Extracted from machineGroupControl's _dispatchInFlight + _delayedCall
|
// afterwards. Extracted from machineGroupControl's _dispatchInFlight +
|
||||||
// pattern so MGC, pumpingStation, valveGroupControl etc. can share it.
|
// _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 {
|
class LatestWinsGate {
|
||||||
constructor(asyncDispatchFn, options = {}) {
|
constructor(asyncDispatchFn, options = {}) {
|
||||||
@@ -14,7 +27,7 @@ class LatestWinsGate {
|
|||||||
this._dispatch = asyncDispatchFn;
|
this._dispatch = asyncDispatchFn;
|
||||||
this._logger = options.logger || null;
|
this._logger = options.logger || null;
|
||||||
this._inFlight = false;
|
this._inFlight = false;
|
||||||
this._pending = null; // { value, ctx } | null
|
this._pending = null; // { value, ctx, settle? } | null
|
||||||
this._drainResolvers = []; // resolved when idle again
|
this._drainResolvers = []; // resolved when idle again
|
||||||
this.lastError = null;
|
this.lastError = null;
|
||||||
}
|
}
|
||||||
@@ -25,14 +38,31 @@ class LatestWinsGate {
|
|||||||
return this._pending ? 2 : 1;
|
return this._pending ? 2 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never blocks the caller. If a dispatch is in flight, the latest
|
// Never blocks. If a dispatch is in flight, the latest value is parked;
|
||||||
// value is parked; older parked values are silently overwritten.
|
// older parked values are silently overwritten.
|
||||||
fire(value, ctx) {
|
fire(value, ctx) {
|
||||||
if (this._inFlight) {
|
if (this._inFlight) {
|
||||||
this._pending = { value, ctx };
|
this._supersedePending();
|
||||||
|
this._pending = { value, ctx, settle: null };
|
||||||
return;
|
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() {
|
drain() {
|
||||||
@@ -40,18 +70,28 @@ class LatestWinsGate {
|
|||||||
return new Promise((resolve) => { this._drainResolvers.push(resolve); });
|
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;
|
this._inFlight = true;
|
||||||
// Kick the dispatch on a microtask so fire() always returns
|
// Kick the dispatch on a microtask so fire()/fireAndWait() always
|
||||||
// synchronously, even if _dispatch resolves immediately.
|
// return synchronously, even if _dispatch resolves immediately.
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => this._dispatch(value, ctx))
|
.then(() => this._dispatch(value, ctx))
|
||||||
.catch((err) => {
|
.then((result) => {
|
||||||
|
if (typeof settle === 'function') settle(result);
|
||||||
|
}, (err) => {
|
||||||
this.lastError = err;
|
this.lastError = err;
|
||||||
if (this._logger && typeof this._logger.error === 'function') {
|
if (this._logger && typeof this._logger.error === 'function') {
|
||||||
this._logger.error(err);
|
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());
|
.then(() => this._afterDispatch());
|
||||||
}
|
}
|
||||||
@@ -59,9 +99,9 @@ class LatestWinsGate {
|
|||||||
_afterDispatch() {
|
_afterDispatch() {
|
||||||
this._inFlight = false;
|
this._inFlight = false;
|
||||||
if (this._pending) {
|
if (this._pending) {
|
||||||
const { value, ctx } = this._pending;
|
const { value, ctx, settle } = this._pending;
|
||||||
this._pending = null;
|
this._pending = null;
|
||||||
this._run(value, ctx);
|
this._run(value, ctx, settle);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Idle — release any drain() waiters.
|
// Idle — release any drain() waiters.
|
||||||
@@ -71,4 +111,6 @@ class LatestWinsGate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LatestWinsGate.SUPERSEDED = SUPERSEDED;
|
||||||
|
|
||||||
module.exports = LatestWinsGate;
|
module.exports = LatestWinsGate;
|
||||||
|
|||||||
@@ -10,8 +10,32 @@
|
|||||||
// JSON-Schema. Anything richer belongs in the handler itself, which has
|
// JSON-Schema. Anything richer belongs in the handler itself, which has
|
||||||
// access to logger via ctx.
|
// access to logger via ctx.
|
||||||
|
|
||||||
|
const convert = require('../convert');
|
||||||
|
|
||||||
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any', 'none']);
|
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 {
|
class CommandRegistry {
|
||||||
constructor(commands, options = {}) {
|
constructor(commands, options = {}) {
|
||||||
if (!Array.isArray(commands)) {
|
if (!Array.isArray(commands)) {
|
||||||
@@ -45,11 +69,13 @@ class CommandRegistry {
|
|||||||
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
|
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const units = this._validateUnits(cmd);
|
||||||
const descriptor = {
|
const descriptor = {
|
||||||
topic: cmd.topic,
|
topic: cmd.topic,
|
||||||
aliases,
|
aliases,
|
||||||
payloadSchema: cmd.payloadSchema || null,
|
payloadSchema: cmd.payloadSchema || null,
|
||||||
description: typeof cmd.description === 'string' ? cmd.description : null,
|
description: typeof cmd.description === 'string' ? cmd.description : null,
|
||||||
|
units,
|
||||||
handler: cmd.handler,
|
handler: cmd.handler,
|
||||||
};
|
};
|
||||||
this._byKey.set(cmd.topic, descriptor);
|
this._byKey.set(cmd.topic, descriptor);
|
||||||
@@ -60,6 +86,17 @@ class CommandRegistry {
|
|||||||
this._descriptors.push(descriptor);
|
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) {
|
has(topic) {
|
||||||
return typeof topic === 'string' && this._byKey.has(topic);
|
return typeof topic === 'string' && this._byKey.has(topic);
|
||||||
}
|
}
|
||||||
@@ -77,6 +114,7 @@ class CommandRegistry {
|
|||||||
aliases: d.aliases.slice(),
|
aliases: d.aliases.slice(),
|
||||||
payloadSchema: d.payloadSchema,
|
payloadSchema: d.payloadSchema,
|
||||||
description: d.description,
|
description: d.description,
|
||||||
|
units: d.units ? { measure: d.units.measure, default: d.units.default } : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +137,7 @@ class CommandRegistry {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
|
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;
|
if (!this._validatePayload(descriptor, msg, log)) return;
|
||||||
return descriptor.handler(source, msg, ctx);
|
return descriptor.handler(source, msg, ctx);
|
||||||
}
|
}
|
||||||
@@ -111,6 +150,40 @@ class CommandRegistry {
|
|||||||
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
|
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) {
|
_validatePayload(descriptor, msg, log) {
|
||||||
const schema = descriptor.payloadSchema;
|
const schema = descriptor.payloadSchema;
|
||||||
if (!schema) return true;
|
if (!schema) return true;
|
||||||
|
|||||||
@@ -150,3 +150,91 @@ test('size reports 0 / 1 / 2 across the lifecycle', async () => {
|
|||||||
await gate.drain();
|
await gate.drain();
|
||||||
assert.equal(gate.size, 0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -152,12 +152,14 @@ test('list() returns descriptors without handler functions', () => {
|
|||||||
aliases: ['setMode'],
|
aliases: ['setMode'],
|
||||||
payloadSchema: { type: 'string' },
|
payloadSchema: { type: 'string' },
|
||||||
description: null,
|
description: null,
|
||||||
|
units: null,
|
||||||
});
|
});
|
||||||
assert.deepEqual(list[1], {
|
assert.deepEqual(list[1], {
|
||||||
topic: 'cmd.startup',
|
topic: 'cmd.startup',
|
||||||
aliases: [],
|
aliases: [],
|
||||||
payloadSchema: null,
|
payloadSchema: null,
|
||||||
description: null,
|
description: null,
|
||||||
|
units: null,
|
||||||
});
|
});
|
||||||
for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor');
|
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(null), /array/);
|
||||||
assert.throws(() => createRegistry({}), /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);
|
||||||
|
});
|
||||||
|
|||||||
90
test/basic/convert.basic.test.js
Normal file
90
test/basic/convert.basic.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user