From 47faf9404855a081241cd5c7020c4d785ccd3e6e Mon Sep 17 00:00:00 2001 From: znetsixe Date: Sun, 10 May 2026 18:27:29 +0200 Subject: [PATCH] Phase 1 wave 1: domain + nodered + stats infra (additive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/domain/ChildRouter.js | 184 ++++++++++++++++++++++ src/domain/HealthStatus.js | 102 ++++++++++++ src/domain/LatestWinsGate.js | 74 +++++++++ src/domain/UnitPolicy.js | 149 ++++++++++++++++++ src/nodered/statusBadge.js | 96 ++++++++++++ src/stats/index.js | 52 +++++++ test/basic/ChildRouter.basic.test.js | 197 ++++++++++++++++++++++++ test/basic/HealthStatus.basic.test.js | 103 +++++++++++++ test/basic/LatestWinsGate.basic.test.js | 152 ++++++++++++++++++ test/basic/UnitPolicy.basic.test.js | 145 +++++++++++++++++ test/basic/stats.basic.test.js | 50 ++++++ test/basic/statusBadge.basic.test.js | 70 +++++++++ 12 files changed, 1374 insertions(+) create mode 100644 src/domain/ChildRouter.js create mode 100644 src/domain/HealthStatus.js create mode 100644 src/domain/LatestWinsGate.js create mode 100644 src/domain/UnitPolicy.js create mode 100644 src/nodered/statusBadge.js create mode 100644 src/stats/index.js create mode 100644 test/basic/ChildRouter.basic.test.js create mode 100644 test/basic/HealthStatus.basic.test.js create mode 100644 test/basic/LatestWinsGate.basic.test.js create mode 100644 test/basic/UnitPolicy.basic.test.js create mode 100644 test/basic/stats.basic.test.js create mode 100644 test/basic/statusBadge.basic.test.js diff --git a/src/domain/ChildRouter.js b/src/domain/ChildRouter.js new file mode 100644 index 0000000..b0988b8 --- /dev/null +++ b/src/domain/ChildRouter.js @@ -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 + 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; diff --git a/src/domain/HealthStatus.js b/src/domain/HealthStatus.js new file mode 100644 index 0000000..2a0f3f4 --- /dev/null +++ b/src/domain/HealthStatus.js @@ -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 }; diff --git a/src/domain/LatestWinsGate.js b/src/domain/LatestWinsGate.js new file mode 100644 index 0000000..2402833 --- /dev/null +++ b/src/domain/LatestWinsGate.js @@ -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; diff --git a/src/domain/UnitPolicy.js b/src/domain/UnitPolicy.js new file mode 100644 index 0000000..9e56173 --- /dev/null +++ b/src/domain/UnitPolicy.js @@ -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; diff --git a/src/nodered/statusBadge.js b/src/nodered/statusBadge.js new file mode 100644 index 0000000..2ff26b0 --- /dev/null +++ b/src/nodered/statusBadge.js @@ -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 }; diff --git a/src/stats/index.js b/src/stats/index.js new file mode 100644 index 0000000..bdc6539 --- /dev/null +++ b/src/stats/index.js @@ -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 }; diff --git a/test/basic/ChildRouter.basic.test.js b/test/basic/ChildRouter.basic.test.js new file mode 100644 index 0000000..904da2a --- /dev/null +++ b/test/basic/ChildRouter.basic.test.js @@ -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); +}); diff --git a/test/basic/HealthStatus.basic.test.js b/test/basic/HealthStatus.basic.test.js new file mode 100644 index 0000000..4de7a2c --- /dev/null +++ b/test/basic/HealthStatus.basic.test.js @@ -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'); +}); diff --git a/test/basic/LatestWinsGate.basic.test.js b/test/basic/LatestWinsGate.basic.test.js new file mode 100644 index 0000000..4ae8630 --- /dev/null +++ b/test/basic/LatestWinsGate.basic.test.js @@ -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); +}); diff --git a/test/basic/UnitPolicy.basic.test.js b/test/basic/UnitPolicy.basic.test.js new file mode 100644 index 0000000..ea46b4d --- /dev/null +++ b/test/basic/UnitPolicy.basic.test.js @@ -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'); +}); diff --git a/test/basic/stats.basic.test.js b/test/basic/stats.basic.test.js new file mode 100644 index 0000000..bea8e64 --- /dev/null +++ b/test/basic/stats.basic.test.js @@ -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); +}); diff --git a/test/basic/statusBadge.basic.test.js b/test/basic/statusBadge.basic.test.js new file mode 100644 index 0000000..54e32b0 --- /dev/null +++ b/test/basic/statusBadge.basic.test.js @@ -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'); +});