Files
machineGroupControl/test/integration/per-pump-ctrl-fanout.integration.test.js
znetsixe f18f3cc673 feat(mgc-dashboard): -1 OFF sentinel on per-pump % control chart
fn_chart_pump_a/b/c now emit -1 on the ctrl output when the cached pump
state is off/idle/maintenance, instead of the residual ctrl% (which would
sit at 0 and be indistinguishable from a pump genuinely running at 0%).
ui_chart_pumps_ctrl ymin set to -5 so the OFF rail is visible below the
0-100 band.

Adds test/integration/per-pump-ctrl-fanout.integration.test.js covering
both chart outputs of all three pumps in populated (running), OFF
(off/idle/maintenance), and degraded (missing state/ctrl/flow, pre-tick,
NaN, ctrl-only delta) states per .claude/rules/output-coverage.md. Updates
test/_output-manifest.md to document the previously-undocumented per-pump
fan-out functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:16:50 +02:00

118 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 0100 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 });
});
}