Compare commits
7 Commits
main
...
95c5e684e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c5e684e4 | ||
|
|
8ebf31dd39 | ||
|
|
92eb8d2f15 | ||
|
|
7372d12088 | ||
|
|
62f389a51f | ||
|
|
57b77f905a | ||
|
|
47faf94048 |
29
index.js
29
index.js
@@ -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
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js src/nrmse/errorMetric.test.js"
|
||||
"test": "node --test test/ src/nrmse/errorMetric.test.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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
139
src/domain/BaseDomain.js
Normal 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
184
src/domain/ChildRouter.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* ChildRouter — declarative parent-side child registration & event routing.
|
||||
*
|
||||
* Replaces the per-node `registerChild` switch + manual
|
||||
* `child.measurements.emitter.on(...)` wiring repeated in pumpingStation,
|
||||
* rotatingMachine and machineGroupControl.
|
||||
*
|
||||
* See CONTRACTS.md §5. Built on top of `childRegistrationUtils`, which
|
||||
* already canonicalises softwareType (e.g. rotatingmachine → machine).
|
||||
*/
|
||||
|
||||
// Same alias map as childRegistrationUtils. Duplicated rather than imported
|
||||
// because we need to canonicalise inputs to onRegister/onMeasurement/onPrediction
|
||||
// at *declaration* time (before any child has registered), so that a domain
|
||||
// can write `onRegister('rotatingmachine', ...)` or `onRegister('machine', ...)`
|
||||
// interchangeably and have the dispatch match.
|
||||
const SOFTWARE_TYPE_ALIASES = {
|
||||
rotatingmachine: 'machine',
|
||||
machinegroupcontrol: 'machinegroup',
|
||||
};
|
||||
|
||||
function canonicalType(rawType) {
|
||||
const t = String(rawType || '').toLowerCase();
|
||||
return SOFTWARE_TYPE_ALIASES[t] || t;
|
||||
}
|
||||
|
||||
class ChildRouter {
|
||||
constructor(domain) {
|
||||
this.domain = domain;
|
||||
this.logger = domain?.logger || null;
|
||||
|
||||
// Subscription tables, keyed by canonical softwareType.
|
||||
this._registerSubs = new Map(); // softwareType -> Array<fn>
|
||||
this._measurementSubs = new Map(); // softwareType -> Array<{filter, fn}>
|
||||
this._predictionSubs = new Map(); // softwareType -> Array<{filter, fn}>
|
||||
|
||||
// Track every emitter listener we attach so tearDown can remove them.
|
||||
this._attached = [];
|
||||
}
|
||||
|
||||
// ── declaration API ────────────────────────────────────────────────
|
||||
|
||||
onRegister(softwareType, fn) {
|
||||
if (typeof fn !== 'function') {
|
||||
throw new TypeError('ChildRouter.onRegister: fn must be a function');
|
||||
}
|
||||
const key = canonicalType(softwareType);
|
||||
if (!this._registerSubs.has(key)) this._registerSubs.set(key, []);
|
||||
this._registerSubs.get(key).push(fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
onMeasurement(softwareType, filter, fn) {
|
||||
return this._addEventSub(this._measurementSubs, softwareType, filter, fn, 'onMeasurement');
|
||||
}
|
||||
|
||||
onPrediction(softwareType, filter, fn) {
|
||||
return this._addEventSub(this._predictionSubs, softwareType, filter, fn, 'onPrediction');
|
||||
}
|
||||
|
||||
_addEventSub(table, softwareType, filter, fn, label) {
|
||||
if (typeof filter === 'function' && fn === undefined) {
|
||||
// Allow `onMeasurement(type, fn)` shorthand — no filter.
|
||||
fn = filter;
|
||||
filter = {};
|
||||
}
|
||||
if (typeof fn !== 'function') {
|
||||
throw new TypeError(`ChildRouter.${label}: fn must be a function`);
|
||||
}
|
||||
const key = canonicalType(softwareType);
|
||||
if (!table.has(key)) table.set(key, []);
|
||||
table.get(key).push({ filter: filter || {}, fn });
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by the domain's registerChild(). Runs onRegister handlers, then
|
||||
* attaches measurement/prediction listeners on the child's emitter.
|
||||
*/
|
||||
dispatchRegister(child, softwareType) {
|
||||
const key = canonicalType(softwareType);
|
||||
|
||||
const regHandlers = this._registerSubs.get(key) || [];
|
||||
for (const fn of regHandlers) {
|
||||
try { fn.call(this.domain, child, key); }
|
||||
catch (err) { this._logHandlerError('onRegister', key, err); }
|
||||
}
|
||||
|
||||
const emitter = child?.measurements?.emitter;
|
||||
if (!emitter || typeof emitter.on !== 'function') return;
|
||||
|
||||
this._attachVariantListeners(child, key, emitter, 'measured', this._measurementSubs);
|
||||
this._attachVariantListeners(child, key, emitter, 'predicted', this._predictionSubs);
|
||||
}
|
||||
|
||||
_attachVariantListeners(child, key, emitter, variant, table) {
|
||||
const subs = table.get(key) || [];
|
||||
for (const { filter, fn } of subs) {
|
||||
// Build the set of (type, position) tuples this sub matches. If a filter
|
||||
// omits one or both of {type, position}, we can't pre-enumerate the event
|
||||
// names — fall back to a wildcard listener via `emit`-time matching.
|
||||
if (filter.type && filter.position) {
|
||||
const eventName = `${filter.type}.${variant}.${String(filter.position).toLowerCase()}`;
|
||||
this._attach(emitter, eventName, (data) => this._invoke(fn, data, child, variant));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wildcard: subscribe to a generic catch-all by patching emitter.emit.
|
||||
// EventEmitter has no built-in wildcard — install a one-off proxy listener
|
||||
// that intercepts every emit on this emitter and filters by name.
|
||||
const proxyKey = `__childRouter_proxy_${variant}__`;
|
||||
if (!emitter[proxyKey]) {
|
||||
const origEmit = emitter.emit.bind(emitter);
|
||||
const proxies = [];
|
||||
emitter[proxyKey] = proxies;
|
||||
emitter.emit = (eventName, ...args) => {
|
||||
const parts = String(eventName).split('.');
|
||||
if (parts.length === 3 && parts[1] === variant) {
|
||||
for (const p of proxies) p({ type: parts[0], position: parts[2], args });
|
||||
}
|
||||
return origEmit(eventName, ...args);
|
||||
};
|
||||
// Track the proxy install for tearDown to undo.
|
||||
this._attached.push({ emitter, kind: 'proxy', variant, original: origEmit, proxyKey });
|
||||
}
|
||||
const proxyFn = ({ type, position, args }) => {
|
||||
if (filter.type && type !== filter.type) return;
|
||||
if (filter.position && position !== String(filter.position).toLowerCase()) return;
|
||||
this._invoke(fn, args[0], child, variant);
|
||||
};
|
||||
emitter[proxyKey].push(proxyFn);
|
||||
this._attached.push({ emitter, kind: 'proxyEntry', proxyKey, proxyFn });
|
||||
}
|
||||
}
|
||||
|
||||
_attach(emitter, eventName, listener) {
|
||||
emitter.on(eventName, listener);
|
||||
this._attached.push({ emitter, kind: 'listener', eventName, listener });
|
||||
}
|
||||
|
||||
_invoke(fn, eventData, child, variant) {
|
||||
try { fn.call(this.domain, eventData, child); }
|
||||
catch (err) { this._logHandlerError(`on${variant === 'measured' ? 'Measurement' : 'Prediction'}`, '', err); }
|
||||
}
|
||||
|
||||
_logHandlerError(kind, key, err) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(`ChildRouter ${kind}${key ? `[${key}]` : ''} handler threw: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── teardown ──────────────────────────────────────────────────────
|
||||
|
||||
tearDown() {
|
||||
// Two passes: drop concrete listeners + proxy entries first, then unwrap
|
||||
// any proxies whose entry list is now empty. Order matters — restoring
|
||||
// emit before clearing entries would leave dangling proxy state.
|
||||
for (const rec of this._attached) {
|
||||
if (rec.kind === 'listener') {
|
||||
if (typeof rec.emitter.off === 'function') rec.emitter.off(rec.eventName, rec.listener);
|
||||
else if (typeof rec.emitter.removeListener === 'function') rec.emitter.removeListener(rec.eventName, rec.listener);
|
||||
} else if (rec.kind === 'proxyEntry') {
|
||||
const proxies = rec.emitter[rec.proxyKey];
|
||||
if (Array.isArray(proxies)) {
|
||||
const idx = proxies.indexOf(rec.proxyFn);
|
||||
if (idx >= 0) proxies.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rec of this._attached) {
|
||||
if (rec.kind !== 'proxy') continue;
|
||||
const proxies = rec.emitter[rec.proxyKey];
|
||||
if (!Array.isArray(proxies) || proxies.length === 0) {
|
||||
rec.emitter.emit = rec.original;
|
||||
delete rec.emitter[rec.proxyKey];
|
||||
}
|
||||
}
|
||||
this._attached = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChildRouter;
|
||||
102
src/domain/HealthStatus.js
Normal file
102
src/domain/HealthStatus.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* HealthStatus — standardised health/quality datum.
|
||||
* Contract: see .claude/refactor/CONTRACTS.md §9.
|
||||
*
|
||||
* Shape (always frozen):
|
||||
* { level: 0|1|2|3, flags: string[], message: string, source: string|null }
|
||||
*
|
||||
* level 0 = nominal, 3 = unusable. Returned objects are frozen plain
|
||||
* objects (not class instances) so they round-trip cleanly through
|
||||
* JSON / InfluxDB serialisation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const LABELS = ['nominal', 'minor', 'major', 'critical'];
|
||||
|
||||
function _freeze(level, flags, message, source) {
|
||||
return Object.freeze({
|
||||
level,
|
||||
flags: Object.freeze(flags.slice()),
|
||||
message,
|
||||
source: source == null ? null : String(source),
|
||||
});
|
||||
}
|
||||
|
||||
function _coerceDegradedLevel(level) {
|
||||
const n = Math.trunc(Number(level));
|
||||
if (!Number.isFinite(n) || n < 1) return 1;
|
||||
if (n > 3) return 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
function _coerceFlags(flags) {
|
||||
if (!Array.isArray(flags)) return [];
|
||||
const out = [];
|
||||
for (const f of flags) {
|
||||
if (f == null) continue;
|
||||
out.push(String(f));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ok(message, source) {
|
||||
return _freeze(
|
||||
0,
|
||||
[],
|
||||
typeof message === 'string' && message.length > 0 ? message : 'nominal',
|
||||
source != null ? source : null,
|
||||
);
|
||||
}
|
||||
|
||||
function degraded(level, flags, message, source) {
|
||||
const lvl = _coerceDegradedLevel(level);
|
||||
const f = _coerceFlags(flags);
|
||||
const m = typeof message === 'string' && message.length > 0
|
||||
? message
|
||||
: LABELS[lvl];
|
||||
return _freeze(lvl, f, m, source != null ? source : null);
|
||||
}
|
||||
|
||||
// Merge multiple statuses into one node-level status. Worst level wins
|
||||
// for level/message/source; flags are concatenated and de-duped.
|
||||
function compose(statuses) {
|
||||
if (!Array.isArray(statuses) || statuses.length === 0) return ok();
|
||||
|
||||
let worst = null;
|
||||
const seen = new Set();
|
||||
const flags = [];
|
||||
|
||||
for (const s of statuses) {
|
||||
if (!s || typeof s !== 'object') continue;
|
||||
const lvl = Number.isFinite(s.level) ? s.level : 0;
|
||||
if (worst === null || lvl > worst.level) {
|
||||
worst = { level: lvl, message: s.message, source: s.source ?? null };
|
||||
}
|
||||
if (Array.isArray(s.flags)) {
|
||||
for (const f of s.flags) {
|
||||
if (f == null) continue;
|
||||
const k = String(f);
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
flags.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (worst === null) return ok();
|
||||
|
||||
const message = typeof worst.message === 'string' && worst.message.length > 0
|
||||
? worst.message
|
||||
: LABELS[Math.max(0, Math.min(3, worst.level))];
|
||||
return _freeze(worst.level, flags, message, worst.source);
|
||||
}
|
||||
|
||||
function label(level) {
|
||||
const n = Math.trunc(Number(level));
|
||||
if (!Number.isFinite(n) || n < 0 || n > 3) return 'unknown';
|
||||
return LABELS[n];
|
||||
}
|
||||
|
||||
module.exports = { ok, degraded, compose, label };
|
||||
74
src/domain/LatestWinsGate.js
Normal file
74
src/domain/LatestWinsGate.js
Normal file
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
// Serialises an async dispatch so that high-frequency callers cannot stack
|
||||
// up overlapping invocations. Intermediate values are dropped — only the
|
||||
// most recent fire() during an in-flight dispatch is replayed afterwards.
|
||||
// Extracted from machineGroupControl's _dispatchInFlight + _delayedCall
|
||||
// pattern so MGC, pumpingStation, valveGroupControl etc. can share it.
|
||||
|
||||
class LatestWinsGate {
|
||||
constructor(asyncDispatchFn, options = {}) {
|
||||
if (typeof asyncDispatchFn !== 'function') {
|
||||
throw new TypeError('LatestWinsGate requires an async dispatch function');
|
||||
}
|
||||
this._dispatch = asyncDispatchFn;
|
||||
this._logger = options.logger || null;
|
||||
this._inFlight = false;
|
||||
this._pending = null; // { value, ctx } | null
|
||||
this._drainResolvers = []; // resolved when idle again
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
// 0 = idle, 1 = running with no pending, 2 = running with pending.
|
||||
get size() {
|
||||
if (!this._inFlight) return 0;
|
||||
return this._pending ? 2 : 1;
|
||||
}
|
||||
|
||||
// Never blocks the caller. If a dispatch is in flight, the latest
|
||||
// value is parked; older parked values are silently overwritten.
|
||||
fire(value, ctx) {
|
||||
if (this._inFlight) {
|
||||
this._pending = { value, ctx };
|
||||
return;
|
||||
}
|
||||
this._run(value, ctx);
|
||||
}
|
||||
|
||||
drain() {
|
||||
if (!this._inFlight && !this._pending) return Promise.resolve();
|
||||
return new Promise((resolve) => { this._drainResolvers.push(resolve); });
|
||||
}
|
||||
|
||||
_run(value, ctx) {
|
||||
this._inFlight = true;
|
||||
// Kick the dispatch on a microtask so fire() always returns
|
||||
// synchronously, even if _dispatch resolves immediately.
|
||||
Promise.resolve()
|
||||
.then(() => this._dispatch(value, ctx))
|
||||
.catch((err) => {
|
||||
this.lastError = err;
|
||||
if (this._logger && typeof this._logger.error === 'function') {
|
||||
this._logger.error(err);
|
||||
}
|
||||
// Swallow: an error must not deadlock the gate.
|
||||
})
|
||||
.then(() => this._afterDispatch());
|
||||
}
|
||||
|
||||
_afterDispatch() {
|
||||
this._inFlight = false;
|
||||
if (this._pending) {
|
||||
const { value, ctx } = this._pending;
|
||||
this._pending = null;
|
||||
this._run(value, ctx);
|
||||
return;
|
||||
}
|
||||
// Idle — release any drain() waiters.
|
||||
const waiters = this._drainResolvers;
|
||||
this._drainResolvers = [];
|
||||
for (const r of waiters) r();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LatestWinsGate;
|
||||
149
src/domain/UnitPolicy.js
Normal file
149
src/domain/UnitPolicy.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const convert = require('../convert/index.js');
|
||||
|
||||
// Map MeasurementContainer measurement-type names to convert-module
|
||||
// "measure" families. Mirrors MeasurementContainer.measureMap so a policy
|
||||
// declared with the type names domains use ('flow', 'pressure', ...) can be
|
||||
// validated against the same convert-module families MeasurementContainer
|
||||
// uses internally.
|
||||
const TYPE_TO_MEASURE = Object.freeze({
|
||||
pressure: 'pressure',
|
||||
atmpressure: 'pressure',
|
||||
flow: 'volumeFlowRate',
|
||||
power: 'power',
|
||||
hydraulicpower: 'power',
|
||||
reactivepower: 'reactivePower',
|
||||
apparentpower: 'apparentPower',
|
||||
temperature: 'temperature',
|
||||
volume: 'volume',
|
||||
length: 'length',
|
||||
mass: 'mass',
|
||||
energy: 'energy',
|
||||
reactiveenergy: 'reactiveEnergy',
|
||||
});
|
||||
|
||||
const DEFAULT_REQUIRED_TYPES = Object.freeze(['flow', 'pressure', 'power', 'temperature']);
|
||||
|
||||
class UnitPolicy {
|
||||
constructor({ canonical, output, curve, requireUnitForTypes, logger } = {}) {
|
||||
this._canonical = freezeShallow(canonical);
|
||||
this._output = freezeShallow(output);
|
||||
this._curve = curve ? freezeShallow(curve) : null;
|
||||
this._requireUnitForTypes = Object.freeze(
|
||||
Array.isArray(requireUnitForTypes) ? [...requireUnitForTypes] : [...DEFAULT_REQUIRED_TYPES]
|
||||
);
|
||||
this._logger = logger || null;
|
||||
// Warn-once memo: same (label, candidate) pair only logs the first time.
|
||||
this._warned = new Set();
|
||||
}
|
||||
|
||||
static declare(spec = {}) {
|
||||
if (!spec.canonical || typeof spec.canonical !== 'object') {
|
||||
throw new Error('UnitPolicy.declare: canonical units map is required');
|
||||
}
|
||||
if (!spec.output || typeof spec.output !== 'object') {
|
||||
throw new Error('UnitPolicy.declare: output units map is required');
|
||||
}
|
||||
return new UnitPolicy(spec);
|
||||
}
|
||||
|
||||
setLogger(logger) {
|
||||
this._logger = logger || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
canonical(type) {
|
||||
return this._canonical[type] || null;
|
||||
}
|
||||
|
||||
output(type) {
|
||||
return this._output[type] || null;
|
||||
}
|
||||
|
||||
curve(type) {
|
||||
return this._curve ? (this._curve[type] || null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user-supplied unit string against `expectedMeasure`. On any
|
||||
* mismatch return `fallback` and warn once for this (label, candidate)
|
||||
* pair. On success return the trimmed candidate.
|
||||
*/
|
||||
resolve(candidate, expectedMeasure, fallback, label = 'unit') {
|
||||
const fallbackUnit = String(fallback || '').trim();
|
||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
if (!raw) return fallbackUnit;
|
||||
|
||||
try {
|
||||
const desc = convert().describe(raw);
|
||||
const measure = resolveMeasure(expectedMeasure);
|
||||
if (measure && desc.measure !== measure) {
|
||||
throw new Error(`expected ${measure} but got ${desc.measure}`);
|
||||
}
|
||||
return raw;
|
||||
} catch (error) {
|
||||
this._warnOnce(label, raw, `Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallbackUnit}'.`);
|
||||
return fallbackUnit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict numeric conversion. Throws if value is not finite.
|
||||
* No-ops (still returning a Number) when from/to are missing or equal.
|
||||
*/
|
||||
convert(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
throw new Error(`${contextLabel}: value '${value}' is not finite`);
|
||||
}
|
||||
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
|
||||
return convert(numeric).from(fromUnit).to(toUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the option bag for `new MeasurementContainer(options, logger)`.
|
||||
* Exact shape required by MeasurementContainer; see
|
||||
* src/measurements/MeasurementContainer.js constructor.
|
||||
*/
|
||||
containerOptions() {
|
||||
const defaultUnits = { ...this._output };
|
||||
const preferredUnits = { ...this._output };
|
||||
const canonicalUnits = { ...this._canonical };
|
||||
return {
|
||||
defaultUnits,
|
||||
preferredUnits,
|
||||
canonicalUnits,
|
||||
storeCanonical: true,
|
||||
strictUnitValidation: true,
|
||||
throwOnInvalidUnit: true,
|
||||
requireUnitForTypes: [...this._requireUnitForTypes],
|
||||
};
|
||||
}
|
||||
|
||||
_warnOnce(label, candidate, message) {
|
||||
const key = `${label}::${candidate}`;
|
||||
if (this._warned.has(key)) return;
|
||||
this._warned.add(key);
|
||||
if (this._logger && typeof this._logger.warn === 'function') {
|
||||
this._logger.warn(message);
|
||||
} else {
|
||||
// Last-resort fallback so misconfigurations don't go silent in
|
||||
// domains that haven't wired a logger yet.
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function freezeShallow(obj) {
|
||||
return Object.freeze({ ...(obj || {}) });
|
||||
}
|
||||
|
||||
// Accepts either the convert-module measure family ('volumeFlowRate') or one
|
||||
// of our type names ('flow') and returns the convert-module measure.
|
||||
function resolveMeasure(expected) {
|
||||
if (!expected) return null;
|
||||
const lower = String(expected).trim().toLowerCase();
|
||||
if (TYPE_TO_MEASURE[lower]) return TYPE_TO_MEASURE[lower];
|
||||
return expected;
|
||||
}
|
||||
|
||||
module.exports = UnitPolicy;
|
||||
@@ -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;
|
||||
176
src/nodered/BaseNodeAdapter.js
Normal file
176
src/nodered/BaseNodeAdapter.js
Normal 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;
|
||||
156
src/nodered/commandRegistry.js
Normal file
156
src/nodered/commandRegistry.js
Normal 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 };
|
||||
96
src/nodered/statusBadge.js
Normal file
96
src/nodered/statusBadge.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* statusBadge — small helpers that build Node-RED status objects
|
||||
* ({ fill, shape, text }) consistently across every node.
|
||||
*
|
||||
* See CONTRACTS.md §7. Domains compose badges via these helpers so the
|
||||
* editor look-and-feel converges instead of every node rolling its own
|
||||
* emoji + colour rules.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const MAX_TEXT = 60;
|
||||
const SEPARATOR = ' | ';
|
||||
|
||||
const DEFAULT_BADGE = { fill: 'green', shape: 'dot' };
|
||||
const ERROR_BADGE = { fill: 'red', shape: 'ring' };
|
||||
const IDLE_BADGE = { fill: 'blue', shape: 'dot' };
|
||||
const UNKNOWN_BADGE = { fill: 'grey', shape: 'ring' };
|
||||
|
||||
// Truncate to MAX_TEXT keeping room for the ellipsis. Editor clips the
|
||||
// rest visually anyway, but we want the cut to be deterministic so
|
||||
// snapshot tests don't drift across Node-RED versions.
|
||||
function _clip(text) {
|
||||
if (text == null) return '';
|
||||
const s = String(text);
|
||||
if (s.length <= MAX_TEXT) return s;
|
||||
return s.slice(0, MAX_TEXT - 1) + '…';
|
||||
}
|
||||
|
||||
function _joinParts(parts) {
|
||||
if (!Array.isArray(parts) || parts.length === 0) return '';
|
||||
const kept = parts.filter((p) => p != null && p !== false && p !== '');
|
||||
if (kept.length === 0) return '';
|
||||
return kept.map(String).join(SEPARATOR);
|
||||
}
|
||||
|
||||
function compose(parts, opts) {
|
||||
const text = _clip(_joinParts(parts));
|
||||
return {
|
||||
fill: (opts && opts.fill) || DEFAULT_BADGE.fill,
|
||||
shape: (opts && opts.shape) || DEFAULT_BADGE.shape,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
return {
|
||||
fill: ERROR_BADGE.fill,
|
||||
shape: ERROR_BADGE.shape,
|
||||
text: _clip(`⚠ ${message == null ? '' : message}`),
|
||||
};
|
||||
}
|
||||
|
||||
function idle(label) {
|
||||
return {
|
||||
fill: IDLE_BADGE.fill,
|
||||
shape: IDLE_BADGE.shape,
|
||||
text: _clip(`⏸️ ${label == null ? '' : label}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Look up a state-template badge and optionally compose extra parts
|
||||
// into its text. Missing template falls back to a grey "unknown state"
|
||||
// badge — silent so caller can still surface the bad state through logs.
|
||||
function byState(stateMap, currentState, opts) {
|
||||
const template = stateMap && stateMap[currentState];
|
||||
if (!template) {
|
||||
return {
|
||||
fill: UNKNOWN_BADGE.fill,
|
||||
shape: UNKNOWN_BADGE.shape,
|
||||
text: _clip(`unknown state: ${currentState == null ? '' : currentState}`),
|
||||
};
|
||||
}
|
||||
const baseText = template.text == null ? '' : String(template.text);
|
||||
const extras = opts && Array.isArray(opts.compose) ? opts.compose : [];
|
||||
const merged = extras.length > 0
|
||||
? _joinParts([baseText, ...extras])
|
||||
: baseText;
|
||||
return {
|
||||
fill: template.fill || DEFAULT_BADGE.fill,
|
||||
shape: template.shape || DEFAULT_BADGE.shape,
|
||||
text: _clip(merged),
|
||||
};
|
||||
}
|
||||
|
||||
function text(string, opts) {
|
||||
return {
|
||||
fill: (opts && opts.fill) || DEFAULT_BADGE.fill,
|
||||
shape: (opts && opts.shape) || DEFAULT_BADGE.shape,
|
||||
text: _clip(string == null ? '' : string),
|
||||
};
|
||||
}
|
||||
|
||||
const statusBadge = { compose, error, idle, byState, text };
|
||||
|
||||
module.exports = { statusBadge, MAX_TEXT };
|
||||
90
src/nodered/statusUpdater.js
Normal file
90
src/nodered/statusUpdater.js
Normal 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
52
src/stats/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Reducer-shape stats helpers shared across the platform.
|
||||
*
|
||||
* These were duplicated as static helpers on `Channel` and as instance
|
||||
* methods on the older `measurement/specificClass.js`. Consolidated here so
|
||||
* any consumer (outlier detection, monster summaries, future analytics)
|
||||
* can import a single canonical implementation.
|
||||
*
|
||||
* Stream-shape filters (low/high/band-pass, kalman, savitzky-golay) stay
|
||||
* on Channel as static helpers — they're pipeline state, not reducers.
|
||||
*/
|
||||
|
||||
function mean(arr) {
|
||||
if (!arr.length) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
// Sample std dev (n-1 denominator). A single sample has no variance to
|
||||
// estimate, so we return 0 rather than NaN — callers (e.g. z-score) treat
|
||||
// 0 as "no spread yet" and skip rejection.
|
||||
function stdDev(arr) {
|
||||
if (arr.length <= 1) return 0;
|
||||
const m = mean(arr);
|
||||
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
function median(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function mad(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const med = median(arr);
|
||||
return median(arr.map((v) => Math.abs(v - med)));
|
||||
}
|
||||
|
||||
// Degenerate-range pass-through matches Channel._lerp: callers rely on it
|
||||
// for early-warmup paths where input bounds haven't separated yet.
|
||||
function lerp(value, iMin, iMax, oMin, oMax) {
|
||||
if (iMin >= iMax) return value;
|
||||
return oMin + ((value - iMin) * (oMax - oMin)) / (iMax - iMin);
|
||||
}
|
||||
|
||||
module.exports = { mean, stdDev, median, mad, lerp };
|
||||
195
test/basic/BaseDomain.basic.test.js
Normal file
195
test/basic/BaseDomain.basic.test.js
Normal 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/);
|
||||
});
|
||||
337
test/basic/BaseNodeAdapter.basic.test.js
Normal file
337
test/basic/BaseNodeAdapter.basic.test.js
Normal 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'));
|
||||
});
|
||||
197
test/basic/ChildRouter.basic.test.js
Normal file
197
test/basic/ChildRouter.basic.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const ChildRouter = require('../../src/domain/ChildRouter');
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function makeDomain() {
|
||||
const logs = [];
|
||||
return {
|
||||
logger: {
|
||||
debug: (...a) => logs.push(['debug', ...a]),
|
||||
info: (...a) => logs.push(['info', ...a]),
|
||||
warn: (...a) => logs.push(['warn', ...a]),
|
||||
error: (...a) => logs.push(['error', ...a]),
|
||||
},
|
||||
_logs: logs,
|
||||
};
|
||||
}
|
||||
|
||||
function makeChild({ id = 'c1', name = id, softwareType = 'measurement' } = {}) {
|
||||
return {
|
||||
config: {
|
||||
general: { id, name },
|
||||
functionality: { softwareType },
|
||||
asset: { type: 'pressure' },
|
||||
},
|
||||
measurements: { emitter: new EventEmitter() },
|
||||
};
|
||||
}
|
||||
|
||||
function emitMeasured(child, type, position, value, extra = {}) {
|
||||
child.measurements.emitter.emit(`${type}.measured.${position}`, { value, ...extra });
|
||||
}
|
||||
|
||||
function emitPredicted(child, type, position, value, extra = {}) {
|
||||
child.measurements.emitter.emit(`${type}.predicted.${position}`, { value, ...extra });
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────
|
||||
|
||||
test('onRegister fires for the matching softwareType', () => {
|
||||
const domain = makeDomain();
|
||||
const router = new ChildRouter(domain);
|
||||
const seen = [];
|
||||
|
||||
router.onRegister('measurement', (child, st) => seen.push({ id: child.config.general.id, st }));
|
||||
|
||||
const ch = makeChild({ id: 'm1' });
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].id, 'm1');
|
||||
assert.equal(seen[0].st, 'measurement');
|
||||
});
|
||||
|
||||
test('onMeasurement with full filter only fires for matching events', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(data, child) => hits.push({ v: data.value, id: child.config.general.id }));
|
||||
|
||||
const ch = makeChild({ id: 'p-up' });
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 100);
|
||||
emitMeasured(ch, 'pressure', 'downstream', 200); // ignored: wrong position
|
||||
emitMeasured(ch, 'flow', 'upstream', 5); // ignored: wrong type
|
||||
emitPredicted(ch, 'pressure', 'upstream', 999); // ignored: wrong variant
|
||||
|
||||
assert.deepEqual(hits, [{ v: 100, id: 'p-up' }]);
|
||||
});
|
||||
|
||||
test('onMeasurement without position filter fires for all positions of the type', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure' },
|
||||
(data) => hits.push(data.value));
|
||||
|
||||
const ch = makeChild();
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||
emitMeasured(ch, 'pressure', 'downstream', 2);
|
||||
emitMeasured(ch, 'pressure', 'atequipment', 3);
|
||||
emitMeasured(ch, 'flow', 'upstream', 99); // ignored: wrong type
|
||||
emitPredicted(ch, 'pressure', 'upstream', 50); // ignored: wrong variant
|
||||
|
||||
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('onPrediction works analogously to onMeasurement', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onPrediction('machinegroup', { type: 'flow', position: 'downstream' },
|
||||
(data) => hits.push(data.value));
|
||||
|
||||
const ch = makeChild({ softwareType: 'machinegroupcontrol' });
|
||||
router.dispatchRegister(ch, 'machinegroupcontrol');
|
||||
|
||||
emitPredicted(ch, 'flow', 'downstream', 42);
|
||||
emitPredicted(ch, 'flow', 'upstream', 7); // ignored: wrong position
|
||||
emitMeasured(ch, 'flow', 'downstream', 99); // ignored: wrong variant
|
||||
|
||||
assert.deepEqual(hits, [42]);
|
||||
});
|
||||
|
||||
test('software-type alias resolution: onRegister("machine") matches softwareType="rotatingmachine"', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const seen = [];
|
||||
|
||||
router.onRegister('machine', (child) => seen.push(child.config.general.id));
|
||||
|
||||
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
||||
router.dispatchRegister(rm, 'rotatingmachine');
|
||||
|
||||
assert.deepEqual(seen, ['rm-1']);
|
||||
});
|
||||
|
||||
test('alias resolution also flows through measurement subscriptions', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
// Declare with the canonical 'machine' alias.
|
||||
router.onMeasurement('machine', { type: 'flow', position: 'downstream' },
|
||||
(data) => hits.push(data.value));
|
||||
|
||||
// Child reports the raw, non-canonical softwareType.
|
||||
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
||||
router.dispatchRegister(rm, 'rotatingmachine');
|
||||
|
||||
emitMeasured(rm, 'flow', 'downstream', 17);
|
||||
assert.deepEqual(hits, [17]);
|
||||
});
|
||||
|
||||
test('tearDown removes listeners — re-emitting after tearDown does not invoke handler', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const hits = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(data) => hits.push(['concrete', data.value]));
|
||||
router.onMeasurement('measurement', { type: 'pressure' }, // wildcard branch
|
||||
(data) => hits.push(['wild', data.value]));
|
||||
|
||||
const ch = makeChild();
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||
assert.equal(hits.length, 2);
|
||||
|
||||
router.tearDown();
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 2);
|
||||
emitMeasured(ch, 'pressure', 'downstream', 3);
|
||||
assert.equal(hits.length, 2, 'no further hits after tearDown');
|
||||
|
||||
// Original emit should be restored after teardown — sanity-check it still works
|
||||
// for unrelated listeners on the same emitter.
|
||||
let other = 0;
|
||||
ch.measurements.emitter.on('flow.measured.upstream', () => other++);
|
||||
emitMeasured(ch, 'flow', 'upstream', 9);
|
||||
assert.equal(other, 1);
|
||||
});
|
||||
|
||||
test('multiple onMeasurement subscriptions for same softwareType all fire', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const a = []; const b = []; const c = [];
|
||||
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(d) => a.push(d.value));
|
||||
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||
(d) => b.push(d.value)); // duplicate concrete sub
|
||||
router.onMeasurement('measurement', { type: 'pressure' },
|
||||
(d) => c.push(d.value)); // wildcard-position sub
|
||||
|
||||
const ch = makeChild();
|
||||
router.dispatchRegister(ch, 'measurement');
|
||||
|
||||
emitMeasured(ch, 'pressure', 'upstream', 7);
|
||||
|
||||
assert.deepEqual(a, [7]);
|
||||
assert.deepEqual(b, [7]);
|
||||
assert.deepEqual(c, [7]);
|
||||
});
|
||||
|
||||
test('chainable API returns the router instance', () => {
|
||||
const router = new ChildRouter(makeDomain());
|
||||
const r = router
|
||||
.onRegister('measurement', () => {})
|
||||
.onMeasurement('measurement', { type: 'flow' }, () => {})
|
||||
.onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {});
|
||||
assert.equal(r, router);
|
||||
});
|
||||
103
test/basic/HealthStatus.basic.test.js
Normal file
103
test/basic/HealthStatus.basic.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const HealthStatus = require('../../src/domain/HealthStatus');
|
||||
|
||||
test('ok() returns the canonical zero-level shape', () => {
|
||||
const h = HealthStatus.ok();
|
||||
assert.strictEqual(h.level, 0);
|
||||
assert.deepStrictEqual(h.flags, []);
|
||||
assert.strictEqual(h.message, 'nominal');
|
||||
assert.strictEqual(h.source, null);
|
||||
assert.ok(Object.isFrozen(h));
|
||||
assert.ok(Object.isFrozen(h.flags));
|
||||
});
|
||||
|
||||
test('ok(message, source) carries through optional args', () => {
|
||||
const h = HealthStatus.ok('all good', 'aggregator');
|
||||
assert.strictEqual(h.level, 0);
|
||||
assert.strictEqual(h.message, 'all good');
|
||||
assert.strictEqual(h.source, 'aggregator');
|
||||
});
|
||||
|
||||
test('degraded(2, [...], msg, src) returns the right frozen shape', () => {
|
||||
const h = HealthStatus.degraded(2, ['x'], 'msg', 'src');
|
||||
assert.strictEqual(h.level, 2);
|
||||
assert.deepStrictEqual(h.flags, ['x']);
|
||||
assert.strictEqual(h.message, 'msg');
|
||||
assert.strictEqual(h.source, 'src');
|
||||
assert.ok(Object.isFrozen(h));
|
||||
assert.ok(Object.isFrozen(h.flags));
|
||||
// Mutation attempts must not change the frozen flags array.
|
||||
assert.throws(() => { h.flags.push('y'); }, TypeError);
|
||||
});
|
||||
|
||||
test('degraded clamps out-of-range levels (high)', () => {
|
||||
const h = HealthStatus.degraded(7, ['hot'], 'too high');
|
||||
assert.strictEqual(h.level, 3);
|
||||
});
|
||||
|
||||
test('degraded clamps out-of-range levels (low / non-numeric)', () => {
|
||||
const lo = HealthStatus.degraded(0, ['lo'], 'too low');
|
||||
assert.strictEqual(lo.level, 1);
|
||||
const nan = HealthStatus.degraded('nope', ['n'], 'bad input');
|
||||
assert.strictEqual(nan.level, 1);
|
||||
});
|
||||
|
||||
test('degraded falls back to label-derived message when message is empty', () => {
|
||||
const h = HealthStatus.degraded(2, ['x']);
|
||||
assert.strictEqual(h.message, 'major');
|
||||
});
|
||||
|
||||
test('compose([]) returns ok()', () => {
|
||||
const h = HealthStatus.compose([]);
|
||||
assert.strictEqual(h.level, 0);
|
||||
assert.deepStrictEqual(h.flags, []);
|
||||
assert.strictEqual(h.message, 'nominal');
|
||||
assert.strictEqual(h.source, null);
|
||||
});
|
||||
|
||||
test('compose merges, picking worst level + that status\'s message/source', () => {
|
||||
const h = HealthStatus.compose([
|
||||
HealthStatus.ok(),
|
||||
HealthStatus.degraded(1, ['a'], 'a-msg', 'a-src'),
|
||||
HealthStatus.degraded(2, ['b'], 'b-msg', 'b-src'),
|
||||
]);
|
||||
assert.strictEqual(h.level, 2);
|
||||
assert.deepStrictEqual(h.flags, ['a', 'b']);
|
||||
assert.strictEqual(h.message, 'b-msg');
|
||||
assert.strictEqual(h.source, 'b-src');
|
||||
});
|
||||
|
||||
test('compose ties: first worst-level status wins for message/source', () => {
|
||||
const h = HealthStatus.compose([
|
||||
HealthStatus.degraded(2, ['a'], 'first', 'first-src'),
|
||||
HealthStatus.degraded(2, ['b'], 'second', 'second-src'),
|
||||
]);
|
||||
assert.strictEqual(h.level, 2);
|
||||
assert.strictEqual(h.message, 'first');
|
||||
assert.strictEqual(h.source, 'first-src');
|
||||
});
|
||||
|
||||
test('compose dedupes flags across statuses', () => {
|
||||
const h = HealthStatus.compose([
|
||||
HealthStatus.degraded(1, ['x', 'y'], 'one'),
|
||||
HealthStatus.degraded(2, ['y', 'z', 'x'], 'two'),
|
||||
]);
|
||||
assert.deepStrictEqual(h.flags, ['x', 'y', 'z']);
|
||||
});
|
||||
|
||||
test('label maps 0..3 → nominal/minor/major/critical', () => {
|
||||
assert.strictEqual(HealthStatus.label(0), 'nominal');
|
||||
assert.strictEqual(HealthStatus.label(1), 'minor');
|
||||
assert.strictEqual(HealthStatus.label(2), 'major');
|
||||
assert.strictEqual(HealthStatus.label(3), 'critical');
|
||||
});
|
||||
|
||||
test('label returns "unknown" for out-of-range levels', () => {
|
||||
assert.strictEqual(HealthStatus.label(-1), 'unknown');
|
||||
assert.strictEqual(HealthStatus.label(4), 'unknown');
|
||||
assert.strictEqual(HealthStatus.label('x'), 'unknown');
|
||||
});
|
||||
152
test/basic/LatestWinsGate.basic.test.js
Normal file
152
test/basic/LatestWinsGate.basic.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const LatestWinsGate = require('../../src/domain/LatestWinsGate');
|
||||
|
||||
// Helper: a deferred promise so a test can pause a dispatch and inspect
|
||||
// gate state before resolving. Avoids real timers entirely.
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('single fire calls dispatch with the value', async () => {
|
||||
const calls = [];
|
||||
const gate = new LatestWinsGate(async (v) => { calls.push(v); });
|
||||
gate.fire('a');
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, ['a']);
|
||||
});
|
||||
|
||||
test('two fires while in-flight: second value runs after first settles', async () => {
|
||||
const calls = [];
|
||||
const gates = [deferred(), deferred()];
|
||||
const started = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
const slot = n++;
|
||||
calls.push(v);
|
||||
started[slot].resolve();
|
||||
await gates[slot].promise;
|
||||
});
|
||||
|
||||
gate.fire('first');
|
||||
gate.fire('second'); // parks while 'first' is in flight
|
||||
await started[0].promise;
|
||||
assert.deepEqual(calls, ['first']);
|
||||
assert.equal(gate.size, 2);
|
||||
|
||||
gates[0].resolve();
|
||||
await started[1].promise;
|
||||
assert.deepEqual(calls, ['first', 'second']);
|
||||
|
||||
gates[1].resolve();
|
||||
await gate.drain();
|
||||
});
|
||||
|
||||
test('three fires back-to-back: only the last runs after the first settles', async () => {
|
||||
const calls = [];
|
||||
const first = deferred();
|
||||
const firstStarted = deferred();
|
||||
let count = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (count++ === 0) {
|
||||
firstStarted.resolve();
|
||||
await first.promise;
|
||||
}
|
||||
});
|
||||
|
||||
gate.fire(1);
|
||||
gate.fire(2); // parked
|
||||
gate.fire(3); // overwrites 2
|
||||
|
||||
await firstStarted.promise;
|
||||
assert.deepEqual(calls, [1]);
|
||||
first.resolve();
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, [1, 3]);
|
||||
});
|
||||
|
||||
test('drain() resolves only after all queued work has run', async () => {
|
||||
const calls = [];
|
||||
const d = deferred();
|
||||
let started = 0;
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (started++ === 0) await d.promise;
|
||||
});
|
||||
|
||||
gate.fire('x');
|
||||
gate.fire('y');
|
||||
|
||||
let drained = false;
|
||||
const p = gate.drain().then(() => { drained = true; });
|
||||
|
||||
// While first is paused, drain must not have resolved yet.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
|
||||
d.resolve();
|
||||
await p;
|
||||
assert.deepEqual(calls, ['x', 'y']);
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
test('error in dispatch does not prevent subsequent fire from working', async () => {
|
||||
const calls = [];
|
||||
let throwNext = true;
|
||||
const errors = [];
|
||||
const logger = { error: (e) => errors.push(e) };
|
||||
const gate = new LatestWinsGate(async (v) => {
|
||||
calls.push(v);
|
||||
if (throwNext) {
|
||||
throwNext = false;
|
||||
throw new Error('boom');
|
||||
}
|
||||
}, { logger });
|
||||
|
||||
gate.fire('a');
|
||||
await gate.drain();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].message, /boom/);
|
||||
assert.ok(gate.lastError instanceof Error);
|
||||
|
||||
// Gate must still accept further work.
|
||||
gate.fire('b');
|
||||
await gate.drain();
|
||||
assert.deepEqual(calls, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('error is recorded on lastError when no logger is supplied', async () => {
|
||||
const gate = new LatestWinsGate(async () => { throw new Error('silent'); });
|
||||
gate.fire('only');
|
||||
await gate.drain();
|
||||
assert.ok(gate.lastError instanceof Error);
|
||||
assert.match(gate.lastError.message, /silent/);
|
||||
});
|
||||
|
||||
test('size reports 0 / 1 / 2 across the lifecycle', async () => {
|
||||
const d1 = deferred();
|
||||
const gate = new LatestWinsGate(async () => { await d1.promise; });
|
||||
|
||||
assert.equal(gate.size, 0);
|
||||
|
||||
gate.fire('one');
|
||||
// fire is sync, but _dispatch starts on a microtask. Either way the
|
||||
// gate is marked in-flight synchronously.
|
||||
assert.equal(gate.size, 1);
|
||||
|
||||
gate.fire('two'); // parked
|
||||
assert.equal(gate.size, 2);
|
||||
|
||||
d1.resolve();
|
||||
await gate.drain();
|
||||
assert.equal(gate.size, 0);
|
||||
});
|
||||
145
test/basic/UnitPolicy.basic.test.js
Normal file
145
test/basic/UnitPolicy.basic.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const UnitPolicy = require('../../src/domain/UnitPolicy.js');
|
||||
|
||||
function makeFakeLogger() {
|
||||
const calls = { warn: [], info: [], error: [], debug: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
debug: (m) => calls.debug.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
const baseSpec = {
|
||||
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
||||
curve: { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' },
|
||||
};
|
||||
|
||||
test('declare returns a policy whose canonical/output match the input', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.equal(policy.canonical('flow'), 'm3/s');
|
||||
assert.equal(policy.canonical('pressure'), 'Pa');
|
||||
assert.equal(policy.canonical('power'), 'W');
|
||||
assert.equal(policy.canonical('temperature'), 'K');
|
||||
assert.equal(policy.output('flow'), 'm3/h');
|
||||
assert.equal(policy.output('pressure'), 'mbar');
|
||||
assert.equal(policy.output('power'), 'kW');
|
||||
assert.equal(policy.output('temperature'), 'C');
|
||||
assert.equal(policy.curve('flow'), 'm3/h');
|
||||
assert.equal(policy.curve('control'), '%');
|
||||
});
|
||||
|
||||
test('declare throws when canonical or output is missing', () => {
|
||||
assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/);
|
||||
assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/);
|
||||
});
|
||||
|
||||
test('resolve returns the candidate when it matches the expected measure', () => {
|
||||
const logger = makeFakeLogger();
|
||||
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
||||
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s', 'general.flow'), 'm3/h');
|
||||
assert.equal(policy.resolve('bar', 'pressure', 'mbar', 'asset.pressure'), 'bar');
|
||||
assert.equal(policy.resolve('kW', 'power', 'W', 'asset.power'), 'kW');
|
||||
// No warnings on valid inputs.
|
||||
assert.equal(logger.calls.warn.length, 0);
|
||||
});
|
||||
|
||||
test('resolve falls back when given an invalid candidate, warns once', () => {
|
||||
const logger = makeFakeLogger();
|
||||
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
||||
|
||||
// Wrong measure family (mass unit declared as a flow unit).
|
||||
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
||||
// Same call again — the warn-once memo must suppress.
|
||||
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
||||
assert.equal(logger.calls.warn.length, 1);
|
||||
assert.match(logger.calls.warn[0], /Invalid general\.flow unit 'kg'/);
|
||||
|
||||
// A different invalid candidate logs a separate warning.
|
||||
assert.equal(policy.resolve('not-a-unit', 'pressure', 'Pa', 'asset.pressure'), 'Pa');
|
||||
assert.equal(logger.calls.warn.length, 2);
|
||||
});
|
||||
|
||||
test('resolve falls back to the default when candidate is empty/whitespace', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.equal(policy.resolve('', 'flow', 'm3/s'), 'm3/s');
|
||||
assert.equal(policy.resolve(' ', 'flow', 'm3/s'), 'm3/s');
|
||||
assert.equal(policy.resolve(undefined, 'flow', 'm3/s'), 'm3/s');
|
||||
});
|
||||
|
||||
test('resolve accepts type-name shorthand as well as convert-module measure', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
// 'flow' shorthand should map to volumeFlowRate, not be passed through raw.
|
||||
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s'), 'm3/h');
|
||||
assert.equal(policy.resolve('m3/h', 'volumeFlowRate', 'm3/s'), 'm3/h');
|
||||
});
|
||||
|
||||
test('convert is a no-op when from === to (still coerces to Number)', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.equal(policy.convert('5', 'm3/h', 'm3/h'), 5);
|
||||
assert.equal(typeof policy.convert(5, 'm3/h', 'm3/h'), 'number');
|
||||
// Missing units also no-op.
|
||||
assert.equal(policy.convert(7, '', 'm3/h'), 7);
|
||||
assert.equal(policy.convert(7, 'm3/h', null), 7);
|
||||
});
|
||||
|
||||
test('convert across compatible units returns the expected numeric', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
// 1 m3/s -> 3600 m3/h
|
||||
assert.equal(policy.convert(1, 'm3/s', 'm3/h'), 3600);
|
||||
// 1 bar -> 100000 Pa
|
||||
assert.equal(policy.convert(1, 'bar', 'Pa'), 100000);
|
||||
// 1 kW -> 1000 W
|
||||
assert.equal(policy.convert(1, 'kW', 'W'), 1000);
|
||||
});
|
||||
|
||||
test('convert throws when value is not finite', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
assert.throws(() => policy.convert('not-a-number', 'm3/h', 'm3/s'), /not finite/);
|
||||
assert.throws(() => policy.convert(NaN, 'm3/h', 'm3/s'), /not finite/);
|
||||
assert.throws(() => policy.convert(Infinity, 'm3/h', 'm3/s'), /not finite/);
|
||||
});
|
||||
|
||||
test('containerOptions returns the exact shape consumed by MeasurementContainer', () => {
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
const opts = policy.containerOptions();
|
||||
|
||||
assert.deepEqual(opts.defaultUnits, baseSpec.output);
|
||||
assert.deepEqual(opts.preferredUnits, baseSpec.output);
|
||||
assert.deepEqual(opts.canonicalUnits, baseSpec.canonical);
|
||||
assert.equal(opts.storeCanonical, true);
|
||||
assert.equal(opts.strictUnitValidation, true);
|
||||
assert.equal(opts.throwOnInvalidUnit, true);
|
||||
assert.deepEqual(opts.requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
||||
|
||||
// Mutating the returned bag must not leak back into the policy.
|
||||
opts.defaultUnits.flow = 'tampered';
|
||||
opts.requireUnitForTypes.push('volume');
|
||||
assert.equal(policy.output('flow'), 'm3/h');
|
||||
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
||||
});
|
||||
|
||||
test('containerOptions honours custom requireUnitForTypes from declare', () => {
|
||||
const policy = UnitPolicy.declare({
|
||||
...baseSpec,
|
||||
requireUnitForTypes: ['flow', 'pressure'],
|
||||
});
|
||||
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure']);
|
||||
});
|
||||
|
||||
test('containerOptions output works with a real MeasurementContainer', () => {
|
||||
const { MeasurementContainer } = require('../../src/measurements/index.js');
|
||||
const policy = UnitPolicy.declare(baseSpec);
|
||||
const mc = new MeasurementContainer(policy.containerOptions());
|
||||
// No throw on construction — proves the option bag is a valid input shape.
|
||||
assert.equal(mc.storeCanonical, true);
|
||||
assert.equal(mc.strictUnitValidation, true);
|
||||
assert.equal(mc.throwOnInvalidUnit, true);
|
||||
assert.equal(mc.canonicalUnits.flow, 'm3/s');
|
||||
assert.equal(mc.defaultUnits.flow, 'm3/h');
|
||||
});
|
||||
235
test/basic/commandRegistry.basic.test.js
Normal file
235
test/basic/commandRegistry.basic.test.js
Normal 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/);
|
||||
});
|
||||
50
test/basic/stats.basic.test.js
Normal file
50
test/basic/stats.basic.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { mean, stdDev, median, mad, lerp } = require('../../src/stats');
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
function near(a, b, eps = EPS) {
|
||||
assert.ok(Math.abs(a - b) <= eps, `expected ${a} ≈ ${b} (eps ${eps})`);
|
||||
}
|
||||
|
||||
test('mean: basic and empty', () => {
|
||||
assert.equal(mean([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(mean([]), 0);
|
||||
});
|
||||
|
||||
test('stdDev: zero-variance, classic sample, single-element, empty', () => {
|
||||
assert.equal(stdDev([1, 1, 1, 1]), 0);
|
||||
near(stdDev([1, 2, 3, 4, 5]), 1.5811388300841898);
|
||||
assert.equal(stdDev([5]), 0);
|
||||
assert.equal(stdDev([]), 0);
|
||||
});
|
||||
|
||||
test('median: odd, even, empty', () => {
|
||||
assert.equal(median([1, 2, 3, 4, 5]), 3);
|
||||
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(median([]), 0);
|
||||
});
|
||||
|
||||
test('mad: hand-checked sample and constant array', () => {
|
||||
// [1,1,2,2,4,6,9] -> median 2 -> |dev| [1,1,0,0,2,4,7] -> sorted
|
||||
// [0,0,1,1,2,4,7] -> mad = 1.
|
||||
assert.equal(mad([1, 1, 2, 2, 4, 6, 9]), 1);
|
||||
assert.equal(mad([5, 5, 5]), 0);
|
||||
assert.equal(mad([]), 0);
|
||||
});
|
||||
|
||||
test('lerp: in-range mapping and degenerate pass-through', () => {
|
||||
assert.equal(lerp(2, 0, 4, 0, 100), 50);
|
||||
assert.equal(lerp(2, 0, 0, 0, 100), 2);
|
||||
// iMin > iMax also degenerate (defensive against swapped bounds).
|
||||
assert.equal(lerp(2, 4, 0, 0, 100), 2);
|
||||
});
|
||||
|
||||
test('lerp: float arithmetic stays within epsilon', () => {
|
||||
near(lerp(0.1, 0, 1, 0, 10), 1);
|
||||
near(lerp(1 / 3, 0, 1, 0, 30), 10);
|
||||
});
|
||||
70
test/basic/statusBadge.basic.test.js
Normal file
70
test/basic/statusBadge.basic.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { statusBadge, MAX_TEXT } = require('../../src/nodered/statusBadge');
|
||||
|
||||
test('compose joins parts with " | " and uses default green/dot', () => {
|
||||
const badge = statusBadge.compose(['A', 'B']);
|
||||
assert.deepEqual(badge, { fill: 'green', shape: 'dot', text: 'A | B' });
|
||||
});
|
||||
|
||||
test('compose drops null/undefined/empty parts', () => {
|
||||
const badge = statusBadge.compose(['A', null, 'B', undefined, '']);
|
||||
assert.equal(badge.text, 'A | B');
|
||||
assert.equal(badge.fill, 'green');
|
||||
assert.equal(badge.shape, 'dot');
|
||||
});
|
||||
|
||||
test('compose with empty parts and override fill returns empty text', () => {
|
||||
const badge = statusBadge.compose([], { fill: 'yellow' });
|
||||
assert.equal(badge.text, '');
|
||||
assert.equal(badge.fill, 'yellow');
|
||||
assert.equal(badge.shape, 'dot');
|
||||
});
|
||||
|
||||
test('error returns red ring with ⚠ prefix', () => {
|
||||
const badge = statusBadge.error('boom');
|
||||
assert.deepEqual(badge, { fill: 'red', shape: 'ring', text: '⚠ boom' });
|
||||
});
|
||||
|
||||
test('idle returns blue dot with ⏸ prefix', () => {
|
||||
const badge = statusBadge.idle('waiting');
|
||||
assert.deepEqual(badge, { fill: 'blue', shape: 'dot', text: '⏸️ waiting' });
|
||||
});
|
||||
|
||||
test('byState returns the matching template', () => {
|
||||
const map = { off: { fill: 'red', shape: 'dot', text: 'OFF' } };
|
||||
const badge = statusBadge.byState(map, 'off');
|
||||
assert.deepEqual(badge, { fill: 'red', shape: 'dot', text: 'OFF' });
|
||||
});
|
||||
|
||||
test('byState returns grey "unknown state" badge when key is missing', () => {
|
||||
const badge = statusBadge.byState({}, 'unknown');
|
||||
assert.equal(badge.fill, 'grey');
|
||||
assert.equal(badge.shape, 'ring');
|
||||
assert.match(badge.text, /unknown state/);
|
||||
assert.match(badge.text, /unknown/);
|
||||
});
|
||||
|
||||
test('byState composes extra parts into the template text', () => {
|
||||
const map = { run: { fill: 'green', shape: 'dot', text: 'RUN' } };
|
||||
const badge = statusBadge.byState(map, 'run', { compose: ['flow=12.0', 'P=3kW'] });
|
||||
assert.equal(badge.text, 'RUN | flow=12.0 | P=3kW');
|
||||
});
|
||||
|
||||
test('text length is truncated to MAX_TEXT chars ending with …', () => {
|
||||
const longInput = 'x'.repeat(200);
|
||||
const badge = statusBadge.text(longInput);
|
||||
assert.equal(badge.text.length, MAX_TEXT);
|
||||
assert.equal(badge.text.endsWith('…'), true);
|
||||
});
|
||||
|
||||
test('text helper defaults to green/dot and never returns null text', () => {
|
||||
assert.equal(statusBadge.text(null).text, '');
|
||||
assert.equal(statusBadge.text(undefined).text, '');
|
||||
const badge = statusBadge.text('hi');
|
||||
assert.equal(badge.fill, 'green');
|
||||
assert.equal(badge.shape, 'dot');
|
||||
});
|
||||
189
test/basic/statusUpdater.basic.test.js
Normal file
189
test/basic/statusUpdater.basic.test.js
Normal 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);
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
const ChildRegistrationUtils = require('../src/helper/childRegistrationUtils');
|
||||
const { POSITIONS } = require('../src/constants/positions');
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a minimal mock parent (mainClass) that ChildRegistrationUtils expects. */
|
||||
function createMockParent(opts = {}) {
|
||||
return {
|
||||
child: {},
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
// optionally provide a registerChild callback so the utils can delegate
|
||||
registerChild: opts.registerChild || undefined,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a minimal mock child node with the given overrides. */
|
||||
function createMockChild(overrides = {}) {
|
||||
const defaults = {
|
||||
config: {
|
||||
general: {
|
||||
id: overrides.id || 'child-1',
|
||||
name: overrides.name || 'TestChild',
|
||||
},
|
||||
functionality: {
|
||||
softwareType: overrides.softwareType !== undefined ? overrides.softwareType : 'measurement',
|
||||
positionVsParent: overrides.position || POSITIONS.UPSTREAM,
|
||||
},
|
||||
asset: {
|
||||
category: overrides.category || 'sensor',
|
||||
type: overrides.assetType || 'pressure',
|
||||
},
|
||||
},
|
||||
measurements: overrides.measurements || null,
|
||||
};
|
||||
// allow caller to add extra top-level props
|
||||
return { ...defaults, ...(overrides.extra || {}) };
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ChildRegistrationUtils', () => {
|
||||
let parent;
|
||||
let utils;
|
||||
|
||||
beforeEach(() => {
|
||||
parent = createMockParent();
|
||||
utils = new ChildRegistrationUtils(parent);
|
||||
});
|
||||
|
||||
// ── Construction ─────────────────────────────────────────────────────────
|
||||
describe('constructor', () => {
|
||||
it('should store a reference to the mainClass', () => {
|
||||
expect(utils.mainClass).toBe(parent);
|
||||
});
|
||||
|
||||
it('should initialise with an empty registeredChildren map', () => {
|
||||
expect(utils.registeredChildren.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should use the parent logger', () => {
|
||||
expect(utils.logger).toBe(parent.logger);
|
||||
});
|
||||
});
|
||||
|
||||
// ── registerChild ────────────────────────────────────────────────────────
|
||||
describe('registerChild()', () => {
|
||||
it('should register a child and store it in the internal map', async () => {
|
||||
const child = createMockChild();
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(utils.registeredChildren.size).toBe(1);
|
||||
expect(utils.registeredChildren.has('child-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should store softwareType, position and timestamp in the registry entry', async () => {
|
||||
const child = createMockChild({ softwareType: 'machine' });
|
||||
const before = Date.now();
|
||||
await utils.registerChild(child, POSITIONS.DOWNSTREAM);
|
||||
const after = Date.now();
|
||||
|
||||
const entry = utils.registeredChildren.get('child-1');
|
||||
expect(entry.softwareType).toBe('machine');
|
||||
expect(entry.position).toBe(POSITIONS.DOWNSTREAM);
|
||||
expect(entry.registeredAt).toBeGreaterThanOrEqual(before);
|
||||
expect(entry.registeredAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('should store the child in mainClass.child[softwareType][category]', async () => {
|
||||
const child = createMockChild({ softwareType: 'measurement', category: 'sensor' });
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(parent.child.measurement).toBeDefined();
|
||||
expect(parent.child.measurement.sensor).toBeInstanceOf(Array);
|
||||
expect(parent.child.measurement.sensor).toContain(child);
|
||||
});
|
||||
|
||||
it('should set the parent reference on the child', async () => {
|
||||
const child = createMockChild();
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(child.parent).toEqual([parent]);
|
||||
});
|
||||
|
||||
it('should set positionVsParent on the child', async () => {
|
||||
const child = createMockChild();
|
||||
await utils.registerChild(child, POSITIONS.DOWNSTREAM);
|
||||
|
||||
expect(child.positionVsParent).toBe(POSITIONS.DOWNSTREAM);
|
||||
});
|
||||
|
||||
it('should lowercase the softwareType before storing', async () => {
|
||||
const child = createMockChild({ softwareType: 'Measurement' });
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
const entry = utils.registeredChildren.get('child-1');
|
||||
expect(entry.softwareType).toBe('measurement');
|
||||
expect(parent.child.measurement).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delegate to mainClass.registerChild when it is a function', async () => {
|
||||
const registerSpy = jest.fn();
|
||||
parent.registerChild = registerSpy;
|
||||
const child = createMockChild({ softwareType: 'measurement' });
|
||||
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(registerSpy).toHaveBeenCalledWith(child, 'measurement');
|
||||
});
|
||||
|
||||
it('should NOT throw when mainClass has no registerChild method', async () => {
|
||||
delete parent.registerChild;
|
||||
const child = createMockChild();
|
||||
|
||||
await expect(utils.registerChild(child, POSITIONS.UPSTREAM)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should log a debug message on registration', async () => {
|
||||
const child = createMockChild({ name: 'Pump1', id: 'p1' });
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(parent.logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Registering child: Pump1')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty softwareType gracefully', async () => {
|
||||
const child = createMockChild({ softwareType: '' });
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
const entry = utils.registeredChildren.get('child-1');
|
||||
expect(entry.softwareType).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple children ────────────────────────────────────────────────────
|
||||
describe('multiple children registration', () => {
|
||||
it('should register multiple children of the same softwareType', async () => {
|
||||
const c1 = createMockChild({ id: 'c1', name: 'Sensor1', softwareType: 'measurement' });
|
||||
const c2 = createMockChild({ id: 'c2', name: 'Sensor2', softwareType: 'measurement' });
|
||||
|
||||
await utils.registerChild(c1, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(c2, POSITIONS.DOWNSTREAM);
|
||||
|
||||
expect(utils.registeredChildren.size).toBe(2);
|
||||
expect(parent.child.measurement.sensor).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should register children of different softwareTypes', async () => {
|
||||
const sensor = createMockChild({ id: 's1', softwareType: 'measurement' });
|
||||
const machine = createMockChild({ id: 'm1', softwareType: 'machine', category: 'pump' });
|
||||
|
||||
await utils.registerChild(sensor, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(machine, POSITIONS.AT_EQUIPMENT);
|
||||
|
||||
expect(parent.child.measurement).toBeDefined();
|
||||
expect(parent.child.machine).toBeDefined();
|
||||
expect(parent.child.machine.pump).toContain(machine);
|
||||
});
|
||||
|
||||
it('should register children of different categories under the same softwareType', async () => {
|
||||
const sensor = createMockChild({ id: 's1', softwareType: 'measurement', category: 'sensor' });
|
||||
const analyser = createMockChild({ id: 'a1', softwareType: 'measurement', category: 'analyser' });
|
||||
|
||||
await utils.registerChild(sensor, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(analyser, POSITIONS.DOWNSTREAM);
|
||||
|
||||
expect(parent.child.measurement.sensor).toHaveLength(1);
|
||||
expect(parent.child.measurement.analyser).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should support multiple parents on a child (array append)', async () => {
|
||||
const parent2 = createMockParent();
|
||||
const utils2 = new ChildRegistrationUtils(parent2);
|
||||
const child = createMockChild();
|
||||
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
await utils2.registerChild(child, POSITIONS.DOWNSTREAM);
|
||||
|
||||
expect(child.parent).toEqual([parent, parent2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate registration ───────────────────────────────────────────────
|
||||
describe('duplicate registration', () => {
|
||||
it('should overwrite the registry entry when the same child id is registered twice', async () => {
|
||||
const child = createMockChild({ id: 'dup-1' });
|
||||
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(child, POSITIONS.DOWNSTREAM);
|
||||
|
||||
// Map.set overwrites, so still size 1
|
||||
expect(utils.registeredChildren.size).toBe(1);
|
||||
const entry = utils.registeredChildren.get('dup-1');
|
||||
expect(entry.position).toBe(POSITIONS.DOWNSTREAM);
|
||||
});
|
||||
|
||||
it('should push the child into the category array again on duplicate registration', async () => {
|
||||
const child = createMockChild({ id: 'dup-1' });
|
||||
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
// _storeChild does a push each time
|
||||
expect(parent.child.measurement.sensor).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Measurement context setup ────────────────────────────────────────────
|
||||
describe('measurement context on child', () => {
|
||||
it('should call setChildId, setChildName, setParentRef when child has measurements', async () => {
|
||||
const measurements = {
|
||||
setChildId: jest.fn(),
|
||||
setChildName: jest.fn(),
|
||||
setParentRef: jest.fn(),
|
||||
};
|
||||
const child = createMockChild({ id: 'mc-1', name: 'Sensor1', measurements });
|
||||
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(measurements.setChildId).toHaveBeenCalledWith('mc-1');
|
||||
expect(measurements.setChildName).toHaveBeenCalledWith('Sensor1');
|
||||
expect(measurements.setParentRef).toHaveBeenCalledWith(parent);
|
||||
});
|
||||
|
||||
it('should skip measurement setup when child has no measurements object', async () => {
|
||||
const child = createMockChild({ measurements: null });
|
||||
|
||||
// Should not throw
|
||||
await expect(utils.registerChild(child, POSITIONS.UPSTREAM)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChildrenOfType ────────────────────────────────────────────────────
|
||||
describe('getChildrenOfType()', () => {
|
||||
beforeEach(async () => {
|
||||
const s1 = createMockChild({ id: 's1', softwareType: 'measurement', category: 'sensor' });
|
||||
const s2 = createMockChild({ id: 's2', softwareType: 'measurement', category: 'sensor' });
|
||||
const a1 = createMockChild({ id: 'a1', softwareType: 'measurement', category: 'analyser' });
|
||||
const m1 = createMockChild({ id: 'm1', softwareType: 'machine', category: 'pump' });
|
||||
|
||||
await utils.registerChild(s1, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(s2, POSITIONS.DOWNSTREAM);
|
||||
await utils.registerChild(a1, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(m1, POSITIONS.AT_EQUIPMENT);
|
||||
});
|
||||
|
||||
it('should return all children of a given softwareType', () => {
|
||||
const measurements = utils.getChildrenOfType('measurement');
|
||||
expect(measurements).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return children filtered by category', () => {
|
||||
const sensors = utils.getChildrenOfType('measurement', 'sensor');
|
||||
expect(sensors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown softwareType', () => {
|
||||
expect(utils.getChildrenOfType('nonexistent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown category', () => {
|
||||
expect(utils.getChildrenOfType('measurement', 'nonexistent')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChildById ─────────────────────────────────────────────────────────
|
||||
describe('getChildById()', () => {
|
||||
it('should return the child by its id', async () => {
|
||||
const child = createMockChild({ id: 'find-me' });
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(utils.getChildById('find-me')).toBe(child);
|
||||
});
|
||||
|
||||
it('should return null for unknown id', () => {
|
||||
expect(utils.getChildById('does-not-exist')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAllChildren ───────────────────────────────────────────────────────
|
||||
describe('getAllChildren()', () => {
|
||||
it('should return an empty array when no children registered', () => {
|
||||
expect(utils.getAllChildren()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all registered child objects', async () => {
|
||||
const c1 = createMockChild({ id: 'c1' });
|
||||
const c2 = createMockChild({ id: 'c2' });
|
||||
await utils.registerChild(c1, POSITIONS.UPSTREAM);
|
||||
await utils.registerChild(c2, POSITIONS.DOWNSTREAM);
|
||||
|
||||
const all = utils.getAllChildren();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all).toContain(c1);
|
||||
expect(all).toContain(c2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── logChildStructure ───────────────────────────────────────────────────
|
||||
describe('logChildStructure()', () => {
|
||||
it('should log the child structure via debug', async () => {
|
||||
const child = createMockChild({ id: 'log-1', name: 'LogChild' });
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
utils.logChildStructure();
|
||||
|
||||
expect(parent.logger.debug).toHaveBeenCalledWith(
|
||||
'Current child structure:',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── _storeChild (internal) ──────────────────────────────────────────────
|
||||
describe('_storeChild() internal behaviour', () => {
|
||||
it('should create the child object on parent if it does not exist', async () => {
|
||||
delete parent.child;
|
||||
const child = createMockChild();
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(parent.child).toBeDefined();
|
||||
expect(parent.child.measurement.sensor).toContain(child);
|
||||
});
|
||||
|
||||
it('should use "sensor" as default category when asset.category is absent', async () => {
|
||||
const child = createMockChild();
|
||||
// remove asset.category to trigger default
|
||||
delete child.config.asset.category;
|
||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
||||
|
||||
expect(parent.child.measurement.sensor).toContain(child);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
const path = require('path');
|
||||
const ConfigManager = require('../src/configs/index');
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
const configDir = path.resolve(__dirname, '../src/configs');
|
||||
let cm;
|
||||
|
||||
beforeEach(() => {
|
||||
cm = new ConfigManager(configDir);
|
||||
});
|
||||
|
||||
// ── getConfig() ──────────────────────────────────────────────────────
|
||||
describe('getConfig()', () => {
|
||||
it('should load and parse a known JSON config file', () => {
|
||||
const config = cm.getConfig('baseConfig');
|
||||
expect(config).toBeDefined();
|
||||
expect(typeof config).toBe('object');
|
||||
});
|
||||
|
||||
it('should return the same content on successive calls', () => {
|
||||
const a = cm.getConfig('baseConfig');
|
||||
const b = cm.getConfig('baseConfig');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('should throw when the config file does not exist', () => {
|
||||
expect(() => cm.getConfig('nonExistentConfig_xyz'))
|
||||
.toThrow(/Failed to load config/);
|
||||
});
|
||||
|
||||
it('should throw a descriptive message including the config name', () => {
|
||||
expect(() => cm.getConfig('missing'))
|
||||
.toThrow("Failed to load config 'missing'");
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasConfig() ──────────────────────────────────────────────────────
|
||||
describe('hasConfig()', () => {
|
||||
it('should return true for a config that exists', () => {
|
||||
expect(cm.hasConfig('baseConfig')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a config that does not exist', () => {
|
||||
expect(cm.hasConfig('doesNotExist_abc')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAvailableConfigs() ────────────────────────────────────────────
|
||||
describe('getAvailableConfigs()', () => {
|
||||
it('should return an array of strings', () => {
|
||||
const configs = cm.getAvailableConfigs();
|
||||
expect(Array.isArray(configs)).toBe(true);
|
||||
configs.forEach(name => expect(typeof name).toBe('string'));
|
||||
});
|
||||
|
||||
it('should include known config names without .json extension', () => {
|
||||
const configs = cm.getAvailableConfigs();
|
||||
expect(configs).toContain('baseConfig');
|
||||
expect(configs).toContain('diffuser');
|
||||
expect(configs).toContain('measurement');
|
||||
});
|
||||
|
||||
it('should not include .json extension in returned names', () => {
|
||||
const configs = cm.getAvailableConfigs();
|
||||
configs.forEach(name => {
|
||||
expect(name).not.toMatch(/\.json$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when pointed at a non-existent directory', () => {
|
||||
const bad = new ConfigManager('/tmp/nonexistent_dir_xyz_123');
|
||||
expect(() => bad.getAvailableConfigs()).toThrow(/Failed to read config directory/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildConfig() ────────────────────────────────────────────────────
|
||||
describe('buildConfig()', () => {
|
||||
it('should return an object with general and functionality sections', () => {
|
||||
const uiConfig = { name: 'TestNode', unit: 'bar', enableLog: true, logLevel: 'debug' };
|
||||
const result = cm.buildConfig('measurement', uiConfig, 'node-id-1');
|
||||
expect(result).toHaveProperty('general');
|
||||
expect(result).toHaveProperty('functionality');
|
||||
expect(result).toHaveProperty('output');
|
||||
});
|
||||
|
||||
it('should populate general.name from uiConfig.name', () => {
|
||||
const uiConfig = { name: 'MySensor' };
|
||||
const result = cm.buildConfig('measurement', uiConfig, 'id-1');
|
||||
expect(result.general.name).toBe('MySensor');
|
||||
});
|
||||
|
||||
it('should default general.name to nodeName when uiConfig.name is empty', () => {
|
||||
const result = cm.buildConfig('measurement', {}, 'id-1');
|
||||
expect(result.general.name).toBe('measurement');
|
||||
});
|
||||
|
||||
it('should set general.id from the nodeId argument', () => {
|
||||
const result = cm.buildConfig('valve', {}, 'node-42');
|
||||
expect(result.general.id).toBe('node-42');
|
||||
});
|
||||
|
||||
it('should default unit to unitless', () => {
|
||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
||||
expect(result.general.unit).toBe('unitless');
|
||||
});
|
||||
|
||||
it('should default logging.enabled to true when enableLog is undefined', () => {
|
||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
||||
expect(result.general.logging.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect enableLog = false', () => {
|
||||
const result = cm.buildConfig('valve', { enableLog: false }, 'id-1');
|
||||
expect(result.general.logging.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default logLevel to info', () => {
|
||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
||||
expect(result.general.logging.logLevel).toBe('info');
|
||||
});
|
||||
|
||||
it('should set functionality.softwareType to lowercase nodeName', () => {
|
||||
const result = cm.buildConfig('Valve', {}, 'id-1');
|
||||
expect(result.functionality.softwareType).toBe('valve');
|
||||
});
|
||||
|
||||
it('should default positionVsParent to atEquipment', () => {
|
||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
||||
expect(result.functionality.positionVsParent).toBe('atEquipment');
|
||||
});
|
||||
|
||||
it('should set distance when hasDistance is true', () => {
|
||||
const result = cm.buildConfig('valve', { hasDistance: true, distance: 5.5 }, 'id-1');
|
||||
expect(result.functionality.distance).toBe(5.5);
|
||||
});
|
||||
|
||||
it('should set distance to undefined when hasDistance is false', () => {
|
||||
const result = cm.buildConfig('valve', { hasDistance: false, distance: 5.5 }, 'id-1');
|
||||
expect(result.functionality.distance).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── asset section ──────────────────────────────────────────────────
|
||||
it('should not include asset section when no asset fields provided', () => {
|
||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
||||
expect(result.asset).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include asset section when supplier is provided', () => {
|
||||
const result = cm.buildConfig('valve', { supplier: 'Siemens' }, 'id-1');
|
||||
expect(result.asset).toBeDefined();
|
||||
expect(result.asset.supplier).toBe('Siemens');
|
||||
});
|
||||
|
||||
it('should populate asset defaults for missing optional fields', () => {
|
||||
const result = cm.buildConfig('valve', { supplier: 'ABB' }, 'id-1');
|
||||
expect(result.asset.category).toBe('sensor');
|
||||
expect(result.asset.type).toBe('Unknown');
|
||||
expect(result.asset.model).toBe('Unknown');
|
||||
});
|
||||
|
||||
// ── domainConfig merge ─────────────────────────────────────────────
|
||||
it('should merge domainConfig sections into the result', () => {
|
||||
const domain = { scaling: { enabled: true, factor: 2 } };
|
||||
const result = cm.buildConfig('measurement', {}, 'id-1', domain);
|
||||
expect(result.scaling).toEqual({ enabled: true, factor: 2 });
|
||||
});
|
||||
|
||||
it('should handle empty domainConfig gracefully', () => {
|
||||
const result = cm.buildConfig('measurement', {}, 'id-1', {});
|
||||
expect(result).toHaveProperty('general');
|
||||
expect(result).toHaveProperty('functionality');
|
||||
});
|
||||
|
||||
it('should default output formats to process and influxdb', () => {
|
||||
const result = cm.buildConfig('measurement', {}, 'id-1');
|
||||
expect(result.output).toEqual({
|
||||
process: 'process',
|
||||
dbase: 'influxdb',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow output format overrides from ui config', () => {
|
||||
const result = cm.buildConfig('measurement', {
|
||||
processOutputFormat: 'json',
|
||||
dbaseOutputFormat: 'csv',
|
||||
}, 'id-1');
|
||||
expect(result.output).toEqual({
|
||||
process: 'json',
|
||||
dbase: 'csv',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── createEndpoint() ─────────────────────────────────────────────────
|
||||
describe('createEndpoint()', () => {
|
||||
it('should return a JavaScript string containing the node name', () => {
|
||||
const script = cm.createEndpoint('baseConfig');
|
||||
expect(typeof script).toBe('string');
|
||||
expect(script).toContain('baseConfig');
|
||||
expect(script).toContain('window.EVOLV');
|
||||
});
|
||||
|
||||
it('should throw for a non-existent config', () => {
|
||||
expect(() => cm.createEndpoint('doesNotExist_xyz'))
|
||||
.toThrow(/Failed to create endpoint/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getBaseConfig() ──────────────────────────────────────────────────
|
||||
describe('getBaseConfig()', () => {
|
||||
it('should load the baseConfig.json file', () => {
|
||||
const base = cm.getBaseConfig();
|
||||
expect(base).toBeDefined();
|
||||
expect(typeof base).toBe('object');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,336 +0,0 @@
|
||||
const MeasurementContainer = require('../src/measurements/MeasurementContainer');
|
||||
|
||||
describe('MeasurementContainer', () => {
|
||||
let mc;
|
||||
|
||||
beforeEach(() => {
|
||||
mc = new MeasurementContainer({ windowSize: 5, autoConvert: false });
|
||||
});
|
||||
|
||||
// ── Construction ─────────────────────────────────────────────────────
|
||||
describe('constructor', () => {
|
||||
it('should initialise with default windowSize when none provided', () => {
|
||||
const m = new MeasurementContainer();
|
||||
expect(m.windowSize).toBe(10);
|
||||
});
|
||||
|
||||
it('should accept a custom windowSize', () => {
|
||||
expect(mc.windowSize).toBe(5);
|
||||
});
|
||||
|
||||
it('should start with an empty measurements map', () => {
|
||||
expect(mc.measurements).toEqual({});
|
||||
});
|
||||
|
||||
it('should populate default units', () => {
|
||||
expect(mc.defaultUnits.pressure).toBe('mbar');
|
||||
expect(mc.defaultUnits.flow).toBe('m3/h');
|
||||
});
|
||||
|
||||
it('should allow overriding default units', () => {
|
||||
const m = new MeasurementContainer({ defaultUnits: { pressure: 'Pa' } });
|
||||
expect(m.defaultUnits.pressure).toBe('Pa');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chainable setters ───────────────────────────────────────────────
|
||||
describe('chaining API — type / variant / position', () => {
|
||||
it('should set type and return this for chaining', () => {
|
||||
const ret = mc.type('pressure');
|
||||
expect(ret).toBe(mc);
|
||||
expect(mc._currentType).toBe('pressure');
|
||||
});
|
||||
|
||||
it('should reset variant and position when type is called', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
mc.type('flow');
|
||||
expect(mc._currentVariant).toBeNull();
|
||||
expect(mc._currentPosition).toBeNull();
|
||||
});
|
||||
|
||||
it('should set variant and return this', () => {
|
||||
mc.type('pressure');
|
||||
const ret = mc.variant('measured');
|
||||
expect(ret).toBe(mc);
|
||||
expect(mc._currentVariant).toBe('measured');
|
||||
});
|
||||
|
||||
it('should throw if variant is called without type', () => {
|
||||
expect(() => mc.variant('measured')).toThrow(/Type must be specified/);
|
||||
});
|
||||
|
||||
it('should set position (lowercased) and return this', () => {
|
||||
mc.type('pressure').variant('measured');
|
||||
const ret = mc.position('Upstream');
|
||||
expect(ret).toBe(mc);
|
||||
expect(mc._currentPosition).toBe('upstream');
|
||||
});
|
||||
|
||||
it('should throw if position is called without variant', () => {
|
||||
mc.type('pressure');
|
||||
expect(() => mc.position('upstream')).toThrow(/Variant must be specified/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Storing and retrieving values ───────────────────────────────────
|
||||
describe('value() and retrieval methods', () => {
|
||||
beforeEach(() => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
});
|
||||
|
||||
it('should store a value and retrieve it with getCurrentValue()', () => {
|
||||
mc.value(42, 1000);
|
||||
expect(mc.getCurrentValue()).toBe(42);
|
||||
});
|
||||
|
||||
it('should return this for chaining from value()', () => {
|
||||
const ret = mc.value(1, 1000);
|
||||
expect(ret).toBe(mc);
|
||||
});
|
||||
|
||||
it('should store multiple values and keep the latest', () => {
|
||||
mc.value(10, 1).value(20, 2).value(30, 3);
|
||||
expect(mc.getCurrentValue()).toBe(30);
|
||||
});
|
||||
|
||||
it('should respect the windowSize (rolling window)', () => {
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
mc.value(i, i);
|
||||
}
|
||||
const all = mc.getAllValues();
|
||||
// windowSize is 5, so only the last 5 values should remain
|
||||
expect(all.values.length).toBe(5);
|
||||
expect(all.values).toEqual([4, 5, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it('should compute getAverage() correctly', () => {
|
||||
mc.value(10, 1).value(20, 2).value(30, 3);
|
||||
expect(mc.getAverage()).toBe(20);
|
||||
});
|
||||
|
||||
it('should compute getMin()', () => {
|
||||
mc.value(10, 1).value(5, 2).value(20, 3);
|
||||
expect(mc.getMin()).toBe(5);
|
||||
});
|
||||
|
||||
it('should compute getMax()', () => {
|
||||
mc.value(10, 1).value(5, 2).value(20, 3);
|
||||
expect(mc.getMax()).toBe(20);
|
||||
});
|
||||
|
||||
it('should return null for getCurrentValue() when no values exist', () => {
|
||||
expect(mc.getCurrentValue()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for getAverage() when no values exist', () => {
|
||||
expect(mc.getAverage()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for getMin() when no values exist', () => {
|
||||
expect(mc.getMin()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for getMax() when no values exist', () => {
|
||||
expect(mc.getMax()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAllValues() ──────────────────────────────────────────────────
|
||||
describe('getAllValues()', () => {
|
||||
it('should return values, timestamps, and unit', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
mc.unit('bar');
|
||||
mc.value(10, 100).value(20, 200);
|
||||
const all = mc.getAllValues();
|
||||
expect(all.values).toEqual([10, 20]);
|
||||
expect(all.timestamps).toEqual([100, 200]);
|
||||
expect(all.unit).toBe('bar');
|
||||
});
|
||||
|
||||
it('should return null when chain is incomplete', () => {
|
||||
mc.type('pressure');
|
||||
expect(mc.getAllValues()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── unit() ──────────────────────────────────────────────────────────
|
||||
describe('unit()', () => {
|
||||
it('should set unit on the underlying measurement', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
mc.unit('bar');
|
||||
const measurement = mc.get();
|
||||
expect(measurement.unit).toBe('bar');
|
||||
});
|
||||
});
|
||||
|
||||
// ── get() ───────────────────────────────────────────────────────────
|
||||
describe('get()', () => {
|
||||
it('should return the Measurement instance for a complete chain', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
mc.value(1, 1);
|
||||
const m = mc.get();
|
||||
expect(m).toBeDefined();
|
||||
expect(m.type).toBe('pressure');
|
||||
expect(m.variant).toBe('measured');
|
||||
expect(m.position).toBe('upstream');
|
||||
});
|
||||
|
||||
it('should return null when chain is incomplete', () => {
|
||||
mc.type('pressure');
|
||||
expect(mc.get()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── exists() ────────────────────────────────────────────────────────
|
||||
describe('exists()', () => {
|
||||
it('should return false for a non-existent measurement', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
expect(mc.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after a value has been stored', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
||||
expect(mc.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support requireValues option', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
// Force creation of measurement without values
|
||||
mc.get();
|
||||
expect(mc.exists({ requireValues: false })).toBe(true);
|
||||
expect(mc.exists({ requireValues: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should support explicit type/variant/position overrides', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
||||
// Reset chain, then query by explicit keys
|
||||
mc.type('flow');
|
||||
expect(mc.exists({ type: 'pressure', variant: 'measured', position: 'upstream' })).toBe(true);
|
||||
expect(mc.exists({ type: 'flow', variant: 'measured', position: 'upstream' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when type is not set and not provided', () => {
|
||||
const fresh = new MeasurementContainer({ autoConvert: false });
|
||||
expect(fresh.exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLaggedValue() / getLaggedSample() ─────────────────────────────
|
||||
describe('getLaggedValue() and getLaggedSample()', () => {
|
||||
beforeEach(() => {
|
||||
mc.type('pressure').variant('measured').position('upstream');
|
||||
mc.value(10, 100).value(20, 200).value(30, 300);
|
||||
});
|
||||
|
||||
it('should return the value at lag=1 (previous value)', () => {
|
||||
expect(mc.getLaggedValue(1)).toBe(20);
|
||||
});
|
||||
|
||||
it('should return null when lag exceeds stored values', () => {
|
||||
expect(mc.getLaggedValue(10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return a sample object from getLaggedSample()', () => {
|
||||
const sample = mc.getLaggedSample(0);
|
||||
expect(sample).toHaveProperty('value', 30);
|
||||
expect(sample).toHaveProperty('timestamp', 300);
|
||||
});
|
||||
|
||||
it('should return null from getLaggedSample when not enough values', () => {
|
||||
expect(mc.getLaggedSample(10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Listing helpers ─────────────────────────────────────────────────
|
||||
describe('getTypes() / getVariants() / getPositions()', () => {
|
||||
beforeEach(() => {
|
||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
||||
mc.type('flow').variant('predicted').position('downstream').value(2, 2);
|
||||
});
|
||||
|
||||
it('should list all stored types', () => {
|
||||
const types = mc.getTypes();
|
||||
expect(types).toContain('pressure');
|
||||
expect(types).toContain('flow');
|
||||
});
|
||||
|
||||
it('should list variants for a given type', () => {
|
||||
mc.type('pressure');
|
||||
expect(mc.getVariants()).toContain('measured');
|
||||
});
|
||||
|
||||
it('should return empty array for type with no variants', () => {
|
||||
mc.type('temperature');
|
||||
expect(mc.getVariants()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw if getVariants() called without type', () => {
|
||||
const fresh = new MeasurementContainer({ autoConvert: false });
|
||||
expect(() => fresh.getVariants()).toThrow(/Type must be specified/);
|
||||
});
|
||||
|
||||
it('should list positions for type+variant', () => {
|
||||
mc.type('pressure').variant('measured');
|
||||
expect(mc.getPositions()).toContain('upstream');
|
||||
});
|
||||
|
||||
it('should throw if getPositions() called without type and variant', () => {
|
||||
const fresh = new MeasurementContainer({ autoConvert: false });
|
||||
expect(() => fresh.getPositions()).toThrow(/Type and variant must be specified/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── clear() ─────────────────────────────────────────────────────────
|
||||
describe('clear()', () => {
|
||||
it('should reset all measurements and chain state', () => {
|
||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
||||
mc.clear();
|
||||
expect(mc.measurements).toEqual({});
|
||||
expect(mc._currentType).toBeNull();
|
||||
expect(mc._currentVariant).toBeNull();
|
||||
expect(mc._currentPosition).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Child context setters ───────────────────────────────────────────
|
||||
describe('child context', () => {
|
||||
it('should set childId and return this', () => {
|
||||
expect(mc.setChildId('c1')).toBe(mc);
|
||||
expect(mc.childId).toBe('c1');
|
||||
});
|
||||
|
||||
it('should set childName and return this', () => {
|
||||
expect(mc.setChildName('pump1')).toBe(mc);
|
||||
expect(mc.childName).toBe('pump1');
|
||||
});
|
||||
|
||||
it('should set parentRef and return this', () => {
|
||||
const parent = { id: 'p1' };
|
||||
expect(mc.setParentRef(parent)).toBe(mc);
|
||||
expect(mc.parentRef).toBe(parent);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Event emission ──────────────────────────────────────────────────
|
||||
describe('event emission', () => {
|
||||
it('should emit an event when a value is set', (done) => {
|
||||
mc.emitter.on('pressure.measured.upstream', (data) => {
|
||||
expect(data.value).toBe(42);
|
||||
expect(data.type).toBe('pressure');
|
||||
expect(data.variant).toBe('measured');
|
||||
expect(data.position).toBe('upstream');
|
||||
done();
|
||||
});
|
||||
mc.type('pressure').variant('measured').position('upstream').value(42, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setPreferredUnit ────────────────────────────────────────────────
|
||||
describe('setPreferredUnit()', () => {
|
||||
it('should store preferred unit and return this', () => {
|
||||
const ret = mc.setPreferredUnit('pressure', 'Pa');
|
||||
expect(ret).toBe(mc);
|
||||
expect(mc.preferredUnits.pressure).toBe('Pa');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
const OutputUtils = require('../src/helper/outputUtils');
|
||||
|
||||
describe('OutputUtils', () => {
|
||||
let outputUtils;
|
||||
let config;
|
||||
|
||||
beforeEach(() => {
|
||||
outputUtils = new OutputUtils();
|
||||
config = {
|
||||
general: {
|
||||
name: 'Pump-1',
|
||||
id: 'node-1',
|
||||
unit: 'm3/h',
|
||||
},
|
||||
functionality: {
|
||||
softwareType: 'pump',
|
||||
role: 'test-role',
|
||||
},
|
||||
asset: {
|
||||
supplier: 'EVOLV',
|
||||
type: 'sensor',
|
||||
},
|
||||
output: {
|
||||
process: 'process',
|
||||
dbase: 'influxdb',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('keeps legacy process output by default', () => {
|
||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'process');
|
||||
expect(msg).toEqual({
|
||||
topic: 'Pump-1',
|
||||
payload: { flow: 12.5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy influxdb output by default', () => {
|
||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'influxdb');
|
||||
expect(msg.topic).toBe('Pump-1');
|
||||
expect(msg.payload).toEqual(expect.objectContaining({
|
||||
measurement: 'Pump-1',
|
||||
fields: { flow: 12.5 },
|
||||
tags: expect.objectContaining({
|
||||
id: 'node-1',
|
||||
name: 'Pump-1',
|
||||
softwareType: 'pump',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('supports config-driven json formatting on the process channel', () => {
|
||||
config.output.process = 'json';
|
||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'process');
|
||||
expect(msg.topic).toBe('Pump-1');
|
||||
expect(typeof msg.payload).toBe('string');
|
||||
expect(msg.payload).toContain('"measurement":"Pump-1"');
|
||||
expect(msg.payload).toContain('"flow":12.5');
|
||||
});
|
||||
|
||||
it('supports config-driven csv formatting on the database channel', () => {
|
||||
config.output.dbase = 'csv';
|
||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'influxdb');
|
||||
expect(msg.topic).toBe('Pump-1');
|
||||
expect(typeof msg.payload).toBe('string');
|
||||
expect(msg.payload).toContain('Pump-1');
|
||||
expect(msg.payload).toContain('flow=12.5');
|
||||
});
|
||||
});
|
||||
@@ -1,554 +0,0 @@
|
||||
const ValidationUtils = require('../src/helper/validationUtils');
|
||||
const { validateNumber, validateInteger, validateBoolean, validateString, validateEnum } = require('../src/helper/validators/typeValidators');
|
||||
const { validateArray, validateSet, validateObject } = require('../src/helper/validators/collectionValidators');
|
||||
const { validateCurve, validateMachineCurve, isSorted, isUnique, areNumbers } = require('../src/helper/validators/curveValidator');
|
||||
|
||||
// Shared mock logger used across tests
|
||||
function mockLogger() {
|
||||
return { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Type validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
describe('typeValidators', () => {
|
||||
let logger;
|
||||
beforeEach(() => { logger = mockLogger(); });
|
||||
|
||||
// ── validateNumber ──────────────────────────────────────────────────
|
||||
describe('validateNumber()', () => {
|
||||
it('should accept a valid number', () => {
|
||||
expect(validateNumber(42, {}, { default: 0 }, 'n', 'k', logger)).toBe(42);
|
||||
});
|
||||
|
||||
it('should parse a string to a number', () => {
|
||||
expect(validateNumber('3.14', {}, { default: 0 }, 'n', 'k', logger)).toBe(3.14);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return default when below min', () => {
|
||||
expect(validateNumber(1, { min: 5 }, { default: 5 }, 'n', 'k', logger)).toBe(5);
|
||||
});
|
||||
|
||||
it('should return default when above max', () => {
|
||||
expect(validateNumber(100, { max: 50 }, { default: 50 }, 'n', 'k', logger)).toBe(50);
|
||||
});
|
||||
|
||||
it('should accept boundary value equal to min', () => {
|
||||
expect(validateNumber(5, { min: 5 }, { default: 0 }, 'n', 'k', logger)).toBe(5);
|
||||
});
|
||||
|
||||
it('should accept boundary value equal to max', () => {
|
||||
expect(validateNumber(50, { max: 50 }, { default: 0 }, 'n', 'k', logger)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateInteger ─────────────────────────────────────────────────
|
||||
describe('validateInteger()', () => {
|
||||
it('should accept a valid integer', () => {
|
||||
expect(validateInteger(7, {}, { default: 0 }, 'n', 'k', logger)).toBe(7);
|
||||
});
|
||||
|
||||
it('should parse a string to an integer', () => {
|
||||
expect(validateInteger('10', {}, { default: 0 }, 'n', 'k', logger)).toBe(10);
|
||||
});
|
||||
|
||||
it('should return default for a non-parseable value', () => {
|
||||
expect(validateInteger('abc', {}, { default: -1 }, 'n', 'k', logger)).toBe(-1);
|
||||
});
|
||||
|
||||
it('should return default when below min', () => {
|
||||
expect(validateInteger(2, { min: 5 }, { default: 5 }, 'n', 'k', logger)).toBe(5);
|
||||
});
|
||||
|
||||
it('should return default when above max', () => {
|
||||
expect(validateInteger(100, { max: 50 }, { default: 50 }, 'n', 'k', logger)).toBe(50);
|
||||
});
|
||||
|
||||
it('should parse a float string and truncate to integer', () => {
|
||||
expect(validateInteger('7.9', {}, { default: 0 }, 'n', 'k', logger)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateBoolean ─────────────────────────────────────────────────
|
||||
describe('validateBoolean()', () => {
|
||||
it('should pass through a true boolean', () => {
|
||||
expect(validateBoolean(true, 'n', 'k', logger)).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass through a false boolean', () => {
|
||||
expect(validateBoolean(false, 'n', 'k', logger)).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse string "true" to boolean true', () => {
|
||||
expect(validateBoolean('true', 'n', 'k', logger)).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse string "false" to boolean false', () => {
|
||||
expect(validateBoolean('false', 'n', 'k', logger)).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass through non-boolean non-string values unchanged', () => {
|
||||
expect(validateBoolean(42, 'n', 'k', logger)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateString ──────────────────────────────────────────────────
|
||||
describe('validateString()', () => {
|
||||
it('should accept a lowercase string', () => {
|
||||
expect(validateString('hello', {}, { default: '' }, 'n', 'k', logger)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should convert uppercase to lowercase', () => {
|
||||
expect(validateString('Hello', {}, { default: '' }, 'n', 'k', logger)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should convert a number to a string', () => {
|
||||
expect(validateString(42, {}, { default: '' }, 'n', 'k', logger)).toBe('42');
|
||||
});
|
||||
|
||||
it('should return null when nullable and value is null', () => {
|
||||
expect(validateString(null, { nullable: true }, { default: '' }, 'n', 'k', logger)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateEnum ────────────────────────────────────────────────────
|
||||
describe('validateEnum()', () => {
|
||||
const rules = { values: [{ value: 'open' }, { value: 'closed' }, { value: 'partial' }] };
|
||||
|
||||
it('should accept a valid enum value', () => {
|
||||
expect(validateEnum('open', rules, { default: 'closed' }, 'n', 'k', logger)).toBe('open');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(validateEnum('OPEN', rules, { default: 'closed' }, 'n', 'k', logger)).toBe('open');
|
||||
});
|
||||
|
||||
it('should return default for an invalid value', () => {
|
||||
expect(validateEnum('invalid', rules, { default: 'closed' }, 'n', 'k', logger)).toBe('closed');
|
||||
});
|
||||
|
||||
it('should return default when value is null', () => {
|
||||
expect(validateEnum(null, rules, { default: 'closed' }, 'n', 'k', logger)).toBe('closed');
|
||||
});
|
||||
|
||||
it('should return default when rules.values is not an array', () => {
|
||||
expect(validateEnum('open', {}, { default: 'closed' }, 'n', 'k', logger)).toBe('closed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Collection validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
describe('collectionValidators', () => {
|
||||
let logger;
|
||||
beforeEach(() => { logger = mockLogger(); });
|
||||
|
||||
// ── validateArray ───────────────────────────────────────────────────
|
||||
describe('validateArray()', () => {
|
||||
it('should return default when value is not an array', () => {
|
||||
expect(validateArray('not-array', { itemType: 'number' }, { default: [1] }, 'n', 'k', logger))
|
||||
.toEqual([1]);
|
||||
});
|
||||
|
||||
it('should filter items by itemType', () => {
|
||||
const result = validateArray([1, 'a', 2], { itemType: 'number', minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
||||
expect(result).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should respect maxLength', () => {
|
||||
const result = validateArray([1, 2, 3, 4, 5], { itemType: 'number', maxLength: 3, minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should return default when fewer items than minLength after filtering', () => {
|
||||
const result = validateArray(['a'], { itemType: 'number', minLength: 1 }, { default: [0] }, 'n', 'k', logger);
|
||||
expect(result).toEqual([0]);
|
||||
});
|
||||
|
||||
it('should pass all items through when itemType is null', () => {
|
||||
const result = validateArray([1, 'a', true], { itemType: 'null', minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
||||
expect(result).toEqual([1, 'a', true]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateSet ─────────────────────────────────────────────────────
|
||||
describe('validateSet()', () => {
|
||||
it('should convert default to Set when value is not a Set', () => {
|
||||
const result = validateSet('not-a-set', { itemType: 'number' }, { default: [1, 2] }, 'n', 'k', logger);
|
||||
expect(result).toBeInstanceOf(Set);
|
||||
expect([...result]).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should filter Set items by type', () => {
|
||||
const input = new Set([1, 'a', 2]);
|
||||
const result = validateSet(input, { itemType: 'number', minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
||||
expect([...result]).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should return default Set when too few items remain', () => {
|
||||
const input = new Set(['a']);
|
||||
const result = validateSet(input, { itemType: 'number', minLength: 1 }, { default: [0] }, 'n', 'k', logger);
|
||||
expect([...result]).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateObject ──────────────────────────────────────────────────
|
||||
describe('validateObject()', () => {
|
||||
it('should return default when value is not an object', () => {
|
||||
expect(validateObject('str', {}, { default: { a: 1 } }, 'n', 'k', jest.fn(), logger))
|
||||
.toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('should return default when value is an array', () => {
|
||||
expect(validateObject([1, 2], {}, { default: {} }, 'n', 'k', jest.fn(), logger))
|
||||
.toEqual({});
|
||||
});
|
||||
|
||||
it('should return default when no schema is provided', () => {
|
||||
expect(validateObject({ a: 1 }, {}, { default: { b: 2 } }, 'n', 'k', jest.fn(), logger))
|
||||
.toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it('should call validateSchemaFn when schema is provided', () => {
|
||||
const mockFn = jest.fn().mockReturnValue({ validated: true });
|
||||
const rules = { schema: { x: { default: 1 } } };
|
||||
const result = validateObject({ x: 2 }, rules, {}, 'n', 'k', mockFn, logger);
|
||||
expect(mockFn).toHaveBeenCalledWith({ x: 2 }, rules.schema, 'n.k');
|
||||
expect(result).toEqual({ validated: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Curve validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
describe('curveValidator', () => {
|
||||
let logger;
|
||||
beforeEach(() => { logger = mockLogger(); });
|
||||
|
||||
// ── Helper utilities ────────────────────────────────────────────────
|
||||
describe('isSorted()', () => {
|
||||
it('should return true for a sorted array', () => {
|
||||
expect(isSorted([1, 2, 3, 4])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for an unsorted array', () => {
|
||||
expect(isSorted([3, 1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for an empty array', () => {
|
||||
expect(isSorted([])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for equal adjacent values', () => {
|
||||
expect(isSorted([1, 1, 2])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnique()', () => {
|
||||
it('should return true when all values are unique', () => {
|
||||
expect(isUnique([1, 2, 3])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when duplicates exist', () => {
|
||||
expect(isUnique([1, 2, 2])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areNumbers()', () => {
|
||||
it('should return true for all numbers', () => {
|
||||
expect(areNumbers([1, 2.5, -3])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when a non-number is present', () => {
|
||||
expect(areNumbers([1, 'a', 3])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateCurve ───────────────────────────────────────────────────
|
||||
describe('validateCurve()', () => {
|
||||
const defaultCurve = { line1: { x: [0, 1], y: [0, 1] } };
|
||||
|
||||
it('should return default when input is null', () => {
|
||||
expect(validateCurve(null, defaultCurve, logger)).toEqual(defaultCurve);
|
||||
});
|
||||
|
||||
it('should return default for an empty object', () => {
|
||||
expect(validateCurve({}, defaultCurve, logger)).toEqual(defaultCurve);
|
||||
});
|
||||
|
||||
it('should validate a correct curve', () => {
|
||||
const curve = { line1: { x: [1, 2, 3], y: [10, 20, 30] } };
|
||||
const result = validateCurve(curve, defaultCurve, logger);
|
||||
expect(result.line1.x).toEqual([1, 2, 3]);
|
||||
expect(result.line1.y).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it('should sort unsorted x values and reorder y accordingly', () => {
|
||||
const curve = { line1: { x: [3, 1, 2], y: [30, 10, 20] } };
|
||||
const result = validateCurve(curve, defaultCurve, logger);
|
||||
expect(result.line1.x).toEqual([1, 2, 3]);
|
||||
expect(result.line1.y).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it('should remove duplicate x values', () => {
|
||||
const curve = { line1: { x: [1, 1, 2], y: [10, 11, 20] } };
|
||||
const result = validateCurve(curve, defaultCurve, logger);
|
||||
expect(result.line1.x).toEqual([1, 2]);
|
||||
expect(result.line1.y.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return default when y contains non-numbers', () => {
|
||||
const curve = { line1: { x: [1, 2], y: ['a', 'b'] } };
|
||||
expect(validateCurve(curve, defaultCurve, logger)).toEqual(defaultCurve);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateMachineCurve ────────────────────────────────────────────
|
||||
describe('validateMachineCurve()', () => {
|
||||
const defaultMC = {
|
||||
nq: { line1: { x: [0, 1], y: [0, 1] } },
|
||||
np: { line1: { x: [0, 1], y: [0, 1] } },
|
||||
};
|
||||
|
||||
it('should return default when input is null', () => {
|
||||
expect(validateMachineCurve(null, defaultMC, logger)).toEqual(defaultMC);
|
||||
});
|
||||
|
||||
it('should return default when nq or np is missing', () => {
|
||||
expect(validateMachineCurve({ nq: {} }, defaultMC, logger)).toEqual(defaultMC);
|
||||
});
|
||||
|
||||
it('should validate a correct machine curve', () => {
|
||||
const input = {
|
||||
nq: { line1: { x: [1, 2], y: [10, 20] } },
|
||||
np: { line1: { x: [1, 2], y: [5, 10] } },
|
||||
};
|
||||
const result = validateMachineCurve(input, defaultMC, logger);
|
||||
expect(result.nq.line1.x).toEqual([1, 2]);
|
||||
expect(result.np.line1.y).toEqual([5, 10]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ValidationUtils class
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
describe('ValidationUtils', () => {
|
||||
let vu;
|
||||
|
||||
beforeEach(() => {
|
||||
vu = new ValidationUtils(true, 'error'); // suppress most logging noise
|
||||
});
|
||||
|
||||
// ── constrain() ─────────────────────────────────────────────────────
|
||||
describe('constrain()', () => {
|
||||
it('should return value when within range', () => {
|
||||
expect(vu.constrain(5, 0, 10)).toBe(5);
|
||||
});
|
||||
|
||||
it('should clamp to min when value is below range', () => {
|
||||
expect(vu.constrain(-5, 0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('should clamp to max when value is above range', () => {
|
||||
expect(vu.constrain(15, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('should return min for boundary value equal to min', () => {
|
||||
expect(vu.constrain(0, 0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return max for boundary value equal to max', () => {
|
||||
expect(vu.constrain(10, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('should return min when value is not a number', () => {
|
||||
expect(vu.constrain('abc', 0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return min when value is null', () => {
|
||||
expect(vu.constrain(null, 0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return min when value is undefined', () => {
|
||||
expect(vu.constrain(undefined, 0, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateSchema() ────────────────────────────────────────────────
|
||||
describe('validateSchema()', () => {
|
||||
it('should use default value when config key is missing', () => {
|
||||
const schema = {
|
||||
speed: { default: 100, rules: { type: 'number' } },
|
||||
};
|
||||
const result = vu.validateSchema({}, schema, 'test');
|
||||
expect(result.speed).toBe(100);
|
||||
});
|
||||
|
||||
it('should use provided value over default', () => {
|
||||
const schema = {
|
||||
speed: { default: 100, rules: { type: 'number' } },
|
||||
};
|
||||
const result = vu.validateSchema({ speed: 200 }, schema, 'test');
|
||||
expect(result.speed).toBe(200);
|
||||
});
|
||||
|
||||
it('should strip unknown keys from config', () => {
|
||||
const schema = {
|
||||
speed: { default: 100, rules: { type: 'number' } },
|
||||
};
|
||||
const config = { speed: 50, unknownKey: 'bad' };
|
||||
const result = vu.validateSchema(config, schema, 'test');
|
||||
expect(result.unknownKey).toBeUndefined();
|
||||
expect(result.speed).toBe(50);
|
||||
});
|
||||
|
||||
it('should validate number type with min/max', () => {
|
||||
const schema = {
|
||||
speed: { default: 10, rules: { type: 'number', min: 0, max: 100 } },
|
||||
};
|
||||
// within range
|
||||
expect(vu.validateSchema({ speed: 50 }, schema, 'test').speed).toBe(50);
|
||||
// below min -> default
|
||||
expect(vu.validateSchema({ speed: -1 }, schema, 'test').speed).toBe(10);
|
||||
// above max -> default
|
||||
expect(vu.validateSchema({ speed: 101 }, schema, 'test').speed).toBe(10);
|
||||
});
|
||||
|
||||
it('should validate boolean type', () => {
|
||||
const schema = {
|
||||
enabled: { default: true, rules: { type: 'boolean' } },
|
||||
};
|
||||
expect(vu.validateSchema({ enabled: false }, schema, 'test').enabled).toBe(false);
|
||||
expect(vu.validateSchema({ enabled: 'true' }, schema, 'test').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate string type (lowercased)', () => {
|
||||
const schema = {
|
||||
mode: { default: 'auto', rules: { type: 'string' } },
|
||||
};
|
||||
expect(vu.validateSchema({ mode: 'Manual' }, schema, 'test').mode).toBe('manual');
|
||||
});
|
||||
|
||||
it('should validate enum type', () => {
|
||||
const schema = {
|
||||
state: {
|
||||
default: 'open',
|
||||
rules: { type: 'enum', values: [{ value: 'open' }, { value: 'closed' }] },
|
||||
},
|
||||
};
|
||||
expect(vu.validateSchema({ state: 'closed' }, schema, 'test').state).toBe('closed');
|
||||
expect(vu.validateSchema({ state: 'invalid' }, schema, 'test').state).toBe('open');
|
||||
});
|
||||
|
||||
it('should validate integer type', () => {
|
||||
const schema = {
|
||||
count: { default: 5, rules: { type: 'integer', min: 1, max: 100 } },
|
||||
};
|
||||
expect(vu.validateSchema({ count: 10 }, schema, 'test').count).toBe(10);
|
||||
expect(vu.validateSchema({ count: '42' }, schema, 'test').count).toBe(42);
|
||||
});
|
||||
|
||||
it('should validate array type', () => {
|
||||
const schema = {
|
||||
items: { default: [1, 2], rules: { type: 'array', itemType: 'number', minLength: 1 } },
|
||||
};
|
||||
expect(vu.validateSchema({ items: [3, 4, 5] }, schema, 'test').items).toEqual([3, 4, 5]);
|
||||
expect(vu.validateSchema({ items: 'not-array' }, schema, 'test').items).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should handle nested object with schema recursively', () => {
|
||||
const schema = {
|
||||
logging: {
|
||||
rules: { type: 'object', schema: {
|
||||
enabled: { default: true, rules: { type: 'boolean' } },
|
||||
level: { default: 'info', rules: { type: 'string' } },
|
||||
}},
|
||||
},
|
||||
};
|
||||
const result = vu.validateSchema(
|
||||
{ logging: { enabled: false, level: 'Debug' } },
|
||||
schema,
|
||||
'test'
|
||||
);
|
||||
expect(result.logging.enabled).toBe(false);
|
||||
expect(result.logging.level).toBe('debug');
|
||||
});
|
||||
|
||||
it('should skip reserved keys (rules, description, schema)', () => {
|
||||
const schema = {
|
||||
rules: 'should be skipped',
|
||||
description: 'should be skipped',
|
||||
schema: 'should be skipped',
|
||||
speed: { default: 10, rules: { type: 'number' } },
|
||||
};
|
||||
const result = vu.validateSchema({}, schema, 'test');
|
||||
expect(result).not.toHaveProperty('rules');
|
||||
expect(result).not.toHaveProperty('description');
|
||||
expect(result).not.toHaveProperty('schema');
|
||||
expect(result.speed).toBe(10);
|
||||
});
|
||||
|
||||
it('should use default for unknown validation type', () => {
|
||||
const schema = {
|
||||
weird: { default: 'fallback', rules: { type: 'unknownType' } },
|
||||
};
|
||||
const result = vu.validateSchema({ weird: 'value' }, schema, 'test');
|
||||
expect(result.weird).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should handle curve type', () => {
|
||||
const schema = {
|
||||
curve: {
|
||||
default: { line1: { x: [0, 1], y: [0, 1] } },
|
||||
rules: { type: 'curve' },
|
||||
},
|
||||
};
|
||||
const validCurve = { line1: { x: [1, 2], y: [10, 20] } };
|
||||
const result = vu.validateSchema({ curve: validCurve }, schema, 'test');
|
||||
expect(result.curve.line1.x).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeUnwantedKeys() ────────────────────────────────────────────
|
||||
describe('removeUnwantedKeys()', () => {
|
||||
it('should remove rules and description keys', () => {
|
||||
const input = {
|
||||
speed: { default: 10, rules: { type: 'number' }, description: 'Speed setting' },
|
||||
};
|
||||
const result = vu.removeUnwantedKeys(input);
|
||||
expect(result.speed).toBe(10);
|
||||
});
|
||||
|
||||
it('should recurse into nested objects', () => {
|
||||
const input = {
|
||||
logging: {
|
||||
enabled: { default: true, rules: {} },
|
||||
level: { default: 'info', description: 'Log level' },
|
||||
},
|
||||
};
|
||||
const result = vu.removeUnwantedKeys(input);
|
||||
expect(result.logging.enabled).toBe(true);
|
||||
expect(result.logging.level).toBe('info');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const input = [
|
||||
{ a: { default: 1, rules: {} } },
|
||||
{ b: { default: 2, description: 'x' } },
|
||||
];
|
||||
const result = vu.removeUnwantedKeys(input);
|
||||
expect(result[0].a).toBe(1);
|
||||
expect(result[1].b).toBe(2);
|
||||
});
|
||||
|
||||
it('should return primitives as-is', () => {
|
||||
expect(vu.removeUnwantedKeys(42)).toBe(42);
|
||||
expect(vu.removeUnwantedKeys('hello')).toBe('hello');
|
||||
expect(vu.removeUnwantedKeys(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user