Phase 1 wave 1: domain + nodered + stats infra (additive)

Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:

- src/domain/UnitPolicy.js     — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js    — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js   — standardised {level, flags, message, source}
- src/nodered/statusBadge.js   — compose / error / idle / byState / text helpers
- src/stats/index.js           — mean / stdDev / median / mad / lerp

All additive — no existing exports change shape.
56 unit tests pass under node:test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-10 18:27:29 +02:00
parent 9a998191cd
commit 47faf94048
12 changed files with 1374 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('events');
const ChildRouter = require('../../src/domain/ChildRouter');
// ── helpers ────────────────────────────────────────────────────────
function makeDomain() {
const logs = [];
return {
logger: {
debug: (...a) => logs.push(['debug', ...a]),
info: (...a) => logs.push(['info', ...a]),
warn: (...a) => logs.push(['warn', ...a]),
error: (...a) => logs.push(['error', ...a]),
},
_logs: logs,
};
}
function makeChild({ id = 'c1', name = id, softwareType = 'measurement' } = {}) {
return {
config: {
general: { id, name },
functionality: { softwareType },
asset: { type: 'pressure' },
},
measurements: { emitter: new EventEmitter() },
};
}
function emitMeasured(child, type, position, value, extra = {}) {
child.measurements.emitter.emit(`${type}.measured.${position}`, { value, ...extra });
}
function emitPredicted(child, type, position, value, extra = {}) {
child.measurements.emitter.emit(`${type}.predicted.${position}`, { value, ...extra });
}
// ── tests ─────────────────────────────────────────────────────────
test('onRegister fires for the matching softwareType', () => {
const domain = makeDomain();
const router = new ChildRouter(domain);
const seen = [];
router.onRegister('measurement', (child, st) => seen.push({ id: child.config.general.id, st }));
const ch = makeChild({ id: 'm1' });
router.dispatchRegister(ch, 'measurement');
assert.equal(seen.length, 1);
assert.equal(seen[0].id, 'm1');
assert.equal(seen[0].st, 'measurement');
});
test('onMeasurement with full filter only fires for matching events', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
(data, child) => hits.push({ v: data.value, id: child.config.general.id }));
const ch = makeChild({ id: 'p-up' });
router.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 100);
emitMeasured(ch, 'pressure', 'downstream', 200); // ignored: wrong position
emitMeasured(ch, 'flow', 'upstream', 5); // ignored: wrong type
emitPredicted(ch, 'pressure', 'upstream', 999); // ignored: wrong variant
assert.deepEqual(hits, [{ v: 100, id: 'p-up' }]);
});
test('onMeasurement without position filter fires for all positions of the type', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
router.onMeasurement('measurement', { type: 'pressure' },
(data) => hits.push(data.value));
const ch = makeChild();
router.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 1);
emitMeasured(ch, 'pressure', 'downstream', 2);
emitMeasured(ch, 'pressure', 'atequipment', 3);
emitMeasured(ch, 'flow', 'upstream', 99); // ignored: wrong type
emitPredicted(ch, 'pressure', 'upstream', 50); // ignored: wrong variant
assert.deepEqual(hits.sort(), [1, 2, 3]);
});
test('onPrediction works analogously to onMeasurement', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
router.onPrediction('machinegroup', { type: 'flow', position: 'downstream' },
(data) => hits.push(data.value));
const ch = makeChild({ softwareType: 'machinegroupcontrol' });
router.dispatchRegister(ch, 'machinegroupcontrol');
emitPredicted(ch, 'flow', 'downstream', 42);
emitPredicted(ch, 'flow', 'upstream', 7); // ignored: wrong position
emitMeasured(ch, 'flow', 'downstream', 99); // ignored: wrong variant
assert.deepEqual(hits, [42]);
});
test('software-type alias resolution: onRegister("machine") matches softwareType="rotatingmachine"', () => {
const router = new ChildRouter(makeDomain());
const seen = [];
router.onRegister('machine', (child) => seen.push(child.config.general.id));
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
router.dispatchRegister(rm, 'rotatingmachine');
assert.deepEqual(seen, ['rm-1']);
});
test('alias resolution also flows through measurement subscriptions', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
// Declare with the canonical 'machine' alias.
router.onMeasurement('machine', { type: 'flow', position: 'downstream' },
(data) => hits.push(data.value));
// Child reports the raw, non-canonical softwareType.
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
router.dispatchRegister(rm, 'rotatingmachine');
emitMeasured(rm, 'flow', 'downstream', 17);
assert.deepEqual(hits, [17]);
});
test('tearDown removes listeners — re-emitting after tearDown does not invoke handler', () => {
const router = new ChildRouter(makeDomain());
const hits = [];
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
(data) => hits.push(['concrete', data.value]));
router.onMeasurement('measurement', { type: 'pressure' }, // wildcard branch
(data) => hits.push(['wild', data.value]));
const ch = makeChild();
router.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 1);
assert.equal(hits.length, 2);
router.tearDown();
emitMeasured(ch, 'pressure', 'upstream', 2);
emitMeasured(ch, 'pressure', 'downstream', 3);
assert.equal(hits.length, 2, 'no further hits after tearDown');
// Original emit should be restored after teardown — sanity-check it still works
// for unrelated listeners on the same emitter.
let other = 0;
ch.measurements.emitter.on('flow.measured.upstream', () => other++);
emitMeasured(ch, 'flow', 'upstream', 9);
assert.equal(other, 1);
});
test('multiple onMeasurement subscriptions for same softwareType all fire', () => {
const router = new ChildRouter(makeDomain());
const a = []; const b = []; const c = [];
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
(d) => a.push(d.value));
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
(d) => b.push(d.value)); // duplicate concrete sub
router.onMeasurement('measurement', { type: 'pressure' },
(d) => c.push(d.value)); // wildcard-position sub
const ch = makeChild();
router.dispatchRegister(ch, 'measurement');
emitMeasured(ch, 'pressure', 'upstream', 7);
assert.deepEqual(a, [7]);
assert.deepEqual(b, [7]);
assert.deepEqual(c, [7]);
});
test('chainable API returns the router instance', () => {
const router = new ChildRouter(makeDomain());
const r = router
.onRegister('measurement', () => {})
.onMeasurement('measurement', { type: 'flow' }, () => {})
.onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {});
assert.equal(r, router);
});

View File

@@ -0,0 +1,103 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const HealthStatus = require('../../src/domain/HealthStatus');
test('ok() returns the canonical zero-level shape', () => {
const h = HealthStatus.ok();
assert.strictEqual(h.level, 0);
assert.deepStrictEqual(h.flags, []);
assert.strictEqual(h.message, 'nominal');
assert.strictEqual(h.source, null);
assert.ok(Object.isFrozen(h));
assert.ok(Object.isFrozen(h.flags));
});
test('ok(message, source) carries through optional args', () => {
const h = HealthStatus.ok('all good', 'aggregator');
assert.strictEqual(h.level, 0);
assert.strictEqual(h.message, 'all good');
assert.strictEqual(h.source, 'aggregator');
});
test('degraded(2, [...], msg, src) returns the right frozen shape', () => {
const h = HealthStatus.degraded(2, ['x'], 'msg', 'src');
assert.strictEqual(h.level, 2);
assert.deepStrictEqual(h.flags, ['x']);
assert.strictEqual(h.message, 'msg');
assert.strictEqual(h.source, 'src');
assert.ok(Object.isFrozen(h));
assert.ok(Object.isFrozen(h.flags));
// Mutation attempts must not change the frozen flags array.
assert.throws(() => { h.flags.push('y'); }, TypeError);
});
test('degraded clamps out-of-range levels (high)', () => {
const h = HealthStatus.degraded(7, ['hot'], 'too high');
assert.strictEqual(h.level, 3);
});
test('degraded clamps out-of-range levels (low / non-numeric)', () => {
const lo = HealthStatus.degraded(0, ['lo'], 'too low');
assert.strictEqual(lo.level, 1);
const nan = HealthStatus.degraded('nope', ['n'], 'bad input');
assert.strictEqual(nan.level, 1);
});
test('degraded falls back to label-derived message when message is empty', () => {
const h = HealthStatus.degraded(2, ['x']);
assert.strictEqual(h.message, 'major');
});
test('compose([]) returns ok()', () => {
const h = HealthStatus.compose([]);
assert.strictEqual(h.level, 0);
assert.deepStrictEqual(h.flags, []);
assert.strictEqual(h.message, 'nominal');
assert.strictEqual(h.source, null);
});
test('compose merges, picking worst level + that status\'s message/source', () => {
const h = HealthStatus.compose([
HealthStatus.ok(),
HealthStatus.degraded(1, ['a'], 'a-msg', 'a-src'),
HealthStatus.degraded(2, ['b'], 'b-msg', 'b-src'),
]);
assert.strictEqual(h.level, 2);
assert.deepStrictEqual(h.flags, ['a', 'b']);
assert.strictEqual(h.message, 'b-msg');
assert.strictEqual(h.source, 'b-src');
});
test('compose ties: first worst-level status wins for message/source', () => {
const h = HealthStatus.compose([
HealthStatus.degraded(2, ['a'], 'first', 'first-src'),
HealthStatus.degraded(2, ['b'], 'second', 'second-src'),
]);
assert.strictEqual(h.level, 2);
assert.strictEqual(h.message, 'first');
assert.strictEqual(h.source, 'first-src');
});
test('compose dedupes flags across statuses', () => {
const h = HealthStatus.compose([
HealthStatus.degraded(1, ['x', 'y'], 'one'),
HealthStatus.degraded(2, ['y', 'z', 'x'], 'two'),
]);
assert.deepStrictEqual(h.flags, ['x', 'y', 'z']);
});
test('label maps 0..3 → nominal/minor/major/critical', () => {
assert.strictEqual(HealthStatus.label(0), 'nominal');
assert.strictEqual(HealthStatus.label(1), 'minor');
assert.strictEqual(HealthStatus.label(2), 'major');
assert.strictEqual(HealthStatus.label(3), 'critical');
});
test('label returns "unknown" for out-of-range levels', () => {
assert.strictEqual(HealthStatus.label(-1), 'unknown');
assert.strictEqual(HealthStatus.label(4), 'unknown');
assert.strictEqual(HealthStatus.label('x'), 'unknown');
});

View File

@@ -0,0 +1,152 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const LatestWinsGate = require('../../src/domain/LatestWinsGate');
// Helper: a deferred promise so a test can pause a dispatch and inspect
// gate state before resolving. Avoids real timers entirely.
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('single fire calls dispatch with the value', async () => {
const calls = [];
const gate = new LatestWinsGate(async (v) => { calls.push(v); });
gate.fire('a');
await gate.drain();
assert.deepEqual(calls, ['a']);
});
test('two fires while in-flight: second value runs after first settles', async () => {
const calls = [];
const gates = [deferred(), deferred()];
const started = [deferred(), deferred()];
let n = 0;
const gate = new LatestWinsGate(async (v) => {
const slot = n++;
calls.push(v);
started[slot].resolve();
await gates[slot].promise;
});
gate.fire('first');
gate.fire('second'); // parks while 'first' is in flight
await started[0].promise;
assert.deepEqual(calls, ['first']);
assert.equal(gate.size, 2);
gates[0].resolve();
await started[1].promise;
assert.deepEqual(calls, ['first', 'second']);
gates[1].resolve();
await gate.drain();
});
test('three fires back-to-back: only the last runs after the first settles', async () => {
const calls = [];
const first = deferred();
const firstStarted = deferred();
let count = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (count++ === 0) {
firstStarted.resolve();
await first.promise;
}
});
gate.fire(1);
gate.fire(2); // parked
gate.fire(3); // overwrites 2
await firstStarted.promise;
assert.deepEqual(calls, [1]);
first.resolve();
await gate.drain();
assert.deepEqual(calls, [1, 3]);
});
test('drain() resolves only after all queued work has run', async () => {
const calls = [];
const d = deferred();
let started = 0;
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (started++ === 0) await d.promise;
});
gate.fire('x');
gate.fire('y');
let drained = false;
const p = gate.drain().then(() => { drained = true; });
// While first is paused, drain must not have resolved yet.
await Promise.resolve();
await Promise.resolve();
assert.equal(drained, false);
d.resolve();
await p;
assert.deepEqual(calls, ['x', 'y']);
assert.equal(drained, true);
});
test('error in dispatch does not prevent subsequent fire from working', async () => {
const calls = [];
let throwNext = true;
const errors = [];
const logger = { error: (e) => errors.push(e) };
const gate = new LatestWinsGate(async (v) => {
calls.push(v);
if (throwNext) {
throwNext = false;
throw new Error('boom');
}
}, { logger });
gate.fire('a');
await gate.drain();
assert.equal(calls.length, 1);
assert.equal(errors.length, 1);
assert.match(errors[0].message, /boom/);
assert.ok(gate.lastError instanceof Error);
// Gate must still accept further work.
gate.fire('b');
await gate.drain();
assert.deepEqual(calls, ['a', 'b']);
});
test('error is recorded on lastError when no logger is supplied', async () => {
const gate = new LatestWinsGate(async () => { throw new Error('silent'); });
gate.fire('only');
await gate.drain();
assert.ok(gate.lastError instanceof Error);
assert.match(gate.lastError.message, /silent/);
});
test('size reports 0 / 1 / 2 across the lifecycle', async () => {
const d1 = deferred();
const gate = new LatestWinsGate(async () => { await d1.promise; });
assert.equal(gate.size, 0);
gate.fire('one');
// fire is sync, but _dispatch starts on a microtask. Either way the
// gate is marked in-flight synchronously.
assert.equal(gate.size, 1);
gate.fire('two'); // parked
assert.equal(gate.size, 2);
d1.resolve();
await gate.drain();
assert.equal(gate.size, 0);
});

View File

@@ -0,0 +1,145 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const UnitPolicy = require('../../src/domain/UnitPolicy.js');
function makeFakeLogger() {
const calls = { warn: [], info: [], error: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(m),
info: (m) => calls.info.push(m),
error: (m) => calls.error.push(m),
debug: (m) => calls.debug.push(m),
};
}
const baseSpec = {
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
curve: { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' },
};
test('declare returns a policy whose canonical/output match the input', () => {
const policy = UnitPolicy.declare(baseSpec);
assert.equal(policy.canonical('flow'), 'm3/s');
assert.equal(policy.canonical('pressure'), 'Pa');
assert.equal(policy.canonical('power'), 'W');
assert.equal(policy.canonical('temperature'), 'K');
assert.equal(policy.output('flow'), 'm3/h');
assert.equal(policy.output('pressure'), 'mbar');
assert.equal(policy.output('power'), 'kW');
assert.equal(policy.output('temperature'), 'C');
assert.equal(policy.curve('flow'), 'm3/h');
assert.equal(policy.curve('control'), '%');
});
test('declare throws when canonical or output is missing', () => {
assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/);
assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/);
});
test('resolve returns the candidate when it matches the expected measure', () => {
const logger = makeFakeLogger();
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s', 'general.flow'), 'm3/h');
assert.equal(policy.resolve('bar', 'pressure', 'mbar', 'asset.pressure'), 'bar');
assert.equal(policy.resolve('kW', 'power', 'W', 'asset.power'), 'kW');
// No warnings on valid inputs.
assert.equal(logger.calls.warn.length, 0);
});
test('resolve falls back when given an invalid candidate, warns once', () => {
const logger = makeFakeLogger();
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
// Wrong measure family (mass unit declared as a flow unit).
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
// Same call again — the warn-once memo must suppress.
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
assert.equal(logger.calls.warn.length, 1);
assert.match(logger.calls.warn[0], /Invalid general\.flow unit 'kg'/);
// A different invalid candidate logs a separate warning.
assert.equal(policy.resolve('not-a-unit', 'pressure', 'Pa', 'asset.pressure'), 'Pa');
assert.equal(logger.calls.warn.length, 2);
});
test('resolve falls back to the default when candidate is empty/whitespace', () => {
const policy = UnitPolicy.declare(baseSpec);
assert.equal(policy.resolve('', 'flow', 'm3/s'), 'm3/s');
assert.equal(policy.resolve(' ', 'flow', 'm3/s'), 'm3/s');
assert.equal(policy.resolve(undefined, 'flow', 'm3/s'), 'm3/s');
});
test('resolve accepts type-name shorthand as well as convert-module measure', () => {
const policy = UnitPolicy.declare(baseSpec);
// 'flow' shorthand should map to volumeFlowRate, not be passed through raw.
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s'), 'm3/h');
assert.equal(policy.resolve('m3/h', 'volumeFlowRate', 'm3/s'), 'm3/h');
});
test('convert is a no-op when from === to (still coerces to Number)', () => {
const policy = UnitPolicy.declare(baseSpec);
assert.equal(policy.convert('5', 'm3/h', 'm3/h'), 5);
assert.equal(typeof policy.convert(5, 'm3/h', 'm3/h'), 'number');
// Missing units also no-op.
assert.equal(policy.convert(7, '', 'm3/h'), 7);
assert.equal(policy.convert(7, 'm3/h', null), 7);
});
test('convert across compatible units returns the expected numeric', () => {
const policy = UnitPolicy.declare(baseSpec);
// 1 m3/s -> 3600 m3/h
assert.equal(policy.convert(1, 'm3/s', 'm3/h'), 3600);
// 1 bar -> 100000 Pa
assert.equal(policy.convert(1, 'bar', 'Pa'), 100000);
// 1 kW -> 1000 W
assert.equal(policy.convert(1, 'kW', 'W'), 1000);
});
test('convert throws when value is not finite', () => {
const policy = UnitPolicy.declare(baseSpec);
assert.throws(() => policy.convert('not-a-number', 'm3/h', 'm3/s'), /not finite/);
assert.throws(() => policy.convert(NaN, 'm3/h', 'm3/s'), /not finite/);
assert.throws(() => policy.convert(Infinity, 'm3/h', 'm3/s'), /not finite/);
});
test('containerOptions returns the exact shape consumed by MeasurementContainer', () => {
const policy = UnitPolicy.declare(baseSpec);
const opts = policy.containerOptions();
assert.deepEqual(opts.defaultUnits, baseSpec.output);
assert.deepEqual(opts.preferredUnits, baseSpec.output);
assert.deepEqual(opts.canonicalUnits, baseSpec.canonical);
assert.equal(opts.storeCanonical, true);
assert.equal(opts.strictUnitValidation, true);
assert.equal(opts.throwOnInvalidUnit, true);
assert.deepEqual(opts.requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
// Mutating the returned bag must not leak back into the policy.
opts.defaultUnits.flow = 'tampered';
opts.requireUnitForTypes.push('volume');
assert.equal(policy.output('flow'), 'm3/h');
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
});
test('containerOptions honours custom requireUnitForTypes from declare', () => {
const policy = UnitPolicy.declare({
...baseSpec,
requireUnitForTypes: ['flow', 'pressure'],
});
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure']);
});
test('containerOptions output works with a real MeasurementContainer', () => {
const { MeasurementContainer } = require('../../src/measurements/index.js');
const policy = UnitPolicy.declare(baseSpec);
const mc = new MeasurementContainer(policy.containerOptions());
// No throw on construction — proves the option bag is a valid input shape.
assert.equal(mc.storeCanonical, true);
assert.equal(mc.strictUnitValidation, true);
assert.equal(mc.throwOnInvalidUnit, true);
assert.equal(mc.canonicalUnits.flow, 'm3/s');
assert.equal(mc.defaultUnits.flow, 'm3/h');
});

View File

@@ -0,0 +1,50 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { mean, stdDev, median, mad, lerp } = require('../../src/stats');
const EPS = 1e-9;
function near(a, b, eps = EPS) {
assert.ok(Math.abs(a - b) <= eps, `expected ${a}${b} (eps ${eps})`);
}
test('mean: basic and empty', () => {
assert.equal(mean([1, 2, 3, 4]), 2.5);
assert.equal(mean([]), 0);
});
test('stdDev: zero-variance, classic sample, single-element, empty', () => {
assert.equal(stdDev([1, 1, 1, 1]), 0);
near(stdDev([1, 2, 3, 4, 5]), 1.5811388300841898);
assert.equal(stdDev([5]), 0);
assert.equal(stdDev([]), 0);
});
test('median: odd, even, empty', () => {
assert.equal(median([1, 2, 3, 4, 5]), 3);
assert.equal(median([1, 2, 3, 4]), 2.5);
assert.equal(median([]), 0);
});
test('mad: hand-checked sample and constant array', () => {
// [1,1,2,2,4,6,9] -> median 2 -> |dev| [1,1,0,0,2,4,7] -> sorted
// [0,0,1,1,2,4,7] -> mad = 1.
assert.equal(mad([1, 1, 2, 2, 4, 6, 9]), 1);
assert.equal(mad([5, 5, 5]), 0);
assert.equal(mad([]), 0);
});
test('lerp: in-range mapping and degenerate pass-through', () => {
assert.equal(lerp(2, 0, 4, 0, 100), 50);
assert.equal(lerp(2, 0, 0, 0, 100), 2);
// iMin > iMax also degenerate (defensive against swapped bounds).
assert.equal(lerp(2, 4, 0, 0, 100), 2);
});
test('lerp: float arithmetic stays within epsilon', () => {
near(lerp(0.1, 0, 1, 0, 10), 1);
near(lerp(1 / 3, 0, 1, 0, 30), 10);
});

View File

@@ -0,0 +1,70 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { statusBadge, MAX_TEXT } = require('../../src/nodered/statusBadge');
test('compose joins parts with " | " and uses default green/dot', () => {
const badge = statusBadge.compose(['A', 'B']);
assert.deepEqual(badge, { fill: 'green', shape: 'dot', text: 'A | B' });
});
test('compose drops null/undefined/empty parts', () => {
const badge = statusBadge.compose(['A', null, 'B', undefined, '']);
assert.equal(badge.text, 'A | B');
assert.equal(badge.fill, 'green');
assert.equal(badge.shape, 'dot');
});
test('compose with empty parts and override fill returns empty text', () => {
const badge = statusBadge.compose([], { fill: 'yellow' });
assert.equal(badge.text, '');
assert.equal(badge.fill, 'yellow');
assert.equal(badge.shape, 'dot');
});
test('error returns red ring with ⚠ prefix', () => {
const badge = statusBadge.error('boom');
assert.deepEqual(badge, { fill: 'red', shape: 'ring', text: '⚠ boom' });
});
test('idle returns blue dot with ⏸ prefix', () => {
const badge = statusBadge.idle('waiting');
assert.deepEqual(badge, { fill: 'blue', shape: 'dot', text: '⏸️ waiting' });
});
test('byState returns the matching template', () => {
const map = { off: { fill: 'red', shape: 'dot', text: 'OFF' } };
const badge = statusBadge.byState(map, 'off');
assert.deepEqual(badge, { fill: 'red', shape: 'dot', text: 'OFF' });
});
test('byState returns grey "unknown state" badge when key is missing', () => {
const badge = statusBadge.byState({}, 'unknown');
assert.equal(badge.fill, 'grey');
assert.equal(badge.shape, 'ring');
assert.match(badge.text, /unknown state/);
assert.match(badge.text, /unknown/);
});
test('byState composes extra parts into the template text', () => {
const map = { run: { fill: 'green', shape: 'dot', text: 'RUN' } };
const badge = statusBadge.byState(map, 'run', { compose: ['flow=12.0', 'P=3kW'] });
assert.equal(badge.text, 'RUN | flow=12.0 | P=3kW');
});
test('text length is truncated to MAX_TEXT chars ending with …', () => {
const longInput = 'x'.repeat(200);
const badge = statusBadge.text(longInput);
assert.equal(badge.text.length, MAX_TEXT);
assert.equal(badge.text.endsWith('…'), true);
});
test('text helper defaults to green/dot and never returns null text', () => {
assert.equal(statusBadge.text(null).text, '');
assert.equal(statusBadge.text(undefined).text, '');
const badge = statusBadge.text('hi');
assert.equal(badge.fill, 'green');
assert.equal(badge.shape, 'dot');
});