Phase 1 wave 1: domain + nodered + stats infra (additive)
Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:
- src/domain/UnitPolicy.js — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js — standardised {level, flags, message, source}
- src/nodered/statusBadge.js — compose / error / idle / byState / text helpers
- src/stats/index.js — mean / stdDev / median / mad / lerp
All additive — no existing exports change shape.
56 unit tests pass under node:test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
src/domain/ChildRouter.js
Normal file
184
src/domain/ChildRouter.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* ChildRouter — declarative parent-side child registration & event routing.
|
||||
*
|
||||
* Replaces the per-node `registerChild` switch + manual
|
||||
* `child.measurements.emitter.on(...)` wiring repeated in pumpingStation,
|
||||
* rotatingMachine and machineGroupControl.
|
||||
*
|
||||
* See CONTRACTS.md §5. Built on top of `childRegistrationUtils`, which
|
||||
* already canonicalises softwareType (e.g. rotatingmachine → machine).
|
||||
*/
|
||||
|
||||
// Same alias map as childRegistrationUtils. Duplicated rather than imported
|
||||
// because we need to canonicalise inputs to onRegister/onMeasurement/onPrediction
|
||||
// at *declaration* time (before any child has registered), so that a domain
|
||||
// can write `onRegister('rotatingmachine', ...)` or `onRegister('machine', ...)`
|
||||
// interchangeably and have the dispatch match.
|
||||
const SOFTWARE_TYPE_ALIASES = {
|
||||
rotatingmachine: 'machine',
|
||||
machinegroupcontrol: 'machinegroup',
|
||||
};
|
||||
|
||||
function canonicalType(rawType) {
|
||||
const t = String(rawType || '').toLowerCase();
|
||||
return SOFTWARE_TYPE_ALIASES[t] || t;
|
||||
}
|
||||
|
||||
class ChildRouter {
|
||||
constructor(domain) {
|
||||
this.domain = domain;
|
||||
this.logger = domain?.logger || null;
|
||||
|
||||
// Subscription tables, keyed by canonical softwareType.
|
||||
this._registerSubs = new Map(); // softwareType -> Array<fn>
|
||||
this._measurementSubs = new Map(); // softwareType -> Array<{filter, fn}>
|
||||
this._predictionSubs = new Map(); // softwareType -> Array<{filter, fn}>
|
||||
|
||||
// Track every emitter listener we attach so tearDown can remove them.
|
||||
this._attached = [];
|
||||
}
|
||||
|
||||
// ── declaration API ────────────────────────────────────────────────
|
||||
|
||||
onRegister(softwareType, fn) {
|
||||
if (typeof fn !== 'function') {
|
||||
throw new TypeError('ChildRouter.onRegister: fn must be a function');
|
||||
}
|
||||
const key = canonicalType(softwareType);
|
||||
if (!this._registerSubs.has(key)) this._registerSubs.set(key, []);
|
||||
this._registerSubs.get(key).push(fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
onMeasurement(softwareType, filter, fn) {
|
||||
return this._addEventSub(this._measurementSubs, softwareType, filter, fn, 'onMeasurement');
|
||||
}
|
||||
|
||||
onPrediction(softwareType, filter, fn) {
|
||||
return this._addEventSub(this._predictionSubs, softwareType, filter, fn, 'onPrediction');
|
||||
}
|
||||
|
||||
_addEventSub(table, softwareType, filter, fn, label) {
|
||||
if (typeof filter === 'function' && fn === undefined) {
|
||||
// Allow `onMeasurement(type, fn)` shorthand — no filter.
|
||||
fn = filter;
|
||||
filter = {};
|
||||
}
|
||||
if (typeof fn !== 'function') {
|
||||
throw new TypeError(`ChildRouter.${label}: fn must be a function`);
|
||||
}
|
||||
const key = canonicalType(softwareType);
|
||||
if (!table.has(key)) table.set(key, []);
|
||||
table.get(key).push({ filter: filter || {}, fn });
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by the domain's registerChild(). Runs onRegister handlers, then
|
||||
* attaches measurement/prediction listeners on the child's emitter.
|
||||
*/
|
||||
dispatchRegister(child, softwareType) {
|
||||
const key = canonicalType(softwareType);
|
||||
|
||||
const regHandlers = this._registerSubs.get(key) || [];
|
||||
for (const fn of regHandlers) {
|
||||
try { fn.call(this.domain, child, key); }
|
||||
catch (err) { this._logHandlerError('onRegister', key, err); }
|
||||
}
|
||||
|
||||
const emitter = child?.measurements?.emitter;
|
||||
if (!emitter || typeof emitter.on !== 'function') return;
|
||||
|
||||
this._attachVariantListeners(child, key, emitter, 'measured', this._measurementSubs);
|
||||
this._attachVariantListeners(child, key, emitter, 'predicted', this._predictionSubs);
|
||||
}
|
||||
|
||||
_attachVariantListeners(child, key, emitter, variant, table) {
|
||||
const subs = table.get(key) || [];
|
||||
for (const { filter, fn } of subs) {
|
||||
// Build the set of (type, position) tuples this sub matches. If a filter
|
||||
// omits one or both of {type, position}, we can't pre-enumerate the event
|
||||
// names — fall back to a wildcard listener via `emit`-time matching.
|
||||
if (filter.type && filter.position) {
|
||||
const eventName = `${filter.type}.${variant}.${String(filter.position).toLowerCase()}`;
|
||||
this._attach(emitter, eventName, (data) => this._invoke(fn, data, child, variant));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wildcard: subscribe to a generic catch-all by patching emitter.emit.
|
||||
// EventEmitter has no built-in wildcard — install a one-off proxy listener
|
||||
// that intercepts every emit on this emitter and filters by name.
|
||||
const proxyKey = `__childRouter_proxy_${variant}__`;
|
||||
if (!emitter[proxyKey]) {
|
||||
const origEmit = emitter.emit.bind(emitter);
|
||||
const proxies = [];
|
||||
emitter[proxyKey] = proxies;
|
||||
emitter.emit = (eventName, ...args) => {
|
||||
const parts = String(eventName).split('.');
|
||||
if (parts.length === 3 && parts[1] === variant) {
|
||||
for (const p of proxies) p({ type: parts[0], position: parts[2], args });
|
||||
}
|
||||
return origEmit(eventName, ...args);
|
||||
};
|
||||
// Track the proxy install for tearDown to undo.
|
||||
this._attached.push({ emitter, kind: 'proxy', variant, original: origEmit, proxyKey });
|
||||
}
|
||||
const proxyFn = ({ type, position, args }) => {
|
||||
if (filter.type && type !== filter.type) return;
|
||||
if (filter.position && position !== String(filter.position).toLowerCase()) return;
|
||||
this._invoke(fn, args[0], child, variant);
|
||||
};
|
||||
emitter[proxyKey].push(proxyFn);
|
||||
this._attached.push({ emitter, kind: 'proxyEntry', proxyKey, proxyFn });
|
||||
}
|
||||
}
|
||||
|
||||
_attach(emitter, eventName, listener) {
|
||||
emitter.on(eventName, listener);
|
||||
this._attached.push({ emitter, kind: 'listener', eventName, listener });
|
||||
}
|
||||
|
||||
_invoke(fn, eventData, child, variant) {
|
||||
try { fn.call(this.domain, eventData, child); }
|
||||
catch (err) { this._logHandlerError(`on${variant === 'measured' ? 'Measurement' : 'Prediction'}`, '', err); }
|
||||
}
|
||||
|
||||
_logHandlerError(kind, key, err) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(`ChildRouter ${kind}${key ? `[${key}]` : ''} handler threw: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── teardown ──────────────────────────────────────────────────────
|
||||
|
||||
tearDown() {
|
||||
// Two passes: drop concrete listeners + proxy entries first, then unwrap
|
||||
// any proxies whose entry list is now empty. Order matters — restoring
|
||||
// emit before clearing entries would leave dangling proxy state.
|
||||
for (const rec of this._attached) {
|
||||
if (rec.kind === 'listener') {
|
||||
if (typeof rec.emitter.off === 'function') rec.emitter.off(rec.eventName, rec.listener);
|
||||
else if (typeof rec.emitter.removeListener === 'function') rec.emitter.removeListener(rec.eventName, rec.listener);
|
||||
} else if (rec.kind === 'proxyEntry') {
|
||||
const proxies = rec.emitter[rec.proxyKey];
|
||||
if (Array.isArray(proxies)) {
|
||||
const idx = proxies.indexOf(rec.proxyFn);
|
||||
if (idx >= 0) proxies.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rec of this._attached) {
|
||||
if (rec.kind !== 'proxy') continue;
|
||||
const proxies = rec.emitter[rec.proxyKey];
|
||||
if (!Array.isArray(proxies) || proxies.length === 0) {
|
||||
rec.emitter.emit = rec.original;
|
||||
delete rec.emitter[rec.proxyKey];
|
||||
}
|
||||
}
|
||||
this._attached = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChildRouter;
|
||||
102
src/domain/HealthStatus.js
Normal file
102
src/domain/HealthStatus.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* HealthStatus — standardised health/quality datum.
|
||||
* Contract: see .claude/refactor/CONTRACTS.md §9.
|
||||
*
|
||||
* Shape (always frozen):
|
||||
* { level: 0|1|2|3, flags: string[], message: string, source: string|null }
|
||||
*
|
||||
* level 0 = nominal, 3 = unusable. Returned objects are frozen plain
|
||||
* objects (not class instances) so they round-trip cleanly through
|
||||
* JSON / InfluxDB serialisation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const LABELS = ['nominal', 'minor', 'major', 'critical'];
|
||||
|
||||
function _freeze(level, flags, message, source) {
|
||||
return Object.freeze({
|
||||
level,
|
||||
flags: Object.freeze(flags.slice()),
|
||||
message,
|
||||
source: source == null ? null : String(source),
|
||||
});
|
||||
}
|
||||
|
||||
function _coerceDegradedLevel(level) {
|
||||
const n = Math.trunc(Number(level));
|
||||
if (!Number.isFinite(n) || n < 1) return 1;
|
||||
if (n > 3) return 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
function _coerceFlags(flags) {
|
||||
if (!Array.isArray(flags)) return [];
|
||||
const out = [];
|
||||
for (const f of flags) {
|
||||
if (f == null) continue;
|
||||
out.push(String(f));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ok(message, source) {
|
||||
return _freeze(
|
||||
0,
|
||||
[],
|
||||
typeof message === 'string' && message.length > 0 ? message : 'nominal',
|
||||
source != null ? source : null,
|
||||
);
|
||||
}
|
||||
|
||||
function degraded(level, flags, message, source) {
|
||||
const lvl = _coerceDegradedLevel(level);
|
||||
const f = _coerceFlags(flags);
|
||||
const m = typeof message === 'string' && message.length > 0
|
||||
? message
|
||||
: LABELS[lvl];
|
||||
return _freeze(lvl, f, m, source != null ? source : null);
|
||||
}
|
||||
|
||||
// Merge multiple statuses into one node-level status. Worst level wins
|
||||
// for level/message/source; flags are concatenated and de-duped.
|
||||
function compose(statuses) {
|
||||
if (!Array.isArray(statuses) || statuses.length === 0) return ok();
|
||||
|
||||
let worst = null;
|
||||
const seen = new Set();
|
||||
const flags = [];
|
||||
|
||||
for (const s of statuses) {
|
||||
if (!s || typeof s !== 'object') continue;
|
||||
const lvl = Number.isFinite(s.level) ? s.level : 0;
|
||||
if (worst === null || lvl > worst.level) {
|
||||
worst = { level: lvl, message: s.message, source: s.source ?? null };
|
||||
}
|
||||
if (Array.isArray(s.flags)) {
|
||||
for (const f of s.flags) {
|
||||
if (f == null) continue;
|
||||
const k = String(f);
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
flags.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (worst === null) return ok();
|
||||
|
||||
const message = typeof worst.message === 'string' && worst.message.length > 0
|
||||
? worst.message
|
||||
: LABELS[Math.max(0, Math.min(3, worst.level))];
|
||||
return _freeze(worst.level, flags, message, worst.source);
|
||||
}
|
||||
|
||||
function label(level) {
|
||||
const n = Math.trunc(Number(level));
|
||||
if (!Number.isFinite(n) || n < 0 || n > 3) return 'unknown';
|
||||
return LABELS[n];
|
||||
}
|
||||
|
||||
module.exports = { ok, degraded, compose, label };
|
||||
74
src/domain/LatestWinsGate.js
Normal file
74
src/domain/LatestWinsGate.js
Normal file
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
// 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.
|
||||
|
||||
class LatestWinsGate {
|
||||
constructor(asyncDispatchFn, options = {}) {
|
||||
if (typeof asyncDispatchFn !== 'function') {
|
||||
throw new TypeError('LatestWinsGate requires an async dispatch function');
|
||||
}
|
||||
this._dispatch = asyncDispatchFn;
|
||||
this._logger = options.logger || null;
|
||||
this._inFlight = false;
|
||||
this._pending = null; // { value, ctx } | null
|
||||
this._drainResolvers = []; // resolved when idle again
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
// 0 = idle, 1 = running with no pending, 2 = running with pending.
|
||||
get size() {
|
||||
if (!this._inFlight) return 0;
|
||||
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.
|
||||
fire(value, ctx) {
|
||||
if (this._inFlight) {
|
||||
this._pending = { value, ctx };
|
||||
return;
|
||||
}
|
||||
this._run(value, ctx);
|
||||
}
|
||||
|
||||
drain() {
|
||||
if (!this._inFlight && !this._pending) return Promise.resolve();
|
||||
return new Promise((resolve) => { this._drainResolvers.push(resolve); });
|
||||
}
|
||||
|
||||
_run(value, ctx) {
|
||||
this._inFlight = true;
|
||||
// Kick the dispatch on a microtask so fire() always returns
|
||||
// synchronously, even if _dispatch resolves immediately.
|
||||
Promise.resolve()
|
||||
.then(() => this._dispatch(value, ctx))
|
||||
.catch((err) => {
|
||||
this.lastError = err;
|
||||
if (this._logger && typeof this._logger.error === 'function') {
|
||||
this._logger.error(err);
|
||||
}
|
||||
// Swallow: an error must not deadlock the gate.
|
||||
})
|
||||
.then(() => this._afterDispatch());
|
||||
}
|
||||
|
||||
_afterDispatch() {
|
||||
this._inFlight = false;
|
||||
if (this._pending) {
|
||||
const { value, ctx } = this._pending;
|
||||
this._pending = null;
|
||||
this._run(value, ctx);
|
||||
return;
|
||||
}
|
||||
// Idle — release any drain() waiters.
|
||||
const waiters = this._drainResolvers;
|
||||
this._drainResolvers = [];
|
||||
for (const r of waiters) r();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LatestWinsGate;
|
||||
149
src/domain/UnitPolicy.js
Normal file
149
src/domain/UnitPolicy.js
Normal file
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
96
src/nodered/statusBadge.js
Normal file
96
src/nodered/statusBadge.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* statusBadge — small helpers that build Node-RED status objects
|
||||
* ({ fill, shape, text }) consistently across every node.
|
||||
*
|
||||
* See CONTRACTS.md §7. Domains compose badges via these helpers so the
|
||||
* editor look-and-feel converges instead of every node rolling its own
|
||||
* emoji + colour rules.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const MAX_TEXT = 60;
|
||||
const SEPARATOR = ' | ';
|
||||
|
||||
const DEFAULT_BADGE = { fill: 'green', shape: 'dot' };
|
||||
const ERROR_BADGE = { fill: 'red', shape: 'ring' };
|
||||
const IDLE_BADGE = { fill: 'blue', shape: 'dot' };
|
||||
const UNKNOWN_BADGE = { fill: 'grey', shape: 'ring' };
|
||||
|
||||
// Truncate to MAX_TEXT keeping room for the ellipsis. Editor clips the
|
||||
// rest visually anyway, but we want the cut to be deterministic so
|
||||
// snapshot tests don't drift across Node-RED versions.
|
||||
function _clip(text) {
|
||||
if (text == null) return '';
|
||||
const s = String(text);
|
||||
if (s.length <= MAX_TEXT) return s;
|
||||
return s.slice(0, MAX_TEXT - 1) + '…';
|
||||
}
|
||||
|
||||
function _joinParts(parts) {
|
||||
if (!Array.isArray(parts) || parts.length === 0) return '';
|
||||
const kept = parts.filter((p) => p != null && p !== false && p !== '');
|
||||
if (kept.length === 0) return '';
|
||||
return kept.map(String).join(SEPARATOR);
|
||||
}
|
||||
|
||||
function compose(parts, opts) {
|
||||
const text = _clip(_joinParts(parts));
|
||||
return {
|
||||
fill: (opts && opts.fill) || DEFAULT_BADGE.fill,
|
||||
shape: (opts && opts.shape) || DEFAULT_BADGE.shape,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
return {
|
||||
fill: ERROR_BADGE.fill,
|
||||
shape: ERROR_BADGE.shape,
|
||||
text: _clip(`⚠ ${message == null ? '' : message}`),
|
||||
};
|
||||
}
|
||||
|
||||
function idle(label) {
|
||||
return {
|
||||
fill: IDLE_BADGE.fill,
|
||||
shape: IDLE_BADGE.shape,
|
||||
text: _clip(`⏸️ ${label == null ? '' : label}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Look up a state-template badge and optionally compose extra parts
|
||||
// into its text. Missing template falls back to a grey "unknown state"
|
||||
// badge — silent so caller can still surface the bad state through logs.
|
||||
function byState(stateMap, currentState, opts) {
|
||||
const template = stateMap && stateMap[currentState];
|
||||
if (!template) {
|
||||
return {
|
||||
fill: UNKNOWN_BADGE.fill,
|
||||
shape: UNKNOWN_BADGE.shape,
|
||||
text: _clip(`unknown state: ${currentState == null ? '' : currentState}`),
|
||||
};
|
||||
}
|
||||
const baseText = template.text == null ? '' : String(template.text);
|
||||
const extras = opts && Array.isArray(opts.compose) ? opts.compose : [];
|
||||
const merged = extras.length > 0
|
||||
? _joinParts([baseText, ...extras])
|
||||
: baseText;
|
||||
return {
|
||||
fill: template.fill || DEFAULT_BADGE.fill,
|
||||
shape: template.shape || DEFAULT_BADGE.shape,
|
||||
text: _clip(merged),
|
||||
};
|
||||
}
|
||||
|
||||
function text(string, opts) {
|
||||
return {
|
||||
fill: (opts && opts.fill) || DEFAULT_BADGE.fill,
|
||||
shape: (opts && opts.shape) || DEFAULT_BADGE.shape,
|
||||
text: _clip(string == null ? '' : string),
|
||||
};
|
||||
}
|
||||
|
||||
const statusBadge = { compose, error, idle, byState, text };
|
||||
|
||||
module.exports = { statusBadge, MAX_TEXT };
|
||||
52
src/stats/index.js
Normal file
52
src/stats/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Reducer-shape stats helpers shared across the platform.
|
||||
*
|
||||
* These were duplicated as static helpers on `Channel` and as instance
|
||||
* methods on the older `measurement/specificClass.js`. Consolidated here so
|
||||
* any consumer (outlier detection, monster summaries, future analytics)
|
||||
* can import a single canonical implementation.
|
||||
*
|
||||
* Stream-shape filters (low/high/band-pass, kalman, savitzky-golay) stay
|
||||
* on Channel as static helpers — they're pipeline state, not reducers.
|
||||
*/
|
||||
|
||||
function mean(arr) {
|
||||
if (!arr.length) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
// Sample std dev (n-1 denominator). A single sample has no variance to
|
||||
// estimate, so we return 0 rather than NaN — callers (e.g. z-score) treat
|
||||
// 0 as "no spread yet" and skip rejection.
|
||||
function stdDev(arr) {
|
||||
if (arr.length <= 1) return 0;
|
||||
const m = mean(arr);
|
||||
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
function median(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function mad(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const med = median(arr);
|
||||
return median(arr.map((v) => Math.abs(v - med)));
|
||||
}
|
||||
|
||||
// Degenerate-range pass-through matches Channel._lerp: callers rely on it
|
||||
// for early-warmup paths where input bounds haven't separated yet.
|
||||
function lerp(value, iMin, iMax, oMin, oMax) {
|
||||
if (iMin >= iMax) return value;
|
||||
return oMin + ((value - iMin) * (oMax - oMin)) / (iMax - iMin);
|
||||
}
|
||||
|
||||
module.exports = { mean, stdDev, median, mad, lerp };
|
||||
197
test/basic/ChildRouter.basic.test.js
Normal file
197
test/basic/ChildRouter.basic.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const ChildRouter = require('../../src/domain/ChildRouter');
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function makeDomain() {
|
||||
const logs = [];
|
||||
return {
|
||||
logger: {
|
||||
debug: (...a) => logs.push(['debug', ...a]),
|
||||
info: (...a) => logs.push(['info', ...a]),
|
||||
warn: (...a) => logs.push(['warn', ...a]),
|
||||
error: (...a) => logs.push(['error', ...a]),
|
||||
},
|
||||
_logs: logs,
|
||||
};
|
||||
}
|
||||
|
||||
function makeChild({ id = 'c1', name = id, softwareType = 'measurement' } = {}) {
|
||||
return {
|
||||
config: {
|
||||
general: { id, name },
|
||||
functionality: { softwareType },
|
||||
asset: { type: 'pressure' },
|
||||
},
|
||||
measurements: { emitter: new EventEmitter() },
|
||||
};
|
||||
}
|
||||
|
||||
function emitMeasured(child, type, position, value, extra = {}) {
|
||||
child.measurements.emitter.emit(`${type}.measured.${position}`, { value, ...extra });
|
||||
}
|
||||
|
||||
function emitPredicted(child, type, position, value, extra = {}) {
|
||||
child.measurements.emitter.emit(`${type}.predicted.${position}`, { value, ...extra });
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────
|
||||
|
||||
test('onRegister fires for the matching softwareType', () => {
|
||||
const domain = makeDomain();
|
||||
const router = new ChildRouter(domain);
|
||||
const seen = [];
|
||||
|
||||
router.onRegister('measurement', (child, st) => seen.push({ id: child.config.general.id, st }));
|
||||
|
||||
const ch = makeChild({ id: 'm1' });
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].id, 'm1');
|
||||
assert.equal(seen[0].st, 'measurement');
|
||||
});
|
||||
|
||||
test('onMeasurement with full filter only fires for matching events', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(data, child) => hits.push({ v: data.value, id: child.config.general.id }));
|
||||
|
||||
const ch = makeChild({ id: 'p-up' });
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 100);
|
||||
emitMeasured(ch, 'pressure', 'downstream', 200); // ignored: wrong position
|
||||
emitMeasured(ch, 'flow', 'upstream', 5); // ignored: wrong type
|
||||
emitPredicted(ch, 'pressure', 'upstream', 999); // ignored: wrong variant
|
||||
|
||||
assert.deepEqual(hits, [{ v: 100, id: 'p-up' }]);
|
||||
});
|
||||
|
||||
test('onMeasurement without position filter fires for all positions of the type', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure' },
|
||||
(data) => hits.push(data.value));
|
||||
|
||||
const ch = makeChild();
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||
emitMeasured(ch, 'pressure', 'downstream', 2);
|
||||
emitMeasured(ch, 'pressure', 'atequipment', 3);
|
||||
emitMeasured(ch, 'flow', 'upstream', 99); // ignored: wrong type
|
||||
emitPredicted(ch, 'pressure', 'upstream', 50); // ignored: wrong variant
|
||||
|
||||
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('onPrediction works analogously to onMeasurement', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onPrediction('machinegroup', { type: 'flow', position: 'downstream' },
|
||||
(data) => hits.push(data.value));
|
||||
|
||||
const ch = makeChild({ softwareType: 'machinegroupcontrol' });
|
||||
router.dispatchRegister(ch, 'machinegroupcontrol');
|
||||
|
||||
emitPredicted(ch, 'flow', 'downstream', 42);
|
||||
emitPredicted(ch, 'flow', 'upstream', 7); // ignored: wrong position
|
||||
emitMeasured(ch, 'flow', 'downstream', 99); // ignored: wrong variant
|
||||
|
||||
assert.deepEqual(hits, [42]);
|
||||
});
|
||||
|
||||
test('software-type alias resolution: onRegister("machine") matches softwareType="rotatingmachine"', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const seen = [];
|
||||
|
||||
router.onRegister('machine', (child) => seen.push(child.config.general.id));
|
||||
|
||||
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
||||
router.dispatchRegister(rm, 'rotatingmachine');
|
||||
|
||||
assert.deepEqual(seen, ['rm-1']);
|
||||
});
|
||||
|
||||
test('alias resolution also flows through measurement subscriptions', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
// Declare with the canonical 'machine' alias.
|
||||
router.onMeasurement('machine', { type: 'flow', position: 'downstream' },
|
||||
(data) => hits.push(data.value));
|
||||
|
||||
// Child reports the raw, non-canonical softwareType.
|
||||
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
||||
router.dispatchRegister(rm, 'rotatingmachine');
|
||||
|
||||
emitMeasured(rm, 'flow', 'downstream', 17);
|
||||
assert.deepEqual(hits, [17]);
|
||||
});
|
||||
|
||||
test('tearDown removes listeners — re-emitting after tearDown does not invoke handler', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(data) => hits.push(['concrete', data.value]));
|
||||
router.onMeasurement('measurement', { type: 'pressure' }, // wildcard branch
|
||||
(data) => hits.push(['wild', data.value]));
|
||||
|
||||
const ch = makeChild();
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||
assert.equal(hits.length, 2);
|
||||
|
||||
router.tearDown();
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 2);
|
||||
emitMeasured(ch, 'pressure', 'downstream', 3);
|
||||
assert.equal(hits.length, 2, 'no further hits after tearDown');
|
||||
|
||||
// Original emit should be restored after teardown — sanity-check it still works
|
||||
// for unrelated listeners on the same emitter.
|
||||
let other = 0;
|
||||
ch.measurements.emitter.on('flow.measured.upstream', () => other++);
|
||||
emitMeasured(ch, 'flow', 'upstream', 9);
|
||||
assert.equal(other, 1);
|
||||
});
|
||||
|
||||
test('multiple onMeasurement subscriptions for same softwareType all fire', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const a = []; const b = []; const c = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(d) => a.push(d.value));
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(d) => b.push(d.value)); // duplicate concrete sub
|
||||
router.onMeasurement('measurement', { type: 'pressure' },
|
||||
(d) => c.push(d.value)); // wildcard-position sub
|
||||
|
||||
const ch = makeChild();
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 7);
|
||||
|
||||
assert.deepEqual(a, [7]);
|
||||
assert.deepEqual(b, [7]);
|
||||
assert.deepEqual(c, [7]);
|
||||
});
|
||||
|
||||
test('chainable API returns the router instance', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const r = router
|
||||
.onRegister('measurement', () => {})
|
||||
.onMeasurement('measurement', { type: 'flow' }, () => {})
|
||||
.onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {});
|
||||
assert.equal(r, router);
|
||||
});
|
||||
103
test/basic/HealthStatus.basic.test.js
Normal file
103
test/basic/HealthStatus.basic.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const HealthStatus = require('../../src/domain/HealthStatus');
|
||||
|
||||
test('ok() returns the canonical zero-level shape', () => {
|
||||
const h = HealthStatus.ok();
|
||||
assert.strictEqual(h.level, 0);
|
||||
assert.deepStrictEqual(h.flags, []);
|
||||
assert.strictEqual(h.message, 'nominal');
|
||||
assert.strictEqual(h.source, null);
|
||||
assert.ok(Object.isFrozen(h));
|
||||
assert.ok(Object.isFrozen(h.flags));
|
||||
});
|
||||
|
||||
test('ok(message, source) carries through optional args', () => {
|
||||
const h = HealthStatus.ok('all good', 'aggregator');
|
||||
assert.strictEqual(h.level, 0);
|
||||
assert.strictEqual(h.message, 'all good');
|
||||
assert.strictEqual(h.source, 'aggregator');
|
||||
});
|
||||
|
||||
test('degraded(2, [...], msg, src) returns the right frozen shape', () => {
|
||||
const h = HealthStatus.degraded(2, ['x'], 'msg', 'src');
|
||||
assert.strictEqual(h.level, 2);
|
||||
assert.deepStrictEqual(h.flags, ['x']);
|
||||
assert.strictEqual(h.message, 'msg');
|
||||
assert.strictEqual(h.source, 'src');
|
||||
assert.ok(Object.isFrozen(h));
|
||||
assert.ok(Object.isFrozen(h.flags));
|
||||
// Mutation attempts must not change the frozen flags array.
|
||||
assert.throws(() => { h.flags.push('y'); }, TypeError);
|
||||
});
|
||||
|
||||
test('degraded clamps out-of-range levels (high)', () => {
|
||||
const h = HealthStatus.degraded(7, ['hot'], 'too high');
|
||||
assert.strictEqual(h.level, 3);
|
||||
});
|
||||
|
||||
test('degraded clamps out-of-range levels (low / non-numeric)', () => {
|
||||
const lo = HealthStatus.degraded(0, ['lo'], 'too low');
|
||||
assert.strictEqual(lo.level, 1);
|
||||
const nan = HealthStatus.degraded('nope', ['n'], 'bad input');
|
||||
assert.strictEqual(nan.level, 1);
|
||||
});
|
||||
|
||||
test('degraded falls back to label-derived message when message is empty', () => {
|
||||
const h = HealthStatus.degraded(2, ['x']);
|
||||
assert.strictEqual(h.message, 'major');
|
||||
});
|
||||
|
||||
test('compose([]) returns ok()', () => {
|
||||
const h = HealthStatus.compose([]);
|
||||
assert.strictEqual(h.level, 0);
|
||||
assert.deepStrictEqual(h.flags, []);
|
||||
assert.strictEqual(h.message, 'nominal');
|
||||
assert.strictEqual(h.source, null);
|
||||
});
|
||||
|
||||
test('compose merges, picking worst level + that status\'s message/source', () => {
|
||||
const h = HealthStatus.compose([
|
||||
HealthStatus.ok(),
|
||||
HealthStatus.degraded(1, ['a'], 'a-msg', 'a-src'),
|
||||
HealthStatus.degraded(2, ['b'], 'b-msg', 'b-src'),
|
||||
]);
|
||||
assert.strictEqual(h.level, 2);
|
||||
assert.deepStrictEqual(h.flags, ['a', 'b']);
|
||||
assert.strictEqual(h.message, 'b-msg');
|
||||
assert.strictEqual(h.source, 'b-src');
|
||||
});
|
||||
|
||||
test('compose ties: first worst-level status wins for message/source', () => {
|
||||
const h = HealthStatus.compose([
|
||||
HealthStatus.degraded(2, ['a'], 'first', 'first-src'),
|
||||
HealthStatus.degraded(2, ['b'], 'second', 'second-src'),
|
||||
]);
|
||||
assert.strictEqual(h.level, 2);
|
||||
assert.strictEqual(h.message, 'first');
|
||||
assert.strictEqual(h.source, 'first-src');
|
||||
});
|
||||
|
||||
test('compose dedupes flags across statuses', () => {
|
||||
const h = HealthStatus.compose([
|
||||
HealthStatus.degraded(1, ['x', 'y'], 'one'),
|
||||
HealthStatus.degraded(2, ['y', 'z', 'x'], 'two'),
|
||||
]);
|
||||
assert.deepStrictEqual(h.flags, ['x', 'y', 'z']);
|
||||
});
|
||||
|
||||
test('label maps 0..3 → nominal/minor/major/critical', () => {
|
||||
assert.strictEqual(HealthStatus.label(0), 'nominal');
|
||||
assert.strictEqual(HealthStatus.label(1), 'minor');
|
||||
assert.strictEqual(HealthStatus.label(2), 'major');
|
||||
assert.strictEqual(HealthStatus.label(3), 'critical');
|
||||
});
|
||||
|
||||
test('label returns "unknown" for out-of-range levels', () => {
|
||||
assert.strictEqual(HealthStatus.label(-1), 'unknown');
|
||||
assert.strictEqual(HealthStatus.label(4), 'unknown');
|
||||
assert.strictEqual(HealthStatus.label('x'), 'unknown');
|
||||
});
|
||||
152
test/basic/LatestWinsGate.basic.test.js
Normal file
152
test/basic/LatestWinsGate.basic.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const LatestWinsGate = require('../../src/domain/LatestWinsGate');
|
||||
|
||||
// Helper: a deferred promise so a test can pause a dispatch and inspect
|
||||
// gate state before resolving. Avoids real timers entirely.
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('single fire calls dispatch with the value', async () => {
|
||||
const calls = [];
|
||||
const gate = new LatestWinsGate(async (v) => { calls.push(v); });
|
||||
gate.fire('a');
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, ['a']);
|
||||
});
|
||||
|
||||
test('two fires while in-flight: second value runs after first settles', async () => {
|
||||
const calls = [];
|
||||
const gates = [deferred(), deferred()];
|
||||
const started = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
const slot = n++;
|
||||
calls.push(v);
|
||||
started[slot].resolve();
|
||||
await gates[slot].promise;
|
||||
});
|
||||
|
||||
gate.fire('first');
|
||||
gate.fire('second'); // parks while 'first' is in flight
|
||||
await started[0].promise;
|
||||
assert.deepEqual(calls, ['first']);
|
||||
assert.equal(gate.size, 2);
|
||||
|
||||
gates[0].resolve();
|
||||
await started[1].promise;
|
||||
assert.deepEqual(calls, ['first', 'second']);
|
||||
|
||||
gates[1].resolve();
|
||||
await gate.drain();
|
||||
});
|
||||
|
||||
test('three fires back-to-back: only the last runs after the first settles', async () => {
|
||||
const calls = [];
|
||||
const first = deferred();
|
||||
const firstStarted = deferred();
|
||||
let count = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (count++ === 0) {
|
||||
firstStarted.resolve();
|
||||
await first.promise;
|
||||
}
|
||||
});
|
||||
|
||||
gate.fire(1);
|
||||
gate.fire(2); // parked
|
||||
gate.fire(3); // overwrites 2
|
||||
|
||||
await firstStarted.promise;
|
||||
assert.deepEqual(calls, [1]);
|
||||
first.resolve();
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, [1, 3]);
|
||||
});
|
||||
|
||||
test('drain() resolves only after all queued work has run', async () => {
|
||||
const calls = [];
|
||||
const d = deferred();
|
||||
let started = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (started++ === 0) await d.promise;
|
||||
});
|
||||
|
||||
gate.fire('x');
|
||||
gate.fire('y');
|
||||
|
||||
let drained = false;
|
||||
const p = gate.drain().then(() => { drained = true; });
|
||||
|
||||
// While first is paused, drain must not have resolved yet.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
|
||||
d.resolve();
|
||||
await p;
|
||||
assert.deepEqual(calls, ['x', 'y']);
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
test('error in dispatch does not prevent subsequent fire from working', async () => {
|
||||
const calls = [];
|
||||
let throwNext = true;
|
||||
const errors = [];
|
||||
const logger = { error: (e) => errors.push(e) };
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (throwNext) {
|
||||
throwNext = false;
|
||||
throw new Error('boom');
|
||||
}
|
||||
}, { logger });
|
||||
|
||||
gate.fire('a');
|
||||
await gate.drain();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].message, /boom/);
|
||||
assert.ok(gate.lastError instanceof Error);
|
||||
|
||||
// Gate must still accept further work.
|
||||
gate.fire('b');
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('error is recorded on lastError when no logger is supplied', async () => {
|
||||
const gate = new LatestWinsGate(async () => { throw new Error('silent'); });
|
||||
gate.fire('only');
|
||||
await gate.drain();
|
||||
assert.ok(gate.lastError instanceof Error);
|
||||
assert.match(gate.lastError.message, /silent/);
|
||||
});
|
||||
|
||||
test('size reports 0 / 1 / 2 across the lifecycle', async () => {
|
||||
const d1 = deferred();
|
||||
const gate = new LatestWinsGate(async () => { await d1.promise; });
|
||||
|
||||
assert.equal(gate.size, 0);
|
||||
|
||||
gate.fire('one');
|
||||
// fire is sync, but _dispatch starts on a microtask. Either way the
|
||||
// gate is marked in-flight synchronously.
|
||||
assert.equal(gate.size, 1);
|
||||
|
||||
gate.fire('two'); // parked
|
||||
assert.equal(gate.size, 2);
|
||||
|
||||
d1.resolve();
|
||||
await gate.drain();
|
||||
assert.equal(gate.size, 0);
|
||||
});
|
||||
145
test/basic/UnitPolicy.basic.test.js
Normal file
145
test/basic/UnitPolicy.basic.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const UnitPolicy = require('../../src/domain/UnitPolicy.js');
|
||||
|
||||
function makeFakeLogger() {
|
||||
const calls = { warn: [], info: [], error: [], debug: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
debug: (m) => calls.debug.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
const baseSpec = {
|
||||
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
||||
curve: { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' },
|
||||
};
|
||||
|
||||
test('declare returns a policy whose canonical/output match the input', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.equal(policy.canonical('flow'), 'm3/s');
|
||||
assert.equal(policy.canonical('pressure'), 'Pa');
|
||||
assert.equal(policy.canonical('power'), 'W');
|
||||
assert.equal(policy.canonical('temperature'), 'K');
|
||||
assert.equal(policy.output('flow'), 'm3/h');
|
||||
assert.equal(policy.output('pressure'), 'mbar');
|
||||
assert.equal(policy.output('power'), 'kW');
|
||||
assert.equal(policy.output('temperature'), 'C');
|
||||
assert.equal(policy.curve('flow'), 'm3/h');
|
||||
assert.equal(policy.curve('control'), '%');
|
||||
});
|
||||
|
||||
test('declare throws when canonical or output is missing', () => {
|
||||
assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/);
|
||||
assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/);
|
||||
});
|
||||
|
||||
test('resolve returns the candidate when it matches the expected measure', () => {
|
||||
const logger = makeFakeLogger();
|
||||
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
||||
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s', 'general.flow'), 'm3/h');
|
||||
assert.equal(policy.resolve('bar', 'pressure', 'mbar', 'asset.pressure'), 'bar');
|
||||
assert.equal(policy.resolve('kW', 'power', 'W', 'asset.power'), 'kW');
|
||||
// No warnings on valid inputs.
|
||||
assert.equal(logger.calls.warn.length, 0);
|
||||
});
|
||||
|
||||
test('resolve falls back when given an invalid candidate, warns once', () => {
|
||||
const logger = makeFakeLogger();
|
||||
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
||||
|
||||
// Wrong measure family (mass unit declared as a flow unit).
|
||||
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
||||
// Same call again — the warn-once memo must suppress.
|
||||
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
||||
assert.equal(logger.calls.warn.length, 1);
|
||||
assert.match(logger.calls.warn[0], /Invalid general\.flow unit 'kg'/);
|
||||
|
||||
// A different invalid candidate logs a separate warning.
|
||||
assert.equal(policy.resolve('not-a-unit', 'pressure', 'Pa', 'asset.pressure'), 'Pa');
|
||||
assert.equal(logger.calls.warn.length, 2);
|
||||
});
|
||||
|
||||
test('resolve falls back to the default when candidate is empty/whitespace', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.equal(policy.resolve('', 'flow', 'm3/s'), 'm3/s');
|
||||
assert.equal(policy.resolve(' ', 'flow', 'm3/s'), 'm3/s');
|
||||
assert.equal(policy.resolve(undefined, 'flow', 'm3/s'), 'm3/s');
|
||||
});
|
||||
|
||||
test('resolve accepts type-name shorthand as well as convert-module measure', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
// 'flow' shorthand should map to volumeFlowRate, not be passed through raw.
|
||||
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s'), 'm3/h');
|
||||
assert.equal(policy.resolve('m3/h', 'volumeFlowRate', 'm3/s'), 'm3/h');
|
||||
});
|
||||
|
||||
test('convert is a no-op when from === to (still coerces to Number)', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.equal(policy.convert('5', 'm3/h', 'm3/h'), 5);
|
||||
assert.equal(typeof policy.convert(5, 'm3/h', 'm3/h'), 'number');
|
||||
// Missing units also no-op.
|
||||
assert.equal(policy.convert(7, '', 'm3/h'), 7);
|
||||
assert.equal(policy.convert(7, 'm3/h', null), 7);
|
||||
});
|
||||
|
||||
test('convert across compatible units returns the expected numeric', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
// 1 m3/s -> 3600 m3/h
|
||||
assert.equal(policy.convert(1, 'm3/s', 'm3/h'), 3600);
|
||||
// 1 bar -> 100000 Pa
|
||||
assert.equal(policy.convert(1, 'bar', 'Pa'), 100000);
|
||||
// 1 kW -> 1000 W
|
||||
assert.equal(policy.convert(1, 'kW', 'W'), 1000);
|
||||
});
|
||||
|
||||
test('convert throws when value is not finite', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.throws(() => policy.convert('not-a-number', 'm3/h', 'm3/s'), /not finite/);
|
||||
assert.throws(() => policy.convert(NaN, 'm3/h', 'm3/s'), /not finite/);
|
||||
assert.throws(() => policy.convert(Infinity, 'm3/h', 'm3/s'), /not finite/);
|
||||
});
|
||||
|
||||
test('containerOptions returns the exact shape consumed by MeasurementContainer', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
const opts = policy.containerOptions();
|
||||
|
||||
assert.deepEqual(opts.defaultUnits, baseSpec.output);
|
||||
assert.deepEqual(opts.preferredUnits, baseSpec.output);
|
||||
assert.deepEqual(opts.canonicalUnits, baseSpec.canonical);
|
||||
assert.equal(opts.storeCanonical, true);
|
||||
assert.equal(opts.strictUnitValidation, true);
|
||||
assert.equal(opts.throwOnInvalidUnit, true);
|
||||
assert.deepEqual(opts.requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
||||
|
||||
// Mutating the returned bag must not leak back into the policy.
|
||||
opts.defaultUnits.flow = 'tampered';
|
||||
opts.requireUnitForTypes.push('volume');
|
||||
assert.equal(policy.output('flow'), 'm3/h');
|
||||
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
||||
});
|
||||
|
||||
test('containerOptions honours custom requireUnitForTypes from declare', () => {
|
||||
const policy = UnitPolicy.declare({
|
||||
...baseSpec,
|
||||
requireUnitForTypes: ['flow', 'pressure'],
|
||||
});
|
||||
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure']);
|
||||
});
|
||||
|
||||
test('containerOptions output works with a real MeasurementContainer', () => {
|
||||
const { MeasurementContainer } = require('../../src/measurements/index.js');
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
const mc = new MeasurementContainer(policy.containerOptions());
|
||||
// No throw on construction — proves the option bag is a valid input shape.
|
||||
assert.equal(mc.storeCanonical, true);
|
||||
assert.equal(mc.strictUnitValidation, true);
|
||||
assert.equal(mc.throwOnInvalidUnit, true);
|
||||
assert.equal(mc.canonicalUnits.flow, 'm3/s');
|
||||
assert.equal(mc.defaultUnits.flow, 'm3/h');
|
||||
});
|
||||
50
test/basic/stats.basic.test.js
Normal file
50
test/basic/stats.basic.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { mean, stdDev, median, mad, lerp } = require('../../src/stats');
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
function near(a, b, eps = EPS) {
|
||||
assert.ok(Math.abs(a - b) <= eps, `expected ${a} ≈ ${b} (eps ${eps})`);
|
||||
}
|
||||
|
||||
test('mean: basic and empty', () => {
|
||||
assert.equal(mean([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(mean([]), 0);
|
||||
});
|
||||
|
||||
test('stdDev: zero-variance, classic sample, single-element, empty', () => {
|
||||
assert.equal(stdDev([1, 1, 1, 1]), 0);
|
||||
near(stdDev([1, 2, 3, 4, 5]), 1.5811388300841898);
|
||||
assert.equal(stdDev([5]), 0);
|
||||
assert.equal(stdDev([]), 0);
|
||||
});
|
||||
|
||||
test('median: odd, even, empty', () => {
|
||||
assert.equal(median([1, 2, 3, 4, 5]), 3);
|
||||
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(median([]), 0);
|
||||
});
|
||||
|
||||
test('mad: hand-checked sample and constant array', () => {
|
||||
// [1,1,2,2,4,6,9] -> median 2 -> |dev| [1,1,0,0,2,4,7] -> sorted
|
||||
// [0,0,1,1,2,4,7] -> mad = 1.
|
||||
assert.equal(mad([1, 1, 2, 2, 4, 6, 9]), 1);
|
||||
assert.equal(mad([5, 5, 5]), 0);
|
||||
assert.equal(mad([]), 0);
|
||||
});
|
||||
|
||||
test('lerp: in-range mapping and degenerate pass-through', () => {
|
||||
assert.equal(lerp(2, 0, 4, 0, 100), 50);
|
||||
assert.equal(lerp(2, 0, 0, 0, 100), 2);
|
||||
// iMin > iMax also degenerate (defensive against swapped bounds).
|
||||
assert.equal(lerp(2, 4, 0, 0, 100), 2);
|
||||
});
|
||||
|
||||
test('lerp: float arithmetic stays within epsilon', () => {
|
||||
near(lerp(0.1, 0, 1, 0, 10), 1);
|
||||
near(lerp(1 / 3, 0, 1, 0, 30), 10);
|
||||
});
|
||||
70
test/basic/statusBadge.basic.test.js
Normal file
70
test/basic/statusBadge.basic.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { statusBadge, MAX_TEXT } = require('../../src/nodered/statusBadge');
|
||||
|
||||
test('compose joins parts with " | " and uses default green/dot', () => {
|
||||
const badge = statusBadge.compose(['A', 'B']);
|
||||
assert.deepEqual(badge, { fill: 'green', shape: 'dot', text: 'A | B' });
|
||||
});
|
||||
|
||||
test('compose drops null/undefined/empty parts', () => {
|
||||
const badge = statusBadge.compose(['A', null, 'B', undefined, '']);
|
||||
assert.equal(badge.text, 'A | B');
|
||||
assert.equal(badge.fill, 'green');
|
||||
assert.equal(badge.shape, 'dot');
|
||||
});
|
||||
|
||||
test('compose with empty parts and override fill returns empty text', () => {
|
||||
const badge = statusBadge.compose([], { fill: 'yellow' });
|
||||
assert.equal(badge.text, '');
|
||||
assert.equal(badge.fill, 'yellow');
|
||||
assert.equal(badge.shape, 'dot');
|
||||
});
|
||||
|
||||
test('error returns red ring with ⚠ prefix', () => {
|
||||
const badge = statusBadge.error('boom');
|
||||
assert.deepEqual(badge, { fill: 'red', shape: 'ring', text: '⚠ boom' });
|
||||
});
|
||||
|
||||
test('idle returns blue dot with ⏸ prefix', () => {
|
||||
const badge = statusBadge.idle('waiting');
|
||||
assert.deepEqual(badge, { fill: 'blue', shape: 'dot', text: '⏸️ waiting' });
|
||||
});
|
||||
|
||||
test('byState returns the matching template', () => {
|
||||
const map = { off: { fill: 'red', shape: 'dot', text: 'OFF' } };
|
||||
const badge = statusBadge.byState(map, 'off');
|
||||
assert.deepEqual(badge, { fill: 'red', shape: 'dot', text: 'OFF' });
|
||||
});
|
||||
|
||||
test('byState returns grey "unknown state" badge when key is missing', () => {
|
||||
const badge = statusBadge.byState({}, 'unknown');
|
||||
assert.equal(badge.fill, 'grey');
|
||||
assert.equal(badge.shape, 'ring');
|
||||
assert.match(badge.text, /unknown state/);
|
||||
assert.match(badge.text, /unknown/);
|
||||
});
|
||||
|
||||
test('byState composes extra parts into the template text', () => {
|
||||
const map = { run: { fill: 'green', shape: 'dot', text: 'RUN' } };
|
||||
const badge = statusBadge.byState(map, 'run', { compose: ['flow=12.0', 'P=3kW'] });
|
||||
assert.equal(badge.text, 'RUN | flow=12.0 | P=3kW');
|
||||
});
|
||||
|
||||
test('text length is truncated to MAX_TEXT chars ending with …', () => {
|
||||
const longInput = 'x'.repeat(200);
|
||||
const badge = statusBadge.text(longInput);
|
||||
assert.equal(badge.text.length, MAX_TEXT);
|
||||
assert.equal(badge.text.endsWith('…'), true);
|
||||
});
|
||||
|
||||
test('text helper defaults to green/dot and never returns null text', () => {
|
||||
assert.equal(statusBadge.text(null).text, '');
|
||||
assert.equal(statusBadge.text(undefined).text, '');
|
||||
const badge = statusBadge.text('hi');
|
||||
assert.equal(badge.fill, 'green');
|
||||
assert.equal(badge.shape, 'dot');
|
||||
});
|
||||
Reference in New Issue
Block a user