Compare commits
21 Commits
fix/valida
...
30c5dc8508
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30c5dc8508 | ||
|
|
95c5e684e4 | ||
|
|
8ebf31dd39 | ||
|
|
92eb8d2f15 | ||
|
|
7372d12088 | ||
|
|
62f389a51f | ||
|
|
57b77f905a | ||
|
|
47faf94048 | ||
|
|
9a998191cd | ||
|
|
94bcc90b4b | ||
|
|
a516c2b2b6 | ||
|
|
4b6250cc42 | ||
|
|
35f648f64e | ||
|
|
4252292ae1 | ||
|
|
693517cc8f | ||
|
|
086e5fe751 | ||
|
|
29b78a3f9b | ||
|
|
43f69066af | ||
|
|
e50be2ee66 | ||
|
|
75d16c620a | ||
|
|
024db5533a |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
|
||||
# Local stub generated by `npm install` in the submodule directory.
|
||||
# generalFunctions has no production deps of its own.
|
||||
package-lock.json
|
||||
@@ -153,7 +153,7 @@
|
||||
100
|
||||
],
|
||||
"y": [
|
||||
52.14679487594751,
|
||||
11.142207365162072,
|
||||
20.746724065725342,
|
||||
31.960270693111905,
|
||||
45.6989826531509,
|
||||
@@ -411,7 +411,7 @@
|
||||
"y": [
|
||||
8.219999984177646,
|
||||
13.426327986363882,
|
||||
57.998168647814666,
|
||||
25.971821741448165,
|
||||
42.997354839160536,
|
||||
64.33911122026377
|
||||
]
|
||||
@@ -427,7 +427,7 @@
|
||||
"y": [
|
||||
8.219999984177646,
|
||||
13.426327986363882,
|
||||
53.35067019159144,
|
||||
25.288156424842576,
|
||||
42.48429874246399,
|
||||
64.03769740244357
|
||||
]
|
||||
|
||||
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",
|
||||
|
||||
303
scripts/wikiGen.js
Normal file
303
scripts/wikiGen.js
Normal file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* wikiGen.js — shared wiki auto-generation helper for every EVOLV node.
|
||||
*
|
||||
* Two subcommands:
|
||||
*
|
||||
* node wikiGen.js contract <commands-module> [--write <wiki-path>]
|
||||
* node wikiGen.js datamodel <specificClass-module> [--write <wiki-path>]
|
||||
*
|
||||
* `contract` walks the descriptor array exported by `src/commands/index.js`
|
||||
* and emits a markdown table mapping canonical topic → aliases → payload
|
||||
* schema → effect description.
|
||||
*
|
||||
* `datamodel` instantiates the domain with a minimal stub config, calls
|
||||
* `getOutput()` once, and emits a markdown table of (key, type, sample value).
|
||||
* If construction fails (because the domain needs a live runtime that isn't
|
||||
* trivially stubbable), the script falls back to a hand-curated partial at
|
||||
* `<repo>/wiki/_partial-datamodel.md.template` instead of crashing.
|
||||
*
|
||||
* When `--write <wiki-path>` is given, the output is spliced between the
|
||||
* matching `<!-- BEGIN AUTOGEN: <marker> -->` / `<!-- END AUTOGEN: ... -->`
|
||||
* markers in that file. Otherwise it prints to stdout.
|
||||
*
|
||||
* See `.claude/refactor/WIKI_TEMPLATE.md` (sections 5 and 8) and CONTRACTS.md
|
||||
* for the canonical topic naming and registry shape this script consumes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ── CLI parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const [, , subcmd, target, ...rest] = argv;
|
||||
const opts = { subcmd, target, write: null };
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === '--write' && rest[i + 1]) {
|
||||
opts.write = rest[i + 1];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
process.stderr.write([
|
||||
'Usage:',
|
||||
' node wikiGen.js contract <path-to-commands/index.js> [--write <wiki-path>]',
|
||||
' node wikiGen.js datamodel <path-to-specificClass.js> [--write <wiki-path>]',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function resolveAbs(p) {
|
||||
return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
function describeSchema(schema) {
|
||||
if (!schema) return '_unspecified_';
|
||||
const t = schema.type;
|
||||
if (!t) return '_unspecified_';
|
||||
if (t === 'any') return '`any`';
|
||||
if (t === 'object') {
|
||||
const props = schema.properties || {};
|
||||
const keys = Object.keys(props);
|
||||
if (!keys.length) return '`object`';
|
||||
const parts = keys.map((k) => {
|
||||
const subType = props[k]?.type ?? 'any';
|
||||
return `${k}:${subType}`;
|
||||
});
|
||||
return '`{ ' + parts.join(', ') + ' }`';
|
||||
}
|
||||
return '`' + t + '`';
|
||||
}
|
||||
|
||||
function topicEffectFallback(topic) {
|
||||
// Try to derive a short, plain-English effect from the canonical topic
|
||||
// when the descriptor doesn't carry a description field. Keep it terse —
|
||||
// a maintainer can override by adding `description` to the descriptor.
|
||||
const prefixes = {
|
||||
'set.': 'Replaces the named state value with the supplied payload.',
|
||||
'cmd.': 'Triggers an action / sequence — not idempotent.',
|
||||
'data.': 'Pushes a value into the node\'s measurement stream.',
|
||||
'query.': 'Read-only query; node replies on the same msg.',
|
||||
'child.': 'Parent/child plumbing — registers or unregisters a child node.',
|
||||
};
|
||||
for (const [pfx, line] of Object.entries(prefixes)) {
|
||||
if (topic.startsWith(pfx)) return line;
|
||||
}
|
||||
return '_(see handler)_';
|
||||
}
|
||||
|
||||
function spliceAutogen(filePath, marker, body) {
|
||||
const begin = `<!-- BEGIN AUTOGEN: ${marker} -->`;
|
||||
const end = `<!-- END AUTOGEN: ${marker} -->`;
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`wikiGen: --write target '${filePath}' does not exist`);
|
||||
}
|
||||
const src = fs.readFileSync(filePath, 'utf8');
|
||||
const bIdx = src.indexOf(begin);
|
||||
const eIdx = src.indexOf(end);
|
||||
if (bIdx < 0 || eIdx < 0 || eIdx < bIdx) {
|
||||
throw new Error(`wikiGen: markers '${marker}' not found in ${filePath}`);
|
||||
}
|
||||
const before = src.slice(0, bIdx + begin.length);
|
||||
const after = src.slice(eIdx);
|
||||
const out = before + '\n\n' + body.trimEnd() + '\n\n' + after;
|
||||
fs.writeFileSync(filePath, out, 'utf8');
|
||||
}
|
||||
|
||||
// ── Subcommand: contract ───────────────────────────────────────────────────
|
||||
|
||||
function renderContract(commandsPath) {
|
||||
const abs = resolveAbs(commandsPath);
|
||||
// eslint-disable-next-line import/no-dynamic-require, global-require
|
||||
const registry = require(abs);
|
||||
if (!Array.isArray(registry)) {
|
||||
throw new Error(`wikiGen contract: ${abs} does not export an array of descriptors`);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
lines.push('| Canonical topic | Aliases | Payload | Effect |');
|
||||
lines.push('|---|---|---|---|');
|
||||
for (const d of registry) {
|
||||
const topic = '`' + d.topic + '`';
|
||||
const aliases = (d.aliases && d.aliases.length)
|
||||
? d.aliases.map((a) => '`' + a + '`').join(', ')
|
||||
: '_(none)_';
|
||||
const payload = describeSchema(d.payloadSchema);
|
||||
const effect = d.description ? String(d.description) : topicEffectFallback(d.topic);
|
||||
lines.push(`| ${topic} | ${aliases} | ${payload} | ${effect} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Subcommand: datamodel ──────────────────────────────────────────────────
|
||||
|
||||
function inferSampleType(v) {
|
||||
if (v === null) return 'null';
|
||||
if (Array.isArray(v)) return 'array';
|
||||
return typeof v;
|
||||
}
|
||||
|
||||
function trySampleValue(v) {
|
||||
if (v === null || v === undefined) return '`null`';
|
||||
const t = typeof v;
|
||||
if (t === 'number' || t === 'boolean') return '`' + String(v) + '`';
|
||||
if (t === 'string') return '`"' + v + '"`';
|
||||
if (Array.isArray(v)) return '`[…]`';
|
||||
if (t === 'object') return '`{…}`';
|
||||
return '`' + String(v) + '`';
|
||||
}
|
||||
|
||||
// Heuristic unit map for top-level snapshot keys that aren't structured as
|
||||
// MeasurementContainer keys (e.g. `heightBasin`, `surfaceArea`). Best-effort
|
||||
// — the canonical place for unit semantics is the node's config schema; the
|
||||
// table below is just enough to keep the auto-generated data-model readable.
|
||||
const FLAT_KEY_UNITS = {
|
||||
heightBasin: 'm',
|
||||
basinHeight: 'm',
|
||||
inflowLevel: 'm',
|
||||
outflowLevel: 'm',
|
||||
overflowLevel: 'm',
|
||||
startLevel: 'm',
|
||||
stopLevel: 'm',
|
||||
minLevel: 'm',
|
||||
maxLevel: 'm',
|
||||
surfaceArea: 'm2',
|
||||
volEmptyBasin: 'm3',
|
||||
maxVol: 'm3',
|
||||
maxVolAtOverflow:'m3',
|
||||
minVol: 'm3',
|
||||
minVolAtInflow: 'm3',
|
||||
minVolAtOutflow: 'm3',
|
||||
percControl: '%',
|
||||
timeleft: 's',
|
||||
};
|
||||
|
||||
function inferUnitFromKey(key) {
|
||||
// MeasurementContainer-shaped keys take precedence: `{type}.{variant}.{position}.{childId}`.
|
||||
const parts = key.split('.');
|
||||
if (parts.length >= 3) {
|
||||
const type = parts[0];
|
||||
const map = {
|
||||
flow: 'm3/s',
|
||||
pressure: 'Pa',
|
||||
power: 'W',
|
||||
temperature: 'K',
|
||||
level: 'm',
|
||||
volume: 'm3',
|
||||
volumePercent: '%',
|
||||
netFlowRate: 'm3/s',
|
||||
};
|
||||
if (map[type]) return map[type];
|
||||
}
|
||||
return FLAT_KEY_UNITS[key] || '—';
|
||||
}
|
||||
|
||||
function renderDatamodel(specificClassPath) {
|
||||
const abs = resolveAbs(specificClassPath);
|
||||
// eslint-disable-next-line import/no-dynamic-require, global-require
|
||||
const Domain = require(abs);
|
||||
|
||||
// Minimum viable stub config — the BaseDomain pipeline pulls per-key
|
||||
// defaults from the JSON schema, so this only needs to supply the bits
|
||||
// that BaseDomain reads from `userConfig` directly.
|
||||
const stubConfig = {
|
||||
general: {
|
||||
name: `wikiGen-${Domain.name || 'domain'}`,
|
||||
id: `wikiGen-${Domain.name || 'domain'}-id`,
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
},
|
||||
};
|
||||
|
||||
// Look for a hand-curated fallback alongside the wiki. Path is
|
||||
// `<node>/wiki/_partial-datamodel.md.template` relative to the
|
||||
// *commands*-or-specificClass file's repo root.
|
||||
const repoRoot = findRepoRoot(abs);
|
||||
const fallback = repoRoot
|
||||
? path.join(repoRoot, 'wiki', '_partial-datamodel.md.template')
|
||||
: null;
|
||||
|
||||
let out;
|
||||
try {
|
||||
const instance = new Domain(stubConfig);
|
||||
out = instance.getOutput ? instance.getOutput() : null;
|
||||
if (!out || typeof out !== 'object') {
|
||||
throw new Error('getOutput() returned a non-object');
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`wikiGen datamodel: live instantiation failed: ${err.message}\n`);
|
||||
if (fallback && fs.existsSync(fallback)) {
|
||||
process.stderr.write(`wikiGen datamodel: using hand-curated fallback ${fallback}\n`);
|
||||
return fs.readFileSync(fallback, 'utf8').trimEnd();
|
||||
}
|
||||
process.stderr.write('wikiGen datamodel: no hand-curated fallback found — emitting placeholder\n');
|
||||
return [
|
||||
'| Key | Type | Unit | Sample |',
|
||||
'|---|---|---|---|',
|
||||
`| _live instantiation failed; provide ${fallback ? `\`wiki/_partial-datamodel.md.template\`` : 'a hand-curated template'}_ | — | — | — |`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
lines.push('| Key | Type | Unit | Sample |');
|
||||
lines.push('|---|---|---|---|');
|
||||
for (const k of Object.keys(out).sort()) {
|
||||
const v = out[k];
|
||||
lines.push(`| \`${k}\` | ${inferSampleType(v)} | ${inferUnitFromKey(k)} | ${trySampleValue(v)} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function findRepoRoot(startPath) {
|
||||
let dir = path.dirname(startPath);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (!opts.subcmd || !opts.target) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let body;
|
||||
let marker;
|
||||
if (opts.subcmd === 'contract') {
|
||||
body = renderContract(opts.target);
|
||||
marker = 'topic-contract';
|
||||
} else if (opts.subcmd === 'datamodel') {
|
||||
body = renderDatamodel(opts.target);
|
||||
marker = 'data-model';
|
||||
} else {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.write) {
|
||||
spliceAutogen(resolveAbs(opts.write), marker, body);
|
||||
process.stderr.write(`wikiGen: wrote ${marker} block into ${opts.write}\n`);
|
||||
} else {
|
||||
process.stdout.write(body + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { renderContract, renderDatamodel, spliceAutogen, describeSchema };
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class ConfigManager {
|
||||
functionality: {
|
||||
softwareType: nodeName.toLowerCase(),
|
||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
||||
distance: uiConfig.hasDistance ? uiConfig.distance : null
|
||||
},
|
||||
output: {
|
||||
process: uiConfig.processOutputFormat || 'process',
|
||||
|
||||
@@ -411,6 +411,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"current": {
|
||||
"default": "analog",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "analog",
|
||||
"description": "Single-scalar input mode (classic 4-20mA / PLC style). msg.payload is a number; the node runs one offset/scaling/smoothing/outlier pipeline and emits one MeasurementContainer slot."
|
||||
},
|
||||
{
|
||||
"value": "digital",
|
||||
"description": "Multi-channel input mode (MQTT / IoT JSON style). msg.payload is an object keyed by channel names declared under config.channels; the node routes each key through its own pipeline and emits N slots from one input message."
|
||||
}
|
||||
],
|
||||
"description": "Selects how incoming msg.payload is interpreted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"default": [],
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"itemType": "object",
|
||||
"minLength": 0,
|
||||
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
|
||||
}
|
||||
},
|
||||
"outlierDetection": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
"description": "The default flow unit used for reporting station throughput."
|
||||
}
|
||||
},
|
||||
"flowThreshold": {
|
||||
"default": 0.0001,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Flow dead-band in m3/s below which the station treats net flow as steady."
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
@@ -127,6 +135,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"process": {
|
||||
"default": "process",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "process",
|
||||
"description": "Delta-compressed process message."
|
||||
},
|
||||
{
|
||||
"value": "json",
|
||||
"description": "JSON payload."
|
||||
},
|
||||
{
|
||||
"value": "csv",
|
||||
"description": "CSV-formatted payload."
|
||||
}
|
||||
],
|
||||
"description": "Format of the process payload emitted on output port 0."
|
||||
}
|
||||
},
|
||||
"dbase": {
|
||||
"default": "influxdb",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "influxdb",
|
||||
"description": "InfluxDB telemetry payload."
|
||||
},
|
||||
{
|
||||
"value": "json",
|
||||
"description": "JSON payload."
|
||||
},
|
||||
{
|
||||
"value": "csv",
|
||||
"description": "CSV-formatted payload."
|
||||
}
|
||||
],
|
||||
"description": "Format of the telemetry payload emitted on output port 1."
|
||||
}
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"uuid": {
|
||||
"default": null,
|
||||
@@ -235,23 +287,23 @@
|
||||
"description": "Unit used for level related setpoints and thresholds."
|
||||
}
|
||||
},
|
||||
"heightInlet": {
|
||||
"inflowLevel": {
|
||||
"default": 2,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Height of the inlet pipe measured from the basin floor (m)."
|
||||
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m)."
|
||||
}
|
||||
},
|
||||
"heightOutlet": {
|
||||
"outflowLevel": {
|
||||
"default": 0.2,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Height of the outlet pipe measured from the basin floor (m)."
|
||||
"description": "Top height of the outlet or pump-suction pipe measured from the basin floor (m)."
|
||||
}
|
||||
},
|
||||
"heightOverflow": {
|
||||
"overflowLevel": {
|
||||
"default": 2.5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
@@ -433,36 +485,86 @@
|
||||
}
|
||||
},
|
||||
"levelbased": {
|
||||
"minLevel": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Between minLevel and the active ramp start, demand is held at 0 %."
|
||||
}
|
||||
},
|
||||
"startLevel": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "start of pump / group when level reaches this in meters starting from bottom."
|
||||
"description": "Pump-on threshold and ramp foot. Below this level demand is 0 %; at or above it demand scales 0 → 100 % across [startLevel, maxLevel] using the configured curve (linear or log). When enableShiftedRamp is on, this also serves as the bottom of the held-then-ramp curve during draining."
|
||||
}
|
||||
},
|
||||
"stopLevel": {
|
||||
"default": 1,
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"min": 0,
|
||||
"description": "stop of pump / group when level reaches this in meters starting from bottom"
|
||||
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel."
|
||||
}
|
||||
},
|
||||
"minFlowLevel": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "min level to scale the flow lineair"
|
||||
}
|
||||
},
|
||||
"maxFlowLevel": {
|
||||
"maxLevel": {
|
||||
"default": 4,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "max level to scale the flow lineair"
|
||||
"description": "Level at which the pump demand saturates at 100 %. Above this, demand stays clamped."
|
||||
}
|
||||
},
|
||||
"curveType": {
|
||||
"default": "linear",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "linear",
|
||||
"description": "Linear demand scaling between the active lower ramp level and maxLevel."
|
||||
},
|
||||
{
|
||||
"value": "log",
|
||||
"description": "Logarithmic demand scaling with fast response early in the ramp."
|
||||
}
|
||||
],
|
||||
"description": "Demand curve used by levelbased control."
|
||||
}
|
||||
},
|
||||
"logCurveFactor": {
|
||||
"default": 9,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0.001,
|
||||
"description": "Shape factor for the levelbased log curve; higher values increase early response."
|
||||
}
|
||||
},
|
||||
"enableShiftedRamp": {
|
||||
"default": false,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "When true, arm a hysteresis shift: once level rises past shiftLevel the ramp foot moves left from inflowLevel to startLevel until level falls back below startLevel."
|
||||
}
|
||||
},
|
||||
"shiftLevel": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Level (m) at which the held output starts ramping down during draining. Must be > startLevel and ≤ maxLevel. Ignored when enableShiftedRamp is false."
|
||||
}
|
||||
},
|
||||
"shiftArmPercent": {
|
||||
"default": 95,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Output % threshold that arms the shift on the way up. Once armed, the output value at the moment direction flips to draining becomes the held value, and stays held until level drops to shiftLevel. Disarms when level reaches startLevel."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -638,19 +740,18 @@
|
||||
"description": "Volume percentage below which dry run protection activates."
|
||||
}
|
||||
},
|
||||
"dryRunDebounceSeconds": {
|
||||
"default": 30,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
|
||||
}
|
||||
},
|
||||
"enableOverfillProtection": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling."
|
||||
"description": "Deprecated alias for enableHighVolumeSafety. If true, high level alarms and shutdowns will be enforced to preserve overflow margin."
|
||||
}
|
||||
},
|
||||
"enableHighVolumeSafety": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "If true, high-volume safety actions run before the basin reaches physical overflow."
|
||||
}
|
||||
},
|
||||
"overfillThresholdPercent": {
|
||||
@@ -659,15 +760,16 @@
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Volume percentage above which overfill protection activates."
|
||||
"description": "Deprecated alias for highVolumeSafetyThresholdPercent."
|
||||
}
|
||||
},
|
||||
"overfillDebounceSeconds": {
|
||||
"default": 30,
|
||||
"highVolumeSafetyThresholdPercent": {
|
||||
"default": 98,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
|
||||
"max": 100,
|
||||
"description": "Percentage of maxVolAtOverflow where high-volume safety activates before actual overflow."
|
||||
}
|
||||
},
|
||||
"timeleftToFullOrEmptyThresholdSeconds": {
|
||||
|
||||
@@ -91,6 +91,54 @@
|
||||
],
|
||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||
}
|
||||
},
|
||||
"distance": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
|
||||
}
|
||||
},
|
||||
"distanceUnit": {
|
||||
"default": "m",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
|
||||
}
|
||||
},
|
||||
"distanceDescription": {
|
||||
"default": "",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Free-text description of what the distance offset represents (e.g. 'cable length from control panel to motor')."
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"process": {
|
||||
"default": "process",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||
{ "value": "json", "description": "Raw JSON payload." },
|
||||
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||
],
|
||||
"description": "Format of the process payload emitted on output port 0."
|
||||
}
|
||||
},
|
||||
"dbase": {
|
||||
"default": "influxdb",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||
{ "value": "json", "description": "Raw JSON payload." },
|
||||
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||
],
|
||||
"description": "Format of the telemetry payload emitted on output port 1."
|
||||
}
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
@@ -234,42 +282,8 @@
|
||||
},
|
||||
"machineCurve": {
|
||||
"default": {
|
||||
"nq": {
|
||||
"1": {
|
||||
"x": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5
|
||||
],
|
||||
"y": [
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
50
|
||||
]
|
||||
}
|
||||
},
|
||||
"np": {
|
||||
"1": {
|
||||
"x": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5
|
||||
],
|
||||
"y": [
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
50
|
||||
]
|
||||
}
|
||||
}
|
||||
"nq": {},
|
||||
"np": {}
|
||||
},
|
||||
"rules": {
|
||||
"type": "machineCurve",
|
||||
|
||||
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,3 +1,17 @@
|
||||
// Map a child's raw softwareType (the lowercased node name from
|
||||
// buildConfig) to the "role" key that parent registerChild() handlers
|
||||
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
|
||||
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
|
||||
// silently miss every real production child because rotatingMachine
|
||||
// reports softwareType='rotatingmachine' and machineGroupControl reports
|
||||
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
|
||||
// ('machine', 'machinegroup') stay green because those aren't in the
|
||||
// alias map and pass through unchanged.
|
||||
const SOFTWARE_TYPE_ALIASES = {
|
||||
rotatingmachine: 'machine',
|
||||
machinegroupcontrol: 'machinegroup',
|
||||
};
|
||||
|
||||
class ChildRegistrationUtils {
|
||||
constructor(mainClass) {
|
||||
this.mainClass = mainClass;
|
||||
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
||||
const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
||||
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
|
||||
const name = child.config.general.name || child.config.general.id || 'unknown';
|
||||
const id = child.config.general.id || name;
|
||||
|
||||
|
||||
@@ -3,6 +3,51 @@ const EventEmitter = require('events');
|
||||
const convertModule = require('../convert/index');
|
||||
const { POSITIONS } = require('../constants/positions');
|
||||
|
||||
/* ============================================================================
|
||||
* MeasurementContainer — measurement storage with chainable type/variant/
|
||||
* position/child addressing.
|
||||
*
|
||||
* INTERNAL STORAGE SHAPE
|
||||
* measurements[type][variant][position][childId] = Measurement instance
|
||||
*
|
||||
* The childId layer is ALWAYS present, even when the caller doesn't specify
|
||||
* one. _getOrCreateMeasurement defaults childId to 'default' when no
|
||||
* .child(...) is in the chain. So writing
|
||||
*
|
||||
* mc.type('level').variant('measured').position('atequipment')
|
||||
* .value(2.5, ts, 'm');
|
||||
*
|
||||
* stores the value at measurements.level.measured.atequipment.default.
|
||||
*
|
||||
* READING — the chainable getters resolve the default child transparently,
|
||||
* so consumers usually don't see it:
|
||||
*
|
||||
* mc.type('level').variant('measured').position('atequipment')
|
||||
* .getCurrentValue('m'); // returns 2.5
|
||||
*
|
||||
* FLATTENED OUTPUT — getFlattenedOutput() emits ONE key per child, including
|
||||
* the implicit 'default' bucket:
|
||||
*
|
||||
* {
|
||||
* 'level.measured.atequipment.default': 2.5, // implicit child
|
||||
* 'flow.predicted.in.manual-qin': 0.05, // explicit .child('manual-qin')
|
||||
* 'flow.predicted.in.from-pump-A': 0.03,
|
||||
* …
|
||||
* }
|
||||
*
|
||||
* ⚠ DASHBOARDS / DOWNSTREAM PARSERS MUST INCLUDE THE CHILD KEY
|
||||
* The flat key format is `${type}.${variant}.${position}.${childId}`.
|
||||
* When you have not used .child(), the childId is the literal string
|
||||
* 'default'. Use 'level.measured.atequipment.default', NOT
|
||||
* 'level.measured.atequipment'. This trips up new consumers — see the
|
||||
* pumpingStation basic-dashboard parser for an example that gets it right.
|
||||
*
|
||||
* AGGREGATION — sum() folds all children of a position into one number:
|
||||
*
|
||||
* mc.sum('flow', 'predicted', ['in'], 'm3/s');
|
||||
* // = manual-qin + from-pump-A + … + (default if any)
|
||||
* ============================================================================
|
||||
*/
|
||||
class MeasurementContainer {
|
||||
constructor(options = {},logger) {
|
||||
this.logger = logger || null;
|
||||
@@ -141,11 +186,17 @@ class MeasurementContainer {
|
||||
}
|
||||
|
||||
isUnitCompatible(measurementType, unit) {
|
||||
const desc = this._describeUnit(unit);
|
||||
if (!desc) return false;
|
||||
// Unknown type (not in measureMap): accept any unit. This lets user-
|
||||
// defined measurement types (e.g. 'humidity', 'co2', arbitrary IoT
|
||||
// channels in digital mode) pass through without being rejected just
|
||||
// because their unit string ('%', 'ppm', …) is not a known physical
|
||||
// unit to the convert module. Known types are still validated strictly.
|
||||
const normalizedType = this._normalizeType(measurementType);
|
||||
const expectedMeasure = this.measureMap[normalizedType];
|
||||
if (!expectedMeasure) return true;
|
||||
|
||||
const desc = this._describeUnit(unit);
|
||||
if (!desc) return false;
|
||||
return desc.measure === expectedMeasure;
|
||||
}
|
||||
|
||||
@@ -374,16 +425,34 @@ class MeasurementContainer {
|
||||
// Legacy single measurement
|
||||
if (posBucket?.getCurrentValue) return posBucket;
|
||||
|
||||
// Child-aware: pick requested child, otherwise fall back to default, otherwise first available
|
||||
// Child-aware lookup. Two separate sources of "child-id" on the
|
||||
// container, with DIFFERENT strictness:
|
||||
//
|
||||
// _currentChildId : transient, set by .child(name) inside a chain.
|
||||
// Explicit per-call. STRICT — if the named child
|
||||
// does not exist, return null. Silent fall-through
|
||||
// to a sibling would mask a missing-stream read
|
||||
// as a wrong-stream read (see pumpingStation
|
||||
// spillPrev bug, 2026-05-06).
|
||||
//
|
||||
// this.childId : persistent, set by setChildId(id). HINT only —
|
||||
// try it first, then fall back to 'default' then
|
||||
// first available. Containers registered with a
|
||||
// persistent id (rotatingMachine, etc.) write
|
||||
// under composed child ids (e.g. 'up-<id>') that
|
||||
// don't equal the persistent id, and reads must
|
||||
// still resolve to those writes.
|
||||
if (posBucket && typeof posBucket === 'object') {
|
||||
const requestedKey = this._currentChildId || this.childId;
|
||||
const keys = Object.keys(posBucket);
|
||||
if (!keys.length) return null;
|
||||
const measurement =
|
||||
(requestedKey && posBucket[requestedKey]) ||
|
||||
|
||||
if (this._currentChildId) {
|
||||
return posBucket[this._currentChildId] || null;
|
||||
}
|
||||
return (this.childId && posBucket[this.childId]) ||
|
||||
posBucket.default ||
|
||||
posBucket[keys[0]];
|
||||
return measurement || null;
|
||||
posBucket[keys[0]] ||
|
||||
null;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -529,18 +598,43 @@ class MeasurementContainer {
|
||||
.reduce((acc, v) => acc + v, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the entire container to a key→value map, suitable for
|
||||
* dashboards / InfluxDB / debug dumps.
|
||||
*
|
||||
* KEY FORMAT — child-bucketed series (the common case):
|
||||
* `${type}.${variant}.${position}.${childId}`
|
||||
*
|
||||
* Even measurements written without an explicit `.child(...)` end up
|
||||
* here under `childId === 'default'` (see _getOrCreateMeasurement).
|
||||
* Examples:
|
||||
* level.measured.atequipment.default // implicit child
|
||||
* flow.predicted.in.manual-qin // explicit child
|
||||
* flow.predicted.in.from-pump-A // explicit child
|
||||
*
|
||||
* Consumers (Node-RED dashboards, parsers) MUST include the trailing
|
||||
* `.default` when reading default-bucket measurements. Stripping it
|
||||
* silently misses the value. This is the #1 footgun for new code that
|
||||
* uses MeasurementContainer.
|
||||
*
|
||||
* The "Legacy single series" branch below catches a pre-v2 storage
|
||||
* shape where a position held a Measurement directly (no child layer);
|
||||
* new code never produces that shape but old serialized state may.
|
||||
*/
|
||||
getFlattenedOutput(options = {}) {
|
||||
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
||||
const out = {};
|
||||
Object.entries(this.measurements).forEach(([type, variants]) => {
|
||||
Object.entries(variants).forEach(([variant, positions]) => {
|
||||
Object.entries(positions).forEach(([position, entry]) => {
|
||||
// Legacy single series
|
||||
// Legacy single series (no childId layer)
|
||||
if (entry?.getCurrentValue) {
|
||||
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
||||
return;
|
||||
}
|
||||
// Child-bucketed series
|
||||
// Child-bucketed series — ALWAYS the case for new writes,
|
||||
// including the implicit 'default' bucket when no .child() is
|
||||
// used. The flat key carries the childId.
|
||||
if (entry && typeof entry === 'object') {
|
||||
Object.entries(entry).forEach(([childId, m]) => {
|
||||
if (m?.getCurrentValue) {
|
||||
|
||||
@@ -193,8 +193,13 @@ class AssetMenu {
|
||||
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
||||
}
|
||||
|
||||
// Non-dispatching populate (matches the wireEvents version). The
|
||||
// load path below explicitly walks supplier -> type -> model ->
|
||||
// unit in order using saved node.* values, so auto-dispatched
|
||||
// change events (which previously cascaded through wireEvents'
|
||||
// listeners and double-populated everything) are no longer needed.
|
||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||
const previous = selectEl.value;
|
||||
if (!selectEl) return;
|
||||
const mapper = typeof mapFn === 'function'
|
||||
? mapFn
|
||||
: (value) => ({ value, label: value });
|
||||
@@ -227,9 +232,6 @@ class AssetMenu {
|
||||
} else {
|
||||
selectEl.value = '';
|
||||
}
|
||||
if (selectEl.value !== previous) {
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
const categoryKey = resolveCategoryKey();
|
||||
@@ -305,6 +307,28 @@ class AssetMenu {
|
||||
getEventInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset event wiring for ${nodeName}
|
||||
//
|
||||
// The supplier -> type -> model -> unit chain is a strict downward
|
||||
// cascade: each select rebuilds the next based on the currently
|
||||
// selected value above it. Two earlier bugs in this code:
|
||||
//
|
||||
// 1. populate() auto-dispatched a synthetic 'change' event whenever
|
||||
// the value of the rebuilt select differed from before the
|
||||
// rebuild. That triggered the *child* select's listener mid-way
|
||||
// through the *parent* listener, which then continued and
|
||||
// blindly overwrote the child select with empty content. Net
|
||||
// effect: model dropdown showed 'Awaiting Type Selection' even
|
||||
// though a type was clearly selected.
|
||||
//
|
||||
// 2. Each downstream wipe ran unconditionally inside the parent
|
||||
// handler, instead of being driven by the actual current value
|
||||
// of the child select.
|
||||
//
|
||||
// Fix: populate() no longer dispatches change. Cascade is explicit
|
||||
// via cascadeFromSupplier() / cascadeFromType() / cascadeFromModel()
|
||||
// which are called from each handler. The same helpers run on
|
||||
// initial load so behaviour is identical whether the user picked the
|
||||
// value or it came from a saved node.
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||
const categories = menuAsset.categories || {};
|
||||
@@ -316,11 +340,17 @@ class AssetMenu {
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
|
||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||
const previous = selectEl.value;
|
||||
// populate(): rebuild a <select> with a placeholder + items.
|
||||
// No change-event dispatch — cascading is done explicitly by the
|
||||
// caller via cascadeFrom*() so the order of operations is
|
||||
// predictable.
|
||||
function populate(selectEl, items, selectedValue, mapFn, placeholderText) {
|
||||
if (!selectEl) return;
|
||||
if (!Array.isArray(items)) items = [];
|
||||
if (!placeholderText) placeholderText = 'Select...';
|
||||
const mapper = typeof mapFn === 'function'
|
||||
? mapFn
|
||||
: (value) => ({ value, label: value });
|
||||
: (value) => ({ value: value, label: value });
|
||||
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
@@ -331,11 +361,9 @@ class AssetMenu {
|
||||
placeholder.selected = true;
|
||||
selectEl.appendChild(placeholder);
|
||||
|
||||
items.forEach((item) => {
|
||||
items.forEach(function (item) {
|
||||
const option = mapper(item);
|
||||
if (!option || typeof option.value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!option || typeof option.value === 'undefined') return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option.value;
|
||||
opt.textContent = option.label;
|
||||
@@ -344,111 +372,112 @@ class AssetMenu {
|
||||
|
||||
if (selectedValue) {
|
||||
selectEl.value = selectedValue;
|
||||
if (!selectEl.value) {
|
||||
selectEl.value = '';
|
||||
}
|
||||
if (!selectEl.value) selectEl.value = '';
|
||||
} else {
|
||||
selectEl.value = '';
|
||||
}
|
||||
if (selectEl.value !== previous) {
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
const resolveCategoryKey = () => {
|
||||
if (node.softwareType && categories[node.softwareType]) {
|
||||
return node.softwareType;
|
||||
}
|
||||
if (node.category && categories[node.category]) {
|
||||
return node.category;
|
||||
}
|
||||
function resolveCategoryKey() {
|
||||
if (node.softwareType && categories[node.softwareType]) return node.softwareType;
|
||||
if (node.category && categories[node.category]) return node.category;
|
||||
return defaultCategory;
|
||||
};
|
||||
|
||||
const getActiveCategory = () => {
|
||||
}
|
||||
function getActiveCategory() {
|
||||
const key = resolveCategoryKey();
|
||||
return key ? categories[key] : null;
|
||||
};
|
||||
}
|
||||
|
||||
node.category = resolveCategoryKey();
|
||||
|
||||
elems.supplier.addEventListener('change', () => {
|
||||
const category = getActiveCategory();
|
||||
const supplier = category
|
||||
? category.suppliers.find(
|
||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
||||
)
|
||||
: null;
|
||||
// Lookup helpers — read from the *currently selected* values in the
|
||||
// DOM, not from node.* (which may not yet be in sync).
|
||||
function findSupplier() {
|
||||
const cat = getActiveCategory();
|
||||
if (!cat || !Array.isArray(cat.suppliers)) return null;
|
||||
const id = String(elems.supplier.value);
|
||||
return cat.suppliers.find(function (s) {
|
||||
return String(s.id || s.name) === id;
|
||||
}) || null;
|
||||
}
|
||||
function findType(supplier) {
|
||||
if (!supplier || !Array.isArray(supplier.types)) return null;
|
||||
const id = String(elems.type.value);
|
||||
return supplier.types.find(function (t) {
|
||||
return String(t.id || t.name) === id;
|
||||
}) || null;
|
||||
}
|
||||
function findModel(type) {
|
||||
if (!type || !Array.isArray(type.models)) return null;
|
||||
const id = String(elems.model.value);
|
||||
return type.models.find(function (m) {
|
||||
return String(m.id || m.name) === id;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
// === Cascade rebuild functions ==========================
|
||||
// Each one rebuilds the dropdown for the *level it owns* plus all
|
||||
// levels below it, using the current values in the DOM. Called by
|
||||
// the corresponding change handler AND by initial load so both
|
||||
// paths produce identical state.
|
||||
|
||||
function cascadeFromSupplier() {
|
||||
const supplier = findSupplier();
|
||||
const types = supplier ? supplier.types : [];
|
||||
populate(
|
||||
elems.type,
|
||||
types,
|
||||
node.assetType,
|
||||
(type) => ({ value: type.id || type.name, label: type.name }),
|
||||
function (t) { return { value: t.id || t.name, label: t.name }; },
|
||||
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
||||
);
|
||||
node.modelMetadata = null;
|
||||
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
|
||||
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
|
||||
});
|
||||
// After repopulating type, propagate down. cascadeFromType()
|
||||
// will read the new elems.type.value (which was set by populate
|
||||
// to either the saved node.assetType or '') and rebuild model.
|
||||
cascadeFromType();
|
||||
}
|
||||
|
||||
elems.type.addEventListener('change', () => {
|
||||
const category = getActiveCategory();
|
||||
const supplier = category
|
||||
? category.suppliers.find(
|
||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
||||
)
|
||||
: null;
|
||||
const type = supplier
|
||||
? supplier.types.find(
|
||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
||||
)
|
||||
: null;
|
||||
function cascadeFromType() {
|
||||
const supplier = findSupplier();
|
||||
const type = findType(supplier);
|
||||
const models = type ? type.models : [];
|
||||
populate(
|
||||
elems.model,
|
||||
models,
|
||||
node.model,
|
||||
(model) => ({ value: model.id || model.name, label: model.name }),
|
||||
function (m) { return { value: m.id || m.name, label: m.name }; },
|
||||
type ? 'Select...' : 'Awaiting Type Selection'
|
||||
);
|
||||
node.modelMetadata = null;
|
||||
populate(
|
||||
elems.unit,
|
||||
[],
|
||||
'',
|
||||
undefined,
|
||||
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
||||
);
|
||||
});
|
||||
cascadeFromModel();
|
||||
}
|
||||
|
||||
elems.model.addEventListener('change', () => {
|
||||
const category = getActiveCategory();
|
||||
const supplier = category
|
||||
? category.suppliers.find(
|
||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
||||
)
|
||||
: null;
|
||||
const type = supplier
|
||||
? supplier.types.find(
|
||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
||||
)
|
||||
: null;
|
||||
const model = type
|
||||
? type.models.find(
|
||||
(item) => String(item.id || item.name) === String(elems.model.value)
|
||||
)
|
||||
: null;
|
||||
function cascadeFromModel() {
|
||||
const supplier = findSupplier();
|
||||
const type = findType(supplier);
|
||||
const model = findModel(type);
|
||||
node.modelMetadata = model;
|
||||
node.modelName = model ? model.name : '';
|
||||
populate(
|
||||
elems.unit,
|
||||
model ? model.units || [] : [],
|
||||
model ? (model.units || []) : [],
|
||||
node.unit,
|
||||
(unit) => ({ value: unit, label: unit }),
|
||||
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
||||
function (u) { return { value: u, label: u }; },
|
||||
model ? 'Select...' : (type ? 'Awaiting Model Selection' : 'Awaiting Type Selection')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
elems.supplier.addEventListener('change', cascadeFromSupplier);
|
||||
elems.type.addEventListener('change', cascadeFromType);
|
||||
elems.model.addEventListener('change', cascadeFromModel);
|
||||
|
||||
// Expose the cascades so loadData() (or future code) can re-run
|
||||
// them after async data arrives without duplicating logic.
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu._cascade = {
|
||||
fromSupplier: cascadeFromSupplier,
|
||||
fromType: cascadeFromType,
|
||||
fromModel: cascadeFromModel,
|
||||
};
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -68,6 +68,13 @@ const Interpolation = require('./interpolation');
|
||||
class Predict {
|
||||
constructor(config = {}) {
|
||||
|
||||
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
||||
// mutates the input config to drop unknown keys, which would remove
|
||||
// shareInputsFrom because it's not in predictConfig.json's schema).
|
||||
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
|
||||
? config.shareInputsFrom
|
||||
: null;
|
||||
|
||||
// Initialize dependencies
|
||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||
this.configUtils = new ConfigUtils(defaultConfig);
|
||||
@@ -107,8 +114,29 @@ class Predict {
|
||||
this.calculationPoints = this.config.normalization.parameters.curvePoints;
|
||||
this.interpolationType = this.config.interpolation.type;
|
||||
|
||||
// Load curve if provided
|
||||
if (config.curve) {
|
||||
// Load curve if provided.
|
||||
// shareInputsFrom: an existing Predict instance whose pre-built input
|
||||
// curves and splines we adopt by reference. Used to create a parallel
|
||||
// "view" of the same source curves (e.g. an MGC group-scope predict
|
||||
// that mirrors a pump's individual predict). Per-instance state —
|
||||
// currentF / currentX / currentFxyCurve / currentFxySplines /
|
||||
// currentFxyY/X Min/Max / outputY — stays freshly initialised so the
|
||||
// two views have independent operating points. Curve mutations on the
|
||||
// source via updateCurve() are propagated through the source's
|
||||
// "curveUpdated" emitter (see updateCurve below).
|
||||
if (_sharedSource) {
|
||||
this._adoptInputsFrom(_sharedSource);
|
||||
this._sharedInputsSource = _sharedSource;
|
||||
this._sharedInputsHandler = (newCurve) => {
|
||||
this._adoptInputsFrom(this._sharedInputsSource);
|
||||
// Keep our currentF in range; constrain re-uses the new fValues.
|
||||
this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max);
|
||||
};
|
||||
this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler);
|
||||
// Initialise our own operating point to the source's min, same as
|
||||
// the standard buildAllFxyCurves flow does at end of curve load.
|
||||
this.fDimension = this.fValues.min;
|
||||
} else if (config.curve) {
|
||||
this.inputCurveData = config.curve;
|
||||
} else {
|
||||
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
|
||||
@@ -117,6 +145,31 @@ class Predict {
|
||||
|
||||
}
|
||||
|
||||
// Adopt another Predict's input curves and splines by reference. Used by
|
||||
// the shareInputsFrom constructor option and by the curveUpdated emitter
|
||||
// handler to re-sync after the source's curves change. Does NOT touch
|
||||
// per-instance state (currentF, currentX, currentFxy* etc.).
|
||||
//
|
||||
// Also copies the scalar parameters (calculationPoints, normMin/Max,
|
||||
// interpolationType) so the clone uses the SAME pointsCount the source
|
||||
// built fSplines with — otherwise buildSingleFxyCurve can iterate past
|
||||
// the end of the shared fSplines.
|
||||
_adoptInputsFrom(source) {
|
||||
this.inputCurve = source.inputCurve;
|
||||
this.normalizedCurve = source.normalizedCurve;
|
||||
this.calculatedCurve = source.calculatedCurve;
|
||||
this.fCurve = source.fCurve;
|
||||
this.fSplines = source.fSplines;
|
||||
this.normalizedSplines = source.normalizedSplines;
|
||||
this.xValues = source.xValues;
|
||||
this.fValues = source.fValues;
|
||||
this.yValues = source.yValues;
|
||||
this.calculationPoints = source.calculationPoints;
|
||||
this.normMin = source.normMin;
|
||||
this.normMax = source.normMax;
|
||||
this.interpolationType = source.interpolationType;
|
||||
}
|
||||
|
||||
// Improved function to get a local peak in an array by starting in the middle.
|
||||
// It also handles the case of a tie by preferring the left side (arbitrary choice)
|
||||
// when array[start] == leftValue or array[start] == rightValue.
|
||||
@@ -348,6 +401,9 @@ class Predict {
|
||||
|
||||
this.buildAllFxyCurves(validatedCurve);
|
||||
|
||||
// Notify shared-input clones (see shareInputsFrom in the constructor).
|
||||
// They re-adopt our inputs and clamp their own operating point.
|
||||
this.emitter.emit('curveUpdated', validatedCurve);
|
||||
}
|
||||
|
||||
constrain(value,min,max) {
|
||||
|
||||
@@ -66,6 +66,32 @@ class state{
|
||||
}
|
||||
|
||||
if (this.stateManager.getCurrentState() !== "operational") {
|
||||
// 'accelerating' / 'decelerating' here is post-abort residue —
|
||||
// the previous moveTo was aborted (e.g. MGC's per-tick
|
||||
// abortActiveMovements) and the catch block intentionally
|
||||
// doesn't auto-return to operational (avoids a bounce loop).
|
||||
// BUT a new setpoint just arrived, so there's nothing for the
|
||||
// anti-bounce policy to protect: the caller IS asking for a
|
||||
// move. Fall through to operational and execute it. Without
|
||||
// this the FSM gets parked, all subsequent setpoints land in
|
||||
// delayedMove which never fires, and currentPosition freezes —
|
||||
// see test/integration/abort-deadlock.integration.test.js for
|
||||
// the exact deadlock scenario.
|
||||
const movementResidueStates = ['accelerating', 'decelerating'];
|
||||
if (movementResidueStates.includes(this.stateManager.getCurrentState())) {
|
||||
this.logger.debug(`moveTo(${targetPosition}) arrived while parked in '${this.stateManager.getCurrentState()}' (post-abort). Returning to operational to service the new setpoint.`);
|
||||
try {
|
||||
await this.transitionToState("operational");
|
||||
} catch (e) {
|
||||
this.logger.warn(`Could not transition out of '${this.stateManager.getCurrentState()}': ${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
// Fall through — state is now operational, proceed with new move.
|
||||
} else {
|
||||
// Genuine non-operational state (starting, warmingup, stopping,
|
||||
// coolingdown, idle, off, emergencystop, maintenance) — these
|
||||
// are sequence steps the caller can't legitimately interrupt
|
||||
// with a setpoint. Save for later, exactly as before.
|
||||
if (this.config.mode.current === "auto") {
|
||||
this.delayedMove = targetPosition;
|
||||
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
|
||||
@@ -73,9 +99,9 @@ class state{
|
||||
else{
|
||||
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
|
||||
}
|
||||
//return early
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
try {
|
||||
@@ -85,15 +111,46 @@ class state{
|
||||
this.emitter.emit("movementComplete", { position: targetPosition });
|
||||
await this.transitionToState("operational");
|
||||
} catch (error) {
|
||||
// Abort path: only return to 'operational' when explicitly requested
|
||||
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
|
||||
// demand-update aborts must NOT auto-transition — doing so causes a
|
||||
// bounce loop where every tick aborts → operational → new move →
|
||||
// abort → operational → ... and the pump never reaches its setpoint.
|
||||
const msg = typeof error === 'string' ? error : error?.message;
|
||||
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
|
||||
if (this._returnToOperationalOnAbort) {
|
||||
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
|
||||
try {
|
||||
await this.transitionToState("operational");
|
||||
} catch (e) {
|
||||
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
|
||||
}
|
||||
this._returnToOperationalOnAbort = false;
|
||||
this.emitter.emit("movementAborted", { position: targetPosition });
|
||||
} else {
|
||||
this.logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------- State Transition Methods -------- //
|
||||
|
||||
abortCurrentMovement(reason = "group override") {
|
||||
/**
|
||||
* @param {string} reason - human-readable abort reason
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.returnToOperational=false] - when true the FSM
|
||||
* transitions back to 'operational' after the abort so a subsequent
|
||||
* shutdown/emergency-stop sequence can proceed. Set to false (default)
|
||||
* for routine demand updates where the caller will send a new movement
|
||||
* immediately — auto-transitioning would cause a bounce loop.
|
||||
*/
|
||||
abortCurrentMovement(reason = "group override", options = {}) {
|
||||
if (this.abortController && !this.abortController.signal.aborted) {
|
||||
this.logger.warn(`Aborting movement: ${reason}`);
|
||||
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
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