118 lines
5.2 KiB
JavaScript
118 lines
5.2 KiB
JavaScript
|
|
// Output-coverage tests for examples/02-Dashboard.json :: fn_chart_pump_a/b/c.
|
|||
|
|
// These per-pump fan-out functions feed two charts:
|
|||
|
|
// output 0 → ui_chart_per_pump_flow (topic = 'Pump A/B/C', payload = flow m³/h)
|
|||
|
|
// output 1 → ui_chart_pumps_ctrl (topic = 'Pump A/B/C', payload = ctrl %)
|
|||
|
|
// The ctrl output carries a -1 OFF sentinel: when the pump is off / idle /
|
|||
|
|
// maintenance it is not running, so we plot -1 (below the 0–100 band) to give
|
|||
|
|
// the chart a clear OFF rail distinct from a pump genuinely running at 0%.
|
|||
|
|
// Every output is exercised in populated AND degraded states per
|
|||
|
|
// .claude/rules/output-coverage.md.
|
|||
|
|
|
|||
|
|
const test = require('node:test');
|
|||
|
|
const assert = require('node:assert/strict');
|
|||
|
|
const fs = require('node:fs');
|
|||
|
|
const path = require('node:path');
|
|||
|
|
|
|||
|
|
const flow = JSON.parse(fs.readFileSync(
|
|||
|
|
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
|||
|
|
|
|||
|
|
const PUMPS = [
|
|||
|
|
{ id: 'fn_chart_pump_a', topic: 'Pump A' },
|
|||
|
|
{ id: 'fn_chart_pump_b', topic: 'Pump B' },
|
|||
|
|
{ id: 'fn_chart_pump_c', topic: 'Pump C' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const FLOW = 0; // output index → ui_chart_per_pump_flow
|
|||
|
|
const CTRL = 1; // output index → ui_chart_pumps_ctrl
|
|||
|
|
|
|||
|
|
// Each fan-out caches Port 0 deltas in context('c'). Build a fresh runner per
|
|||
|
|
// test so state never leaks between cases.
|
|||
|
|
function makeRunner(node) {
|
|||
|
|
let store = {};
|
|||
|
|
const context = { get: (k) => store[k], set: (k, v) => { store[k] = v; } };
|
|||
|
|
const body = new Function('msg', 'context', node.func);
|
|||
|
|
return (payload) => body({ payload }, context);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A populated downstream-flow key uses the 4-segment MeasurementContainer
|
|||
|
|
// convention the function matches with find('flow.predicted.downstream.').
|
|||
|
|
const flowKey = (id) => `flow.predicted.downstream.${id}`;
|
|||
|
|
|
|||
|
|
test('every per-pump fan-out has exactly 2 outputs wired to flow + ctrl charts', () => {
|
|||
|
|
for (const { id } of PUMPS) {
|
|||
|
|
const node = flow.find(n => n.id === id);
|
|||
|
|
assert.ok(node, `${id} present in flow`);
|
|||
|
|
assert.equal(node.outputs, 2, `${id} outputs`);
|
|||
|
|
assert.equal(node.wires.length, 2, `${id} wires`);
|
|||
|
|
assert.deepEqual(node.wires[FLOW], ['ui_chart_per_pump_flow'], `${id} flow wire`);
|
|||
|
|
assert.deepEqual(node.wires[CTRL], ['ui_chart_pumps_ctrl'], `${id} ctrl wire`);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('ui_chart_pumps_ctrl ymin is -5 so the OFF sentinel (-1) is visible', () => {
|
|||
|
|
const chart = flow.find(n => n.id === 'ui_chart_pumps_ctrl');
|
|||
|
|
assert.ok(chart, 'ui_chart_pumps_ctrl present');
|
|||
|
|
assert.equal(chart.ymin, '-5');
|
|||
|
|
assert.equal(chart.ymax, '100');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const { id, topic } of PUMPS) {
|
|||
|
|
test(`${id}: populated running state → flow + ctrl carry real numbers`, () => {
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
const out = run({ [flowKey(id)]: 478 / 3, ctrl: 72, state: 'operational' });
|
|||
|
|
assert.deepEqual(out[FLOW], { topic, payload: 478 / 3 });
|
|||
|
|
assert.deepEqual(out[CTRL], { topic, payload: 72 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const offState of ['off', 'idle', 'maintenance']) {
|
|||
|
|
test(`${id}: state '${offState}' → ctrl emits -1 sentinel (even if ctrl% is 0/stale)`, () => {
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
// ctrl stale at 0 (or any residual) must be overridden by the sentinel.
|
|||
|
|
const out = run({ [flowKey(id)]: 0, ctrl: 0, state: offState });
|
|||
|
|
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test(`${id}: degraded — no state, ctrl missing → ctrl output is null (drop, never payload:null)`, () => {
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
const out = run({ [flowKey(id)]: 50 });
|
|||
|
|
assert.equal(out[CTRL], null, 'ctrl must drop when no state and no ctrl');
|
|||
|
|
// flow still present.
|
|||
|
|
assert.deepEqual(out[FLOW], { topic, payload: 50 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test(`${id}: degraded — no flow key → flow output is null (drop)`, () => {
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
const out = run({ ctrl: 40, state: 'operational' });
|
|||
|
|
assert.equal(out[FLOW], null, 'flow must drop when source key missing');
|
|||
|
|
assert.deepEqual(out[CTRL], { topic, payload: 40 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test(`${id}: pre-first-tick — empty payload → both outputs null, no payload:null`, () => {
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
const out = run({});
|
|||
|
|
assert.equal(out[FLOW], null);
|
|||
|
|
assert.equal(out[CTRL], null);
|
|||
|
|
for (const m of out) {
|
|||
|
|
if (m && Object.prototype.hasOwnProperty.call(m, 'payload')) {
|
|||
|
|
assert.notEqual(m.payload, null, `${id} emitted { payload: null }`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test(`${id}: running ctrl with NaN/null ctrl value → ctrl drops (no payload:null)`, () => {
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
assert.equal(run({ [flowKey(id)]: 10, ctrl: null, state: 'operational' })[CTRL], null);
|
|||
|
|
assert.equal(run({ [flowKey(id)]: 10, ctrl: NaN, state: 'operational' })[CTRL], null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test(`${id}: delta-cache holds last state so a ctrl-only delta still rails OFF`, () => {
|
|||
|
|
// Realistic: pump first reports state:'off', then a later tick carries only
|
|||
|
|
// a ctrl delta (no state). The cached 'off' must keep the sentinel engaged.
|
|||
|
|
const run = makeRunner(flow.find(n => n.id === id));
|
|||
|
|
run({ state: 'off', ctrl: 0 });
|
|||
|
|
const out = run({ ctrl: 5 }); // ctrl-only delta; cached state still 'off'
|
|||
|
|
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
|||
|
|
});
|
|||
|
|
}
|