6 Commits

Author SHA1 Message Date
znetsixe
8ebf31dd39 P6.4 follow-up: diffuser config schema additions
The P6.4 diffuser refactor reads headerPressure, localAtmPressure,
waterDensity, and zoneVolume out of the config. validateSchema strips
unknown keys, so without these definitions the values fell out of the
config object before specificClass could read them. Added with
sensible defaults that match the pre-refactor inline constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:47:37 +02:00
znetsixe
92eb8d2f15 P8.5: remove src/menu/asset_DEPRECATED.js (zero consumers)
The 243-line legacy AssetMenu was retained for backwards compatibility
but no code in the refactored platform references it. Removed.

loadCurve removal stays deferred — rotatingMachine + valve still call
it through src/curves/curveLoader.js and src/curve/supplierCurve.js.
Migration to loadModel is a follow-up after the platform refactor lands
on main.

113/113 basic tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:26:00 +02:00
znetsixe
7372d12088 fix(BaseNodeAdapter test): close intervals to unblock batch test runs
Tests 1, 4, 5 constructed an Adapter with the default
statusInterval=1000 and no mock for setInterval, leaking a real
status timer that held the event loop open past the assertions.
Single-file runs masked it; node --test test/basic/ blocked the
whole runner.

Fix: set static statusInterval = 0 + invoke node.handlers.close()
(or mock setInterval where the test asserts on registration timing).
113/113 basic tests pass in batch in ~400 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:32:31 +02:00
znetsixe
62f389a51f Phase 1 wave 3 + barrel: BaseNodeAdapter + index.js exports
- src/nodered/BaseNodeAdapter.js — base class for every nodeClass.js
  Lifecycle: config build → domain instantiate → child.register on
  Port 2 → tick (opt-in) or 'output-changed' subscription (default
  event-driven) → status updater → input dispatch via commandRegistry →
  close handler with clean teardown.
- index.js — additive exports of all Phase 1 modules:
  UnitPolicy, ChildRouter, LatestWinsGate, HealthStatus, BaseDomain,
  statusBadge, StatusUpdater, createRegistry, CommandRegistry,
  BaseNodeAdapter, stats. Existing exports unchanged.

113 unit tests pass under node:test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:59:50 +02:00
znetsixe
57b77f905a Phase 1 wave 2: BaseDomain + commandRegistry + statusUpdater
- src/domain/BaseDomain.js     — base class for every specificClass; wires emitter/config/logger/measurements/childRouter
- src/nodered/commandRegistry.js — declarative msg.topic dispatch with alias deprecation
- src/nodered/statusUpdater.js — 1Hz status badge poller with error-resilient loop

Additive. 43 new tests; all 99 basic tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:31:50 +02:00
znetsixe
47faf94048 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>
2026-05-10 18:27:29 +02:00
23 changed files with 2947 additions and 244 deletions

View File

@@ -35,6 +35,21 @@ const { loadModel } = require('./datasets/assetData/modelData/index.js');
const { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js');
const Fysics = require('./src/convert/fysics.js');
// Refactor platform infrastructure (additive — see .claude/refactor/CONTRACTS.md).
// Domain-side
const UnitPolicy = require('./src/domain/UnitPolicy.js');
const ChildRouter = require('./src/domain/ChildRouter.js');
const LatestWinsGate = require('./src/domain/LatestWinsGate.js');
const HealthStatus = require('./src/domain/HealthStatus.js');
const BaseDomain = require('./src/domain/BaseDomain.js');
// Node-RED-side
const { statusBadge } = require('./src/nodered/statusBadge.js');
const { StatusUpdater } = require('./src/nodered/statusUpdater.js');
const { createRegistry, CommandRegistry } = require('./src/nodered/commandRegistry.js');
const BaseNodeAdapter = require('./src/nodered/BaseNodeAdapter.js');
// Stats helpers
const stats = require('./src/stats/index.js');
// Export everything
module.exports = {
predict,
@@ -63,5 +78,17 @@ module.exports = {
POSITIONS,
POSITION_VALUES,
isValidPosition,
Fysics
Fysics,
// refactor infra (Phase 1)
UnitPolicy,
ChildRouter,
LatestWinsGate,
HealthStatus,
BaseDomain,
statusBadge,
StatusUpdater,
createRegistry,
CommandRegistry,
BaseNodeAdapter,
stats
};

View File

@@ -106,6 +106,34 @@
"type": "number",
"description": "Alpha factor used for oxygen transfer correction."
}
},
"headerPressure": {
"default": 0,
"rules": {
"type": "number",
"description": "Header gauge pressure above atmospheric (mbar)."
}
},
"localAtmPressure": {
"default": 1013.25,
"rules": {
"type": "number",
"description": "Local atmospheric pressure (mbar)."
}
},
"waterDensity": {
"default": 997,
"rules": {
"type": "number",
"description": "Water density used in head-pressure calculation (kg/m3)."
}
},
"zoneVolume": {
"default": 0,
"rules": {
"type": "number",
"description": "Aeration zone volume used to convert oxygen output to reactor OTR (m3)."
}
}
}
}

139
src/domain/BaseDomain.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* BaseDomain — shared specificClass scaffolding.
*
* Consolidates the constructor boilerplate that every domain (pumpingStation,
* measurement, MGC, rotatingMachine, …) repeats today: configManager →
* configUtils → logger → MeasurementContainer → childRegistrationUtils →
* ChildRouter. Subclasses declare `static name` (matches the JSON config in
* generalFunctions/src/configs/<name>.json) and optionally `static unitPolicy`
* (a UnitPolicy.declare(...) instance), then implement `configure()` to wire
* concern-modules.
*
* See CONTRACTS.md §3.
*/
const EventEmitter = require('events');
const configManager = require('../configs/index.js');
const configUtils = require('../helper/configUtils.js');
const Logger = require('../helper/logger.js');
const childRegistrationUtils = require('../helper/childRegistrationUtils.js');
const { MeasurementContainer } = require('../measurements/index.js');
const ChildRouter = require('./ChildRouter.js');
class BaseDomain {
constructor(userConfig = {}) {
const ctor = this.constructor;
if (ctor === BaseDomain) {
throw new Error('BaseDomain is abstract; subclass it and declare static name');
}
this.emitter = new EventEmitter();
this.configManager = new configManager();
this.defaultConfig = this.configManager.getConfig(ctor.name);
this.configUtils = new configUtils(this.defaultConfig);
this.config = this.configUtils.initConfig(userConfig);
const loggingCfg = this.config?.general?.logging || {};
this.logger = new Logger(
loggingCfg.enabled,
loggingCfg.logLevel,
this.config?.general?.name
);
// Read static unitPolicy via the constructor — `this.constructor`
// resolves to the leaf subclass even when this base ctor is the caller.
this.unitPolicy = ctor.unitPolicy ?? null;
if (this.unitPolicy && typeof this.unitPolicy.setLogger === 'function') {
this.unitPolicy.setLogger(this.logger);
}
const containerOptions = this.unitPolicy?.containerOptions
? this.unitPolicy.containerOptions()
: { autoConvert: true };
this.measurements = new MeasurementContainer(containerOptions, this.logger);
if (this.config?.general?.id) this.measurements.setChildId(this.config.general.id);
if (this.config?.general?.name) this.measurements.setChildName(this.config.general.name);
this.childRegistrationUtils = new childRegistrationUtils(this);
this.router = new ChildRouter(this);
// childRegistrationUtils calls back into mainClass.registerChild after
// storing the child. Routing through `this.router` keeps subclasses free
// of register-switch boilerplate while preserving the existing handshake.
this.registerChild = (child, softwareType) => {
this.router.dispatchRegister(child, softwareType);
return true;
};
if (typeof this.configure === 'function') this.configure();
if (typeof this._init === 'function') this._init();
}
/**
* Install a read-only getter that flattens `this.child[softwareType]`
* (across all categories, or filtered by `category`) into a single
* id-keyed object. Lets subclasses expose readable accessors like
* `this.machines` while the registry remains the source of truth.
*/
declareChildGetter(name, softwareType, category) {
const key = String(softwareType || '').toLowerCase();
Object.defineProperty(this, name, {
configurable: true,
enumerable: true,
get: () => {
const slice = this.child?.[key];
if (!slice) return {};
const cats = category ? [slice[category] || []] : Object.values(slice);
const out = {};
for (const list of cats) {
if (!Array.isArray(list)) continue;
for (const c of list) {
const id = c?.config?.general?.id || c?.config?.general?.name;
if (id != null) out[id] = c;
}
}
return out;
},
});
}
/**
* Frozen view passed to concern-modules so they don't reach into `this`.
* Subclasses may override to add domain-specific keys.
*/
context() {
return Object.freeze({
config: this.config,
logger: this.logger,
measurements: this.measurements,
emitter: this.emitter,
child: this.child,
unitPolicy: this.unitPolicy,
router: this.router,
});
}
/** Default output shape — subclasses extend with concern-module snapshots. */
getOutput() {
return this.measurements.getFlattenedOutput?.() || {};
}
/** Subclasses MUST override. Grey placeholder so adapters never crash. */
getStatusBadge() {
return { fill: 'grey', shape: 'ring', text: 'no status' };
}
/** Convenience for event-driven nodes — see CONTRACTS.md §3. */
notifyOutputChanged() {
this.emitter.emit('output-changed');
}
close() {
this.router?.tearDown();
this.emitter.removeAllListeners();
}
}
module.exports = BaseDomain;

184
src/domain/ChildRouter.js Normal file
View 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
View 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 };

View 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
View 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;

View File

@@ -1,243 +0,0 @@
// asset.js
const fs = require('fs');
const path = require('path');
class AssetMenu {
/** Define path where to find data of assets in constructor for now */
constructor(relPath = '../../datasets/assetData') {
this.baseDir = path.resolve(__dirname, relPath);
this.assetData = this._loadJSON('assetData');
}
_loadJSON(...segments) {
const filePath = path.resolve(this.baseDir, ...segments) + '.json';
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (err) {
throw new Error(`Failed to load ${filePath}: ${err.message}`);
}
}
/**
* ADD THIS METHOD
* Compiles all menu data from the file system into a single nested object.
* This is run once on the server to pre-load everything.
* @returns {object} A comprehensive object with all menu options.
*/
getAllMenuData() {
// load the raw JSON once
const data = this._loadJSON('assetData');
const allData = {};
data.suppliers.forEach(sup => {
allData[sup.name] = {};
sup.categories.forEach(cat => {
allData[sup.name][cat.name] = {};
cat.types.forEach(type => {
// here: store the full array of model objects, not just names
allData[sup.name][cat.name][type.name] = type.models;
});
});
});
return allData;
}
/**
* Convert the static initEditor function to a string that can be served to the client
* @param {string} nodeName - The name of the node type
* @returns {string} JavaScript code as a string
*/
getClientInitCode(nodeName) {
// step 1: get the two helper strings
const htmlCode = this.getHtmlInjectionCode(nodeName);
const dataCode = this.getDataInjectionCode(nodeName);
const eventsCode = this.getEventInjectionCode(nodeName);
const saveCode = this.getSaveInjectionCode(nodeName);
return `
// --- AssetMenu for ${nodeName} ---
window.EVOLV.nodes.${nodeName}.assetMenu =
window.EVOLV.nodes.${nodeName}.assetMenu || {};
${htmlCode}
${dataCode}
${eventsCode}
${saveCode}
// wire it all up when the editor loads
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
// ------------------ BELOW sequence is important! -------------------------------
console.log('Initializing asset properties for ${nodeName}…');
this.injectHtml();
// load the data and wire up events
// this will populate the fields and set up the event listeners
this.wireEvents(node);
// this will load the initial data into the fields
// this is important to ensure the fields are populated correctly
this.loadData(node);
};
`;
}
getDataInjectionCode(nodeName) {
return `
// Asset Data loader for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = function(node) {
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
const elems = {
supplier: document.getElementById('node-input-supplier'),
category: document.getElementById('node-input-category'),
type: document.getElementById('node-input-assetType'),
model: document.getElementById('node-input-model'),
unit: document.getElementById('node-input-unit')
};
function populate(el, opts, sel) {
const old = el.value;
el.innerHTML = '<option value="">Select…</option>';
(opts||[]).forEach(o=>{
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
el.appendChild(opt);
});
el.value = sel||"";
if(el.value!==old) el.dispatchEvent(new Event('change'));
}
// initial population
populate(elems.supplier, Object.keys(data), node.supplier);
};
`
}
getEventInjectionCode(nodeName) {
return `
// Asset Event wiring for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
const elems = {
supplier: document.getElementById('node-input-supplier'),
category: document.getElementById('node-input-category'),
type: document.getElementById('node-input-assetType'),
model: document.getElementById('node-input-model'),
unit: document.getElementById('node-input-unit')
};
function populate(el, opts, sel) {
const old = el.value;
el.innerHTML = '<option value="">Select…</option>';
(opts||[]).forEach(o=>{
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
el.appendChild(opt);
});
el.value = sel||"";
if(el.value!==old) el.dispatchEvent(new Event('change'));
}
elems.supplier.addEventListener('change', ()=>{
populate(elems.category,
elems.supplier.value? Object.keys(data[elems.supplier.value]||{}) : [],
node.category);
});
elems.category.addEventListener('change', ()=>{
const s=elems.supplier.value, c=elems.category.value;
populate(elems.type,
(s&&c)? Object.keys(data[s][c]||{}) : [],
node.assetType);
});
elems.type.addEventListener('change', ()=>{
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value;
const md = (s&&c&&t)? data[s][c][t]||[] : [];
populate(elems.model, md.map(m=>m.name), node.model);
});
elems.model.addEventListener('change', ()=>{
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value, m=elems.model.value;
const md = (s&&c&&t)? data[s][c][t]||[] : [];
const entry = md.find(x=>x.name===m);
populate(elems.unit, entry? entry.units : [], node.unit);
});
};
`
}
/**
* Generate HTML template for asset fields
*/
getHtmlTemplate() {
return `
<!-- Asset Properties -->
<hr />
<h3>Asset selection</h3>
<div class="form-row">
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
<select id="node-input-supplier" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-category"><i class="fa fa-sitemap"></i> Category</label>
<select id="node-input-category" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
<select id="node-input-assetType" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
<select id="node-input-model" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
<select id="node-input-unit" style="width:70%;"></select>
</div>
<hr />
`;
}
/**
* Get client-side HTML injection code
*/
getHtmlInjectionCode(nodeName) {
const htmlTemplate = this.getHtmlTemplate().replace(/`/g, '\\`').replace(/\$/g, '\\$');
return `
// Asset HTML injection for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.injectHtml = function() {
const placeholder = document.getElementById('asset-fields-placeholder');
if (placeholder && !placeholder.hasChildNodes()) {
placeholder.innerHTML = \`${htmlTemplate}\`;
console.log('Asset HTML injected successfully');
}
};
`;
}
/**
* Returns the JS that injects the saveEditor function
*/
getSaveInjectionCode(nodeName) {
return `
// Asset Save injection for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
console.log('Saving asset properties for ${nodeName}…');
const fields = ['supplier','category','assetType','model','unit'];
const errors = [];
fields.forEach(f => {
const el = document.getElementById(\`node-input-\${f}\`);
node[f] = el ? el.value : '';
});
if (node.assetType && !node.unit) errors.push('Unit must be set when type is specified.');
if (!node.unit) errors.push('Unit is required.');
errors.forEach(e=>RED.notify(e,'error'));
// --- DEBUG: show exactly what was saved ---
const saved = fields.reduce((o,f) => { o[f] = node[f]; return o; }, {});
console.log('→ assetMenu.saveEditor result:', saved);
return errors.length===0;
};
`;
}
}
module.exports = AssetMenu;

View File

@@ -0,0 +1,176 @@
/**
* BaseNodeAdapter — shared nodeClass scaffolding.
*
* Consolidates the boilerplate every node's nodeClass.js repeats today
* (config build → domain instantiate → registration delay → tick loop →
* status loop → input dispatch → close handler). Subclasses declare what
* varies (DomainClass, commands, output strategy) via static fields and
* override `buildDomainConfig(uiConfig, nodeId)` to produce the per-node
* config slice.
*
* See CONTRACTS.md §2; OPEN_QUESTIONS.md (event-driven default + tick
* fire-and-forget resolution, 2026-05-10).
*/
'use strict';
const ConfigManager = require('../configs/index.js');
const OutputUtils = require('../helper/outputUtils.js');
const { createRegistry } = require('./commandRegistry.js');
const { StatusUpdater } = require('./statusUpdater.js');
const REGISTRATION_DELAY_MS = 100;
class BaseNodeAdapter {
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
const ctor = this.constructor;
if (ctor === BaseNodeAdapter) {
throw new Error('BaseNodeAdapter is abstract; subclass it and declare static DomainClass + commands');
}
if (typeof ctor.DomainClass !== 'function') {
throw new Error(`${ctor.name}: static DomainClass is required (a class to instantiate)`);
}
if (!Array.isArray(ctor.commands)) {
throw new Error(`${ctor.name}: static commands is required (array of descriptors; use [] for none)`);
}
if (typeof this.buildDomainConfig !== 'function') {
throw new Error(`${ctor.name}: must implement buildDomainConfig(uiConfig, nodeId)`);
}
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
const cfgMgr = new ConfigManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
this.config = cfgMgr.buildConfig(
this.name,
uiConfig,
this.node.id,
this.buildDomainConfig(uiConfig, this.node.id) || {},
);
this.source = new ctor.DomainClass(this.config);
// Sibling-node lookup uses RED.nodes.getNode(id).source — see existing
// pumpingStation/measurement nodeClass _attachInputHandler patterns.
this.node.source = this.source;
this._output = new OutputUtils();
this._commands = createRegistry(ctor.commands, { logger: this.source?.logger });
this._tickInterval = null;
this._outputChangedListener = null;
this._scheduleRegistration();
this._wireOutputs();
this._statusUpdater = new StatusUpdater({
node: this.node,
source: this.source,
intervalMs: ctor.statusInterval ?? 1000,
logger: this.source?.logger,
});
this._statusUpdater.start();
this._attachInputHandler();
this._attachCloseHandler();
if (typeof this.extraSetup === 'function') this.extraSetup();
}
_scheduleRegistration() {
// Delayed so siblings have finished constructing before the parent
// receives the registration message.
setTimeout(() => {
this.node.send([
null,
null,
{
topic: 'child.register',
payload: this.node.id,
positionVsParent: this.config?.functionality?.positionVsParent ?? 'atEquipment',
distance: this.config?.functionality?.distance ?? null,
},
]);
}, REGISTRATION_DELAY_MS);
}
_wireOutputs() {
const ctor = this.constructor;
const interval = ctor.tickInterval;
if (typeof interval === 'number' && interval > 0) {
this._tickInterval = setInterval(() => {
// Fire-and-forget per OPEN_QUESTIONS 2026-05-10. Domain owns
// its own serialisation via LatestWinsGate when needed.
try { this.source.tick?.(); }
catch (err) { this.source?.logger?.error?.(`tick threw: ${err.message}`); }
this._emitOutputs();
}, interval);
return;
}
// Event-driven default: domain emits 'output-changed' when its
// public output state shifts; adapter pushes outputs in response.
const emitter = this.source?.emitter;
if (emitter && typeof emitter.on === 'function') {
this._outputChangedListener = () => this._emitOutputs();
emitter.on('output-changed', this._outputChangedListener);
}
}
_emitOutputs() {
if (typeof this.source.getOutput !== 'function') return;
const raw = this.source.getOutput();
const cfg = this.source.config || this.config;
const processMsg = this._output.formatMsg(raw, cfg, 'process');
const influxMsg = this._output.formatMsg(raw, cfg, 'influxdb');
this.node.send([processMsg, influxMsg, null]);
}
_attachInputHandler() {
this.node.on('input', async (msg, send, done) => {
try {
await this._commands.dispatch(msg, this.source, {
node: this.node,
RED: this.RED,
send,
logger: this.source?.logger,
});
if (typeof this.extraInputDispatch === 'function') {
await this.extraInputDispatch(msg, send, done);
}
} catch (err) {
this.source?.logger?.error?.(err.message);
} finally {
if (typeof done === 'function') done();
}
});
}
_attachCloseHandler() {
this.node.on('close', (done) => {
try {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = null;
}
if (this._outputChangedListener && this.source?.emitter?.off) {
this.source.emitter.off('output-changed', this._outputChangedListener);
this._outputChangedListener = null;
}
this._statusUpdater?.stop();
this.source?.close?.();
if (typeof this.extraClose === 'function') this.extraClose();
try { this.node.status({}); } catch (_) { /* best effort */ }
} catch (err) {
this.source?.logger?.error?.(`close handler threw: ${err.message}`);
} finally {
if (typeof done === 'function') done();
}
});
}
}
// Defaults overridable via subclass static fields.
BaseNodeAdapter.tickInterval = null;
BaseNodeAdapter.statusInterval = 1000;
module.exports = BaseNodeAdapter;

View File

@@ -0,0 +1,156 @@
'use strict';
// Declarative dispatch for a node's input topics. Each node declares its
// commands as an array of descriptors; the registry builds an O(1) lookup
// keyed by canonical topic + alias, validates the payload against a small
// shape schema, and invokes the handler. Replaces the per-node ~100-line
// `switch (msg.topic)` block in nodeClass._attachInputHandler.
//
// Lightweight on purpose: the schema is a typeof-check ladder, not full
// JSON-Schema. Anything richer belongs in the handler itself, which has
// access to logger via ctx.
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any']);
class CommandRegistry {
constructor(commands, options = {}) {
if (!Array.isArray(commands)) {
throw new TypeError('CommandRegistry requires an array of command descriptors');
}
this._logger = options.logger || null;
this._byKey = new Map(); // topic-or-alias -> descriptor
this._canonicalByAlias = new Map();
this._descriptors = [];
this._deprecationCounts = new Map();
this._deprecationLogged = new Set();
for (const cmd of commands) this._register(cmd);
}
_register(cmd) {
if (!cmd || typeof cmd.topic !== 'string' || cmd.topic.length === 0) {
throw new TypeError('command descriptor requires a non-empty string topic');
}
if (typeof cmd.handler !== 'function') {
throw new TypeError(`command '${cmd.topic}' requires a handler function`);
}
if (this._byKey.has(cmd.topic)) {
throw new Error(`duplicate command topic '${cmd.topic}'`);
}
const aliases = Array.isArray(cmd.aliases) ? cmd.aliases.slice() : [];
for (const alias of aliases) {
if (typeof alias !== 'string' || alias.length === 0) {
throw new TypeError(`command '${cmd.topic}' has an invalid alias`);
}
if (this._byKey.has(alias)) {
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
}
}
const descriptor = {
topic: cmd.topic,
aliases,
payloadSchema: cmd.payloadSchema || null,
handler: cmd.handler,
};
this._byKey.set(cmd.topic, descriptor);
for (const alias of aliases) {
this._byKey.set(alias, descriptor);
this._canonicalByAlias.set(alias, cmd.topic);
}
this._descriptors.push(descriptor);
}
has(topic) {
return typeof topic === 'string' && this._byKey.has(topic);
}
canonical(topic) {
if (typeof topic !== 'string') return topic;
return this._canonicalByAlias.get(topic) || topic;
}
list() {
// Strip handler so callers can safely log / serialise the result
// (handler functions are noisy and not contract-relevant).
return this._descriptors.map((d) => ({
topic: d.topic,
aliases: d.aliases.slice(),
payloadSchema: d.payloadSchema,
}));
}
deprecationStats() {
const out = {};
for (const [alias, count] of this._deprecationCounts) out[alias] = count;
return out;
}
async dispatch(msg, source, ctx) {
const log = this._loggerFor(ctx);
const topic = msg && typeof msg.topic === 'string' ? msg.topic : null;
if (!topic) {
log.warn?.('commandRegistry: msg has no topic; ignoring');
return;
}
const descriptor = this._byKey.get(topic);
if (!descriptor) {
log.warn?.(`commandRegistry: unknown topic '${topic}'`);
return;
}
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
if (!this._validatePayload(descriptor, msg, log)) return;
return descriptor.handler(source, msg, ctx);
}
_noteAlias(alias, canonical, log) {
const prev = this._deprecationCounts.get(alias) || 0;
this._deprecationCounts.set(alias, prev + 1);
if (this._deprecationLogged.has(alias)) return;
this._deprecationLogged.add(alias);
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
}
_validatePayload(descriptor, msg, log) {
const schema = descriptor.payloadSchema;
if (!schema) return true;
const payload = msg.payload;
const type = schema.type || 'any';
if (!SCALAR_TYPES.has(type)) {
log.warn?.(`commandRegistry: command '${descriptor.topic}' has unknown schema type '${type}'`);
return true;
}
if (type === 'any') return true;
// typeof null === 'object' — explicit null fails an object schema.
if (type === 'object') {
if (payload === null || typeof payload !== 'object') {
log.warn?.(`commandRegistry: '${descriptor.topic}' expected object payload, got ${payload === null ? 'null' : typeof payload}`);
return false;
}
} else if (typeof payload !== type) {
log.warn?.(`commandRegistry: '${descriptor.topic}' expected ${type} payload, got ${typeof payload}`);
return false;
}
if (type === 'object' && schema.properties && typeof schema.properties === 'object') {
for (const [key, expected] of Object.entries(schema.properties)) {
if (!(key in payload)) continue; // missing keys allowed
if (typeof payload[key] !== expected) {
log.warn?.(`commandRegistry: '${descriptor.topic}' payload.${key} expected ${expected}, got ${typeof payload[key]}`);
return false;
}
}
}
return true;
}
_loggerFor(ctx) {
const candidate = (ctx && ctx.logger) || this._logger;
return candidate || NOOP_LOGGER;
}
}
const NOOP_LOGGER = { warn() {}, error() {}, info() {}, debug() {} };
function createRegistry(commands, options) {
return new CommandRegistry(commands, options);
}
module.exports = { createRegistry, CommandRegistry };

View 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 };

View File

@@ -0,0 +1,90 @@
/**
* StatusUpdater — periodic Node-RED status badge poller.
*
* Replaces the per-node `_statusInterval` boilerplate (e.g. pumpingStation
* nodeClass lines 160-171) with one class. The adapter constructs it once
* with a `node` (Node-RED handle) and a `source` (the domain), and the
* loop drives `node.status(source.getStatusBadge())` at a fixed cadence.
*
* Errors thrown from the domain become a red error badge instead of
* crashing the interval — operators see the failure in the editor.
*
* See CONTRACTS.md §7 for the badge shape; statusBadge.js for the helpers.
*/
'use strict';
const { statusBadge } = require('./statusBadge');
const CLEAR_BADGE = {};
class StatusUpdater {
constructor({ node, source, intervalMs, logger } = {}) {
if (!node || typeof node.status !== 'function') {
throw new Error('StatusUpdater: node must expose a .status(badge) method');
}
if (!source || typeof source.getStatusBadge !== 'function') {
throw new Error('StatusUpdater: source must expose a .getStatusBadge() method');
}
this._node = node;
this._source = source;
this._intervalMs = Number.isFinite(intervalMs) ? intervalMs : 0;
this._logger = logger || null;
this._timer = null;
}
get isRunning() {
return this._timer !== null;
}
start() {
// intervalMs=0 keeps unit tests / headless harnesses silent.
if (this._intervalMs <= 0) return;
if (this._timer !== null) return;
this._timer = setInterval(() => this._tick(), this._intervalMs);
}
stop() {
if (this._timer !== null) {
clearInterval(this._timer);
this._timer = null;
}
// Wipe the badge so a stale label doesn't linger in the editor
// after the node is closed/redeployed.
try { this._node.status(CLEAR_BADGE); } catch (_) { /* best effort */ }
}
_tick() {
let badge;
try {
badge = this._source.getStatusBadge();
} catch (err) {
const msg = err && err.message ? err.message : String(err);
if (this._logger && typeof this._logger.error === 'function') {
this._logger.error(`StatusUpdater: getStatusBadge threw: ${msg}`);
}
this._safeApply(statusBadge.error(msg));
return;
}
if (badge == null) {
this._safeApply(CLEAR_BADGE);
return;
}
this._safeApply(badge);
}
_safeApply(badge) {
try {
this._node.status(badge);
} catch (err) {
// node.status itself failing is exotic (e.g. node already
// closed). Log once per tick; the next tick will retry.
if (this._logger && typeof this._logger.error === 'function') {
const msg = err && err.message ? err.message : String(err);
this._logger.error(`StatusUpdater: node.status threw: ${msg}`);
}
}
}
}
module.exports = { StatusUpdater };

52
src/stats/index.js Normal file
View 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 };

View File

@@ -0,0 +1,195 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('events');
const BaseDomain = require('../../src/domain/BaseDomain');
const UnitPolicy = require('../../src/domain/UnitPolicy');
// ── Subclasses ────────────────────────────────────────────────────────
// Minimal subclass — relies on every base default. Uses 'measurement' so the
// configManager finds a real config schema in src/configs/measurement.json.
class PlainMeasurement extends BaseDomain {
static name = 'measurement';
}
// Subclass that records call ordering and exposes hooks.
class TrackingMeasurement extends BaseDomain {
static name = 'measurement';
configure() {
this.calls = this.calls || [];
// Pin the moment at which `configure` runs — these MUST be populated
// before the hook fires.
this.calls.push({
hook: 'configure',
hasConfig: !!this.config,
hasMeasurements: !!this.measurements,
});
}
_init() {
this.calls = this.calls || [];
this.calls.push({ hook: '_init' });
}
}
// Subclass with a UnitPolicy — verify containerOptions reach MeasurementContainer.
class PolicyMeasurement extends BaseDomain {
static name = 'measurement';
static unitPolicy = UnitPolicy.declare({
canonical: { flow: 'm3/s', pressure: 'Pa' },
output: { flow: 'L/s', pressure: 'kPa' },
});
}
// Subclass that declares a child getter in `configure`.
class ParentDomain extends BaseDomain {
static name = 'measurement';
configure() {
this.declareChildGetter('machines', 'machine');
}
}
// ── Helpers ──────────────────────────────────────────────────────────
function makeChild({ id = 'c1', name = id, softwareType = 'machine', category = 'centrifugal' } = {}) {
return {
config: {
general: { id, name },
functionality: { softwareType },
asset: { category, type: 'pump' },
},
measurements: {
emitter: new EventEmitter(),
setChildId() {}, setChildName() {}, setParentRef() {},
},
};
}
// ── Tests ────────────────────────────────────────────────────────────
test('constructs successfully against a real config schema', () => {
const m = new PlainMeasurement({});
assert.ok(m.config?.general?.name);
assert.ok(m.measurements);
assert.ok(m.logger);
assert.ok(m.emitter);
assert.ok(m.childRegistrationUtils);
assert.ok(m.router);
});
test('configure() runs after config + measurements are populated, exactly once', () => {
const m = new TrackingMeasurement({});
const configureCalls = m.calls.filter(c => c.hook === 'configure');
assert.equal(configureCalls.length, 1);
assert.equal(configureCalls[0].hasConfig, true);
assert.equal(configureCalls[0].hasMeasurements, true);
});
test('_init() runs after configure()', () => {
const m = new TrackingMeasurement({});
const order = m.calls.map(c => c.hook);
assert.deepEqual(order, ['configure', '_init']);
});
test('static unitPolicy is honored — defaultUnits reflect output map', () => {
const m = new PolicyMeasurement({});
// PolicyMeasurement declares output.flow='L/s', output.pressure='kPa'
assert.equal(m.measurements.defaultUnits.flow, 'L/s');
assert.equal(m.measurements.defaultUnits.pressure, 'kPa');
// Canonical flow was declared as 'm3/s'
assert.equal(m.measurements.canonicalUnits.flow, 'm3/s');
});
test('without unitPolicy, MeasurementContainer keeps its built-in defaults', () => {
const m = new PlainMeasurement({});
assert.equal(m.unitPolicy, null);
// Built-in defaults from MeasurementContainer.
assert.equal(m.measurements.defaultUnits.flow, 'm3/h');
assert.equal(m.measurements.defaultUnits.pressure, 'mbar');
assert.equal(m.measurements.autoConvert, true);
});
test('declareChildGetter flattens registry slice across categories', () => {
const p = new ParentDomain({});
// Empty before any registration.
assert.deepEqual(p.machines, {});
// Mirror what childRegistrationUtils._storeChild does: child.machine.<cat>=[...]
const a = makeChild({ id: 'pumpA', category: 'centrifugal' });
const b = makeChild({ id: 'pumpB', category: 'positivedisplacement' });
p.child = { machine: { centrifugal: [a], positivedisplacement: [b] } };
const flat = p.machines;
assert.deepEqual(Object.keys(flat).sort(), ['pumpA', 'pumpB']);
assert.equal(flat.pumpA, a);
assert.equal(flat.pumpB, b);
});
test('notifyOutputChanged fires "output-changed" on emitter', () => {
const m = new PlainMeasurement({});
let count = 0;
m.emitter.on('output-changed', () => count++);
m.notifyOutputChanged();
m.notifyOutputChanged();
assert.equal(count, 2);
});
test('context() returns a frozen object with the documented keys', () => {
const m = new PlainMeasurement({});
const ctx = m.context();
assert.ok(Object.isFrozen(ctx));
for (const k of ['config', 'logger', 'measurements', 'emitter', 'child', 'unitPolicy', 'router']) {
assert.ok(k in ctx, `context() missing key '${k}'`);
}
assert.equal(ctx.config, m.config);
assert.equal(ctx.measurements, m.measurements);
});
test('close() removes emitter listeners and tears down router', () => {
const m = new PlainMeasurement({});
let teardownCount = 0;
const origTeardown = m.router.tearDown.bind(m.router);
m.router.tearDown = () => { teardownCount++; origTeardown(); };
m.emitter.on('output-changed', () => {});
assert.equal(m.emitter.listenerCount('output-changed'), 1);
m.close();
assert.equal(teardownCount, 1);
assert.equal(m.emitter.listenerCount('output-changed'), 0);
});
test('registerChild delegates to router.dispatchRegister', () => {
const m = new PlainMeasurement({});
const seen = [];
const origDispatch = m.router.dispatchRegister.bind(m.router);
m.router.dispatchRegister = (child, st) => {
seen.push({ id: child.config.general.id, st });
return origDispatch(child, st);
};
const child = makeChild({ id: 'kid1', softwareType: 'measurement' });
const result = m.registerChild(child, 'measurement');
assert.equal(result, true);
assert.deepEqual(seen, [{ id: 'kid1', st: 'measurement' }]);
});
test('childRegistrationUtils.registerChild flows through router (end-to-end handshake)', async () => {
const m = new PlainMeasurement({});
let routed = null;
m.router.onRegister('measurement', (child, st) => {
routed = { id: child.config.general.id, st };
});
const child = makeChild({ id: 'kid2', softwareType: 'measurement' });
await m.childRegistrationUtils.registerChild(child, 'upstream', 0);
assert.deepEqual(routed, { id: 'kid2', st: 'measurement' });
});
test('direct BaseDomain instantiation throws (abstract)', () => {
assert.throws(() => new BaseDomain({}), /abstract/);
});

View File

@@ -0,0 +1,337 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('events');
const BaseNodeAdapter = require('../../src/nodered/BaseNodeAdapter');
// ---- test doubles ---------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
warn: (...a) => calls.warn.push(a.join(' ')),
error: (...a) => calls.error.push(a.join(' ')),
info: (...a) => calls.info.push(a.join(' ')),
debug: (...a) => calls.debug.push(a.join(' ')),
_calls: calls,
};
}
function makeNode(id = 'node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id,
sends,
statuses,
handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {},
error() {},
};
}
function makeRED() {
return { nodes: { getNode: () => null } };
}
// Fake domain — surfaces just enough of the BaseDomain contract that
// BaseNodeAdapter touches (config, logger, emitter, getOutput, getStatusBadge,
// optionally tick + close). Avoids the JSON-config dependency BaseDomain has.
function makeDomain(opts = {}) {
const logger = opts.logger || makeLogger();
return class FakeDomain {
constructor(config) {
this.config = config;
this.logger = logger;
this.emitter = new EventEmitter();
this.tickCount = 0;
this.closed = false;
this._output = opts.output || { temperature: 21 };
this._badge = opts.badge || { fill: 'green', shape: 'dot', text: 'OK' };
}
tick() { this.tickCount += 1; }
getOutput() { return this._output; }
getStatusBadge() { return this._badge; }
close() { this.closed = true; }
};
}
// uiConfig field set used by configManager.buildConfig — measurement is
// chosen as the config-file name because measurement.json ships in
// generalFunctions/src/configs and getConfig() is called during construction.
function uiConfigFixture() {
return {
name: 'm1', unit: 'C', logLevel: 'warn',
positionVsParent: 'upstream', hasDistance: true, distance: 5,
};
}
// ---- 1. Construction with full subclass succeeds --------------------------
test('full subclass constructs and stores wiring on this', () => {
const Domain = makeDomain();
class Adapter extends BaseNodeAdapter {
static DomainClass = Domain;
static commands = [];
// Disable the real status interval — would hold the event loop open
// past the test and stall `node --test test/basic/` runs.
static statusInterval = 0;
buildDomainConfig() { return { extra: { foo: 1 } }; }
}
const node = makeNode();
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
assert.equal(a.name, 'measurement');
assert.equal(a.node, node);
assert.equal(node.source, a.source);
assert.equal(a.config.extra.foo, 1);
assert.equal(a.config.general.name, 'm1');
node.handlers.close(() => {});
});
// ---- 2-4. Static-field validation -----------------------------------------
test('direct new BaseNodeAdapter() throws abstract error', () => {
assert.throws(
() => new BaseNodeAdapter({}, makeRED(), makeNode(), 'measurement'),
/abstract/,
);
});
test('subclass without static DomainClass throws clearly', () => {
class Bad extends BaseNodeAdapter { static commands = []; buildDomainConfig() { return {}; } }
assert.throws(
() => new Bad({}, makeRED(), makeNode(), 'measurement'),
/DomainClass is required/,
);
});
test('subclass without static commands throws clearly', () => {
class Bad extends BaseNodeAdapter {
static DomainClass = makeDomain();
buildDomainConfig() { return {}; }
}
assert.throws(
() => new Bad({}, makeRED(), makeNode(), 'measurement'),
/commands is required/,
);
});
test('static commands = [] is allowed (explicit no-op registry)', () => {
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [];
static statusInterval = 0; // see fix in test #1
buildDomainConfig() { return {}; }
}
const node = makeNode();
assert.doesNotThrow(
() => new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'),
);
node.handlers.close(() => {});
});
// ---- 5. Registration message after 100 ms ---------------------------------
test('registration message fires on Port 2 after 100 ms with child.register', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [];
buildDomainConfig() { return {}; }
}
const node = makeNode('xyz');
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
assert.equal(node.sends.length, 0);
t.mock.timers.tick(100);
assert.equal(node.sends.length, 1);
const [p0, p1, reg] = node.sends[0];
assert.equal(p0, null);
assert.equal(p1, null);
assert.equal(reg.topic, 'child.register');
assert.equal(reg.payload, 'xyz');
assert.equal(reg.positionVsParent, 'upstream');
assert.equal(reg.distance, 5);
});
// ---- 6. Tick mode ---------------------------------------------------------
test('static tickInterval > 0 calls source.tick() on schedule and emits outputs', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [];
static tickInterval = 50;
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
assert.equal(a.source.tickCount, 0);
t.mock.timers.tick(50);
assert.equal(a.source.tickCount, 1);
t.mock.timers.tick(100);
assert.equal(a.source.tickCount, 3);
// Every tick triggers an output emission (the first carries the changed
// fields; subsequent ones may emit nulls because of delta compression —
// but node.send is called either way).
assert.ok(node.sends.length >= 3);
});
// ---- 7. Event-driven default ----------------------------------------------
test('default (no tick) subscribes to "output-changed" on source.emitter', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
// Drain the registration tick so we can isolate output emissions.
t.mock.timers.tick(100);
const before = node.sends.length;
a.source.emitter.emit('output-changed');
assert.equal(node.sends.length, before + 1);
const last = node.sends[node.sends.length - 1];
assert.equal(last.length, 3);
assert.equal(last[2], null);
});
// ---- 8. _emitOutputs shape ------------------------------------------------
test('_emitOutputs sends [processMsg, influxMsg, null] with both formatters', () => {
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain({ output: { v: 1 } });
static commands = [];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
node.sends.length = 0;
a._emitOutputs();
assert.equal(node.sends.length, 1);
const [proc, influx, port2] = node.sends[0];
assert.ok(proc && typeof proc === 'object', 'process msg present');
assert.ok(influx && typeof influx === 'object', 'influxdb msg present');
assert.equal(port2, null);
});
// ---- 9-10. Input dispatch -------------------------------------------------
test('input handler dispatches a known topic to the registered handler', async () => {
const seen = [];
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [{
topic: 'set.mode',
handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
}];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
let donec = 0;
await node.handlers.input({ topic: 'set.mode', payload: 'auto' }, () => {}, () => { donec += 1; });
assert.equal(seen.length, 1);
assert.equal(seen[0].source, a.source);
assert.equal(seen[0].msg.payload, 'auto');
assert.equal(donec, 1);
});
test('input handler with unknown topic warns and does not crash', async () => {
const logger = makeLogger();
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain({ logger });
static commands = [];
static statusInterval = 0;
buildDomainConfig() { return {}; }
}
const node = makeNode();
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
let donec = 0;
await node.handlers.input({ topic: 'totally.unknown', payload: 1 }, () => {}, () => { donec += 1; });
assert.equal(donec, 1);
assert.ok(logger._calls.warn.some((m) => m.includes('totally.unknown')));
});
// ---- 11. Status updater wiring --------------------------------------------
test('status updater receives static statusInterval', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain({ badge: { fill: 'red', shape: 'ring', text: 'X' } });
static commands = [];
static statusInterval = 250;
buildDomainConfig() { return {}; }
}
const node = makeNode();
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
assert.equal(node.statuses.length, 0);
t.mock.timers.tick(250);
assert.equal(node.statuses.length, 1);
assert.deepEqual(node.statuses[0], { fill: 'red', shape: 'ring', text: 'X' });
});
// ---- 12. Close handler ----------------------------------------------------
test('close handler clears tick interval, stops status, clears badge, calls source.close', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [];
static tickInterval = 100;
static statusInterval = 100;
buildDomainConfig() { return {}; }
}
const node = makeNode();
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
t.mock.timers.tick(200); // two ticks fire
const ticksAtClose = a.source.tickCount;
let donec = 0;
node.handlers.close(() => { donec += 1; });
assert.equal(donec, 1);
assert.equal(a.source.closed, true);
// Final node.status({}) appears in statuses.
assert.deepEqual(node.statuses[node.statuses.length - 1], {});
// No further ticks after close.
t.mock.timers.tick(1000);
assert.equal(a.source.tickCount, ticksAtClose);
});
// ---- 13. Hook points fire when defined ------------------------------------
test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const trace = [];
class Adapter extends BaseNodeAdapter {
static DomainClass = makeDomain();
static commands = [{ topic: 'set.x', handler: () => { trace.push('handler'); } }];
static statusInterval = 0;
buildDomainConfig() { return {}; }
extraSetup() { trace.push('extraSetup'); }
extraInputDispatch(msg) { trace.push(`extraInput:${msg.topic}`); }
extraClose() { trace.push('extraClose'); }
}
const node = makeNode();
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
assert.ok(trace.includes('extraSetup'));
await node.handlers.input({ topic: 'set.x', payload: 1 }, () => {}, () => {});
assert.ok(trace.includes('handler'));
assert.ok(trace.includes('extraInput:set.x'));
// Unknown-topic path also runs extraInputDispatch — by design, it's the
// fallback the contract documents.
await node.handlers.input({ topic: 'unknown', payload: 1 }, () => {}, () => {});
assert.ok(trace.includes('extraInput:unknown'));
node.handlers.close(() => {});
assert.ok(trace.includes('extraClose'));
});

View 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);
});

View 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');
});

View 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);
});

View 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');
});

View File

@@ -0,0 +1,235 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry, CommandRegistry } = require('../../src/nodered/commandRegistry');
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
warn: (...a) => calls.warn.push(a.join(' ')),
error: (...a) => calls.error.push(a.join(' ')),
info: (...a) => calls.info.push(a.join(' ')),
debug: (...a) => calls.debug.push(a.join(' ')),
_calls: calls,
};
}
test('canonical topic dispatch invokes the handler with (source, msg, ctx)', async () => {
const seen = [];
const reg = createRegistry([{
topic: 'set.mode',
handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
}]);
const source = { id: 'src' };
const ctx = { tag: 'ctx' };
const msg = { topic: 'set.mode', payload: 'auto' };
await reg.dispatch(msg, source, ctx);
assert.equal(seen.length, 1);
assert.equal(seen[0].source, source);
assert.equal(seen[0].msg, msg);
assert.equal(seen[0].ctx, ctx);
});
test('alias dispatch invokes handler and logs deprecation warning once', async () => {
const logger = makeLogger();
let count = 0;
const reg = createRegistry([{
topic: 'set.mode',
aliases: ['setMode'],
handler: () => { count += 1; },
}], { logger });
await reg.dispatch({ topic: 'setMode', payload: 'auto' }, {}, {});
await reg.dispatch({ topic: 'setMode', payload: 'manual' }, {}, {});
assert.equal(count, 2);
const deprecationWarns = logger._calls.warn.filter((m) => m.includes('deprecated'));
assert.equal(deprecationWarns.length, 1);
assert.match(deprecationWarns[0], /setMode/);
assert.match(deprecationWarns[0], /set\.mode/);
});
test('unknown topic logs warn and returns without throwing', async () => {
const logger = makeLogger();
const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger });
await reg.dispatch({ topic: 'no.such.topic' }, {}, {});
assert.ok(logger._calls.warn.some((m) => m.includes('unknown topic')));
});
test('payloadSchema scalar rejects mismatched payload', async () => {
const logger = makeLogger();
let invoked = false;
const reg = createRegistry([{
topic: 'set.demand',
payloadSchema: { type: 'number' },
handler: () => { invoked = true; },
}], { logger });
await reg.dispatch({ topic: 'set.demand', payload: 'not-a-number' }, {}, {});
assert.equal(invoked, false);
assert.ok(logger._calls.warn.some((m) => m.includes('expected number')));
});
test('payloadSchema object properties enforce per-key typeof', async () => {
const logger = makeLogger();
const accepted = [];
const reg = createRegistry([{
topic: 'cmd.startup',
payloadSchema: { type: 'object', properties: { name: 'string' } },
handler: (_s, msg) => { accepted.push(msg.payload); },
}], { logger });
await reg.dispatch({ topic: 'cmd.startup', payload: { name: 'foo' } }, {}, {});
await reg.dispatch({ topic: 'cmd.startup', payload: { name: 42 } }, {}, {});
assert.deepEqual(accepted, [{ name: 'foo' }]);
assert.ok(logger._calls.warn.some((m) => m.includes('payload.name')));
});
test('payloadSchema type any accepts any payload', async () => {
const logger = makeLogger();
const seen = [];
const reg = createRegistry([{
topic: 'data.measurement',
payloadSchema: { type: 'any' },
handler: (_s, msg) => { seen.push(msg.payload); },
}], { logger });
await reg.dispatch({ topic: 'data.measurement', payload: 1 }, {}, {});
await reg.dispatch({ topic: 'data.measurement', payload: 'x' }, {}, {});
await reg.dispatch({ topic: 'data.measurement', payload: { a: 1 } }, {}, {});
await reg.dispatch({ topic: 'data.measurement', payload: null }, {}, {});
assert.equal(seen.length, 4);
assert.equal(logger._calls.warn.length, 0);
});
test('async handler returns a promise that resolves after the handler completes', async () => {
let done = false;
const reg = createRegistry([{
topic: 'cmd.calibrate',
handler: async () => {
await new Promise((r) => setImmediate(r));
done = true;
},
}]);
const p = reg.dispatch({ topic: 'cmd.calibrate' }, {}, {});
assert.equal(done, false);
await p;
assert.equal(done, true);
});
test('duplicate canonical topic throws at construction', () => {
assert.throws(() => createRegistry([
{ topic: 'set.mode', handler: () => {} },
{ topic: 'set.mode', handler: () => {} },
]), /duplicate command topic/);
});
test('alias collides with another command canonical topic throws', () => {
assert.throws(() => createRegistry([
{ topic: 'set.mode', handler: () => {} },
{ topic: 'cmd.startup', aliases: ['set.mode'], handler: () => {} },
]), /collides/);
});
test('alias collides with another alias throws', () => {
assert.throws(() => createRegistry([
{ topic: 'set.mode', aliases: ['mode'], handler: () => {} },
{ topic: 'cmd.start', aliases: ['mode'], handler: () => {} },
]), /collides/);
});
test('list() returns descriptors without handler functions', () => {
const reg = createRegistry([
{ topic: 'set.mode', aliases: ['setMode'], payloadSchema: { type: 'string' }, handler: () => {} },
{ topic: 'cmd.startup', handler: () => {} },
]);
const list = reg.list();
assert.equal(list.length, 2);
assert.deepEqual(list[0], {
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
});
assert.deepEqual(list[1], {
topic: 'cmd.startup',
aliases: [],
payloadSchema: null,
});
for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor');
});
test('deprecationStats reflects alias hit counts', async () => {
const logger = makeLogger();
const reg = createRegistry([{
topic: 'set.mode',
aliases: ['setMode', 'changemode'],
handler: () => {},
}], { logger });
await reg.dispatch({ topic: 'setMode', payload: 'a' }, {}, {});
await reg.dispatch({ topic: 'setMode', payload: 'b' }, {}, {});
await reg.dispatch({ topic: 'changemode', payload: 'c' }, {}, {});
await reg.dispatch({ topic: 'set.mode', payload: 'd' }, {}, {});
assert.deepEqual(reg.deprecationStats(), { setMode: 2, changemode: 1 });
});
test('canonical() resolves alias to canonical topic; passes through canonical', () => {
const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]);
assert.equal(reg.canonical('setMode'), 'set.mode');
assert.equal(reg.canonical('set.mode'), 'set.mode');
assert.equal(reg.canonical('unknown'), 'unknown');
});
test('has() reports membership for canonical and alias keys', () => {
const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]);
assert.equal(reg.has('set.mode'), true);
assert.equal(reg.has('setMode'), true);
assert.equal(reg.has('nope'), false);
});
test('CommandRegistry class is exported for advanced cases', () => {
const reg = new CommandRegistry([{ topic: 'set.mode', handler: () => {} }]);
assert.ok(reg instanceof CommandRegistry);
});
test('msg without topic logs warn and does not throw', async () => {
const logger = makeLogger();
const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger });
await reg.dispatch({ payload: 'x' }, {}, {});
assert.ok(logger._calls.warn.some((m) => m.includes('no topic')));
});
test('ctx.logger overrides the constructor logger at dispatch time', async () => {
const ctorLogger = makeLogger();
const ctxLogger = makeLogger();
const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger: ctorLogger });
await reg.dispatch({ topic: 'unknown' }, {}, { logger: ctxLogger });
assert.equal(ctorLogger._calls.warn.length, 0);
assert.ok(ctxLogger._calls.warn.some((m) => m.includes('unknown topic')));
});
test('object schema rejects null payload (typeof null === object guard)', async () => {
const logger = makeLogger();
let invoked = false;
const reg = createRegistry([{
topic: 'cmd.startup',
payloadSchema: { type: 'object' },
handler: () => { invoked = true; },
}], { logger });
await reg.dispatch({ topic: 'cmd.startup', payload: null }, {}, {});
assert.equal(invoked, false);
assert.ok(logger._calls.warn.some((m) => m.includes('expected object')));
});
test('constructor throws on missing topic / handler', () => {
assert.throws(() => createRegistry([{ handler: () => {} }]), /topic/);
assert.throws(() => createRegistry([{ topic: 'set.x' }]), /handler/);
});
test('constructor throws when input is not an array', () => {
assert.throws(() => createRegistry(null), /array/);
assert.throws(() => createRegistry({}), /array/);
});

View 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);
});

View 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');
});

View File

@@ -0,0 +1,189 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { StatusUpdater } = require('../../src/nodered/statusUpdater');
function makeNode() {
const calls = [];
return {
calls,
status(badge) { calls.push(badge); },
};
}
function makeSource(initial) {
return {
badge: initial,
throwOnNext: false,
getStatusBadge() {
if (this.throwOnNext) {
this.throwOnNext = false;
throw new Error('boom');
}
return this.badge;
},
};
}
function makeLogger() {
const errors = [];
return {
errors,
error(msg) { errors.push(msg); },
};
}
test('start() schedules a tick that applies the source badge', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
u.start();
assert.equal(node.calls.length, 0);
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 1);
assert.deepEqual(node.calls[0], { fill: 'green', shape: 'dot', text: 'OK' });
u.stop();
});
test('multiple ticks reflect the latest badge from the source', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'A' });
const u = new StatusUpdater({ node, source, intervalMs: 500 });
u.start();
t.mock.timers.tick(500);
source.badge = { fill: 'yellow', shape: 'dot', text: 'B' };
t.mock.timers.tick(500);
source.badge = { fill: 'red', shape: 'ring', text: 'C' };
t.mock.timers.tick(500);
assert.equal(node.calls.length, 3);
assert.equal(node.calls[0].text, 'A');
assert.equal(node.calls[1].text, 'B');
assert.equal(node.calls[2].text, 'C');
u.stop();
});
test('source returns null → node.status({}) is called', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource(null);
const u = new StatusUpdater({ node, source, intervalMs: 100 });
u.start();
t.mock.timers.tick(100);
assert.equal(node.calls.length, 1);
assert.deepEqual(node.calls[0], {});
u.stop();
});
test('source throw → error logged, error badge applied, next tick still runs', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const logger = makeLogger();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
source.throwOnNext = true;
const u = new StatusUpdater({ node, source, intervalMs: 1000, logger });
u.start();
t.mock.timers.tick(1000);
assert.equal(logger.errors.length, 1, 'error logged once');
assert.match(logger.errors[0], /boom/);
assert.deepEqual(node.calls[0], { fill: 'red', shape: 'ring', text: '⚠ boom' });
// Subsequent tick: source recovers, normal badge resumes.
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 2);
assert.deepEqual(node.calls[1], { fill: 'green', shape: 'dot', text: 'OK' });
u.stop();
});
test('stop() halts the interval AND clears the badge', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 500 });
u.start();
t.mock.timers.tick(500);
assert.equal(node.calls.length, 1);
u.stop();
assert.equal(u.isRunning, false);
// stop() pushes a clear-badge call.
assert.equal(node.calls.length, 2);
assert.deepEqual(node.calls[1], {});
// No further ticks after stop.
t.mock.timers.tick(5000);
assert.equal(node.calls.length, 2);
});
test('start() called twice does not schedule two intervals', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
u.start();
u.start();
u.start();
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 1, 'one tick per interval period');
t.mock.timers.tick(1000);
assert.equal(node.calls.length, 2);
u.stop();
});
test('intervalMs: 0 makes start() a no-op', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source, intervalMs: 0 });
u.start();
assert.equal(u.isRunning, false);
t.mock.timers.tick(10000);
assert.equal(node.calls.length, 0);
});
test('intervalMs omitted is also treated as a no-op', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
const u = new StatusUpdater({ node, source });
u.start();
assert.equal(u.isRunning, false);
t.mock.timers.tick(10000);
assert.equal(node.calls.length, 0);
});
test('constructor throws if node.status is missing', () => {
const source = makeSource(null);
assert.throws(
() => new StatusUpdater({ node: {}, source, intervalMs: 1000 }),
/node must expose a \.status/,
);
assert.throws(
() => new StatusUpdater({ node: null, source, intervalMs: 1000 }),
/node must expose a \.status/,
);
});
test('constructor throws if source.getStatusBadge is missing', () => {
const node = makeNode();
assert.throws(
() => new StatusUpdater({ node, source: {}, intervalMs: 1000 }),
/source must expose a \.getStatusBadge/,
);
assert.throws(
() => new StatusUpdater({ node, source: null, intervalMs: 1000 }),
/source must expose a \.getStatusBadge/,
);
});
test('isRunning getter reflects timer lifecycle', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const node = makeNode();
const source = makeSource(null);
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
assert.equal(u.isRunning, false);
u.start();
assert.equal(u.isRunning, true);
u.stop();
assert.equal(u.isRunning, false);
});