Files
machineGroupControl/test/integration/dashboard-fanout.integration.test.js
znetsixe f41e319b30 test(mgc): cover fn_status_split output 17 (% of capacity); fix stale 17→18 count
The dashboard fan-out grew to 18 outputs (output 17 = '% of capacity' chart)
but dashboard-fanout.integration.test.js still asserted 17 and had no PORT
entry or coverage for output 17. Add chart_pctcap (17) with populated (State C,
flow/capMax×100) and degraded (State A → null-drop) assertions, fix the count
assertion, and add the fan-out enumeration table to _output-manifest.md per
.claude/rules/output-coverage.md.

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

252 lines
11 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_status_split.
// Exercises every output port in three states (deploy / post-setup / post-demand)
// AND verifies the per-port format contract that every downstream ui-* widget
// or chart expects. 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 fn = flow.find(n => n.id === 'fn_status_split');
function runFn(msgs) {
let ctxStore = {};
const context = {
get: (k) => ctxStore[k],
set: (k, v) => { ctxStore[k] = v; },
};
const fn_body = new Function('msg', 'context', fn.func);
return msgs.map(msg => fn_body(msg, context));
}
// Indices into the 18-output return array. Kept here as the manifest contract
// for this function — every test below references these names, never raw ints.
const PORT = {
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
text_machines: 4, text_bep_rel: 5, text_eta: 6, text_eta_peak: 7,
text_bep_abs: 8, text_ncog: 9,
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
chart_eta: 14,
raw_rows: 15, raw_passthrough: 16,
chart_pctcap: 17,
};
const initialMsg = {
payload: {
mode: 'optimalControl', scaling: 'normalized',
absDistFromPeak: 0, relDistFromPeak: 0,
flowCapacityMax: 0, flowCapacityMin: 0,
machineCount: 3, machineCountActive: 0,
},
};
const postSetupMsg = {
payload: {
atEquipment_predicted_flow: 0, downstream_predicted_flow: 0,
atEquipment_predicted_power: 0,
flowCapacityMax: 450, flowCapacityMin: 0,
machineCountActive: 0,
headerDiffPa: 110000, headerDiffMbar: 1100,
},
};
const postDemandMsg = {
payload: {
atEquipment_predicted_flow: 200,
downstream_predicted_flow: 200,
atEquipment_predicted_power: 11.4,
atEquipment_predicted_efficiency: 0.62,
// Ncog as MGC actually emits it: SUM of per-pump NCog values.
// 2 pumps each at NCog=0.6 → sum=1.2; per-pump average should display as 60.0 %.
atEquipment_predicted_Ncog: 1.2,
absDistFromPeak: 0.05, relDistFromPeak: 0.08,
flowCapacityMax: 450, machineCountActive: 2,
},
};
test('manifest: function has exactly 18 outputs and wires array matches', () => {
assert.equal(fn.outputs, 18);
assert.equal(fn.wires.length, 18);
});
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
const [out] = runFn([initialMsg]);
assert.equal(out[PORT.text_mode].payload, 'optimalControl');
assert.equal(out[PORT.text_flow].payload, '—');
assert.equal(out[PORT.text_power].payload, '—');
assert.equal(out[PORT.text_ncog].payload, '—');
assert.equal(out[PORT.text_eta].payload, '—');
});
test('State A: charts with no source data emit null msg, never { payload: null }', () => {
const [out] = runFn([initialMsg]);
// Charts 10, 12, 14 have no source data in State A → must be null (drop msg).
assert.equal(out[PORT.chart_flow], null, 'chart_flow must be null when flow missing');
assert.equal(out[PORT.chart_power], null, 'chart_power must be null when power missing');
assert.equal(out[PORT.chart_eta], null, 'chart_eta must be null when eta missing');
// For every msg-emitting chart output: payload is never literally null.
for (const idx of Object.values(PORT)) {
if (out[idx] && Object.prototype.hasOwnProperty.call(out[idx], 'payload')) {
assert.notEqual(out[idx].payload, null,
`port ${idx} emitted { payload: null } — would crash ui-chart`);
}
}
});
test('State B (post-setup, no demand): flow/power = 0, eta missing', () => {
const [, out] = runFn([initialMsg, postSetupMsg]);
assert.equal(out[PORT.text_flow].payload, '0.0 m³/h');
assert.equal(out[PORT.text_power].payload, '0.00 kW');
assert.equal(out[PORT.text_capacity].payload, '0.0 450.0 m³/h');
// η still missing → '—'
assert.equal(out[PORT.text_eta].payload, '—');
});
test('State C (post-demand): every text/chart output has real value', () => {
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
assert.equal(out[PORT.text_flow].payload, '200.0 m³/h');
assert.equal(out[PORT.text_power].payload, '11.40 kW');
assert.equal(out[PORT.text_eta].payload, '62.0 %');
// BEP abs gap: η-points dimensionless, 3 dp.
assert.equal(out[PORT.text_bep_abs].payload, '0.050');
// Charts have numeric payload.
assert.equal(out[PORT.chart_flow].payload, 200);
assert.equal(out[PORT.chart_power].payload, 11.4);
assert.equal(out[PORT.chart_eta].payload, 62);
// % of capacity = flow / flowCapacityMax × 100 = 200 / 450 × 100 ≈ 44.44.
assert.equal(out[PORT.chart_pctcap].topic, '% of capacity');
assert.ok(Math.abs(out[PORT.chart_pctcap].payload - (200 / 450) * 100) < 1e-6);
});
test('% of capacity chart: drops msg when flow or capacity missing (no payload:null)', () => {
// State A: no flow + flowCapacityMax=0 → pctCap undefined → chart() returns
// null so the function node skips the output, never { payload: null }.
const [out] = runFn([initialMsg]);
assert.equal(out[PORT.chart_pctcap], null, 'chart_pctcap must drop msg when source missing');
});
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
// The fix under test. MGC emits Ncog as the SUM of per-pump NCog values
// (range 0..N), so a raw pct() would display 120% for 2 pumps at 0.6 each.
// The formatter must divide by machineCountActive first.
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
// 2 pumps × 0.6 each = sum 1.2, mean 0.6, displayed "60.0 %".
assert.equal(out[PORT.text_ncog].payload, '60.0 %');
});
test('NCog formatter: ncogSum=0 with active pumps → 0.0 %, not em-dash', () => {
const msg = { payload: { ...postSetupMsg.payload,
atEquipment_predicted_Ncog: 0, machineCountActive: 3 } };
const [out] = runFn([msg]);
// Today this is exactly what the live MGC emits (per-pump groupNCog=0
// for the hidrostal-H05K-S03R curve at 110 kPa). The dashboard must show
// a clean "0.0 %" — not "—" — because we DO have data, it's just zero.
assert.equal(out[PORT.text_ncog].payload, '0.0 %');
});
test('NCog formatter: ncogSum present but machineCountActive = 0 → em-dash (no /0)', () => {
const msg = { payload: { atEquipment_predicted_Ncog: 1.5, machineCountActive: 0 } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_ncog].payload, '—');
});
test('NCog formatter: ncogSum present but machineCountActive missing → em-dash', () => {
const msg = { payload: { atEquipment_predicted_Ncog: 1.5 /* no nAct */ } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_ncog].payload, '—');
});
test('NCog formatter: 3 pumps each at NCog=0.5 (sum 1.5) → 50.0 %, not 150 %', () => {
// Regression test for the bug class — the formatter was displaying sum × 100,
// so 1.5 became "150.0 %". Verify the normalization sticks.
const msg = { payload: {
atEquipment_predicted_Ncog: 1.5,
machineCountActive: 3,
} };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_ncog].payload, '50.0 %');
});
test('BEP rel%: undefined bepRel → "—" (degenerate homogeneous-pump case)', () => {
// After today's groupEfficiency fix, MGC emits relDistFromPeak=undefined when
// pumps are identical. The dashboard text formatter must display "—" — NOT
// "0.0 %" via the +null === 0 trap.
const msg = { payload: { mode: 'optimalControl', relDistFromPeak: undefined } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_bep_rel].payload, '—');
});
test('BEP rel%: null bepRel → "—" (defensive against null emission)', () => {
// Same trap as the NCog fix: +null === 0 → pct() would return "0.0 %".
const msg = { payload: { relDistFromPeak: null } };
const [out] = runFn([msg]);
assert.equal(out[PORT.text_bep_rel].payload, '—');
});
test('BEP rel% chart: drops msg when bepRel is null/undefined (no payload:null)', () => {
const msg = { payload: { relDistFromPeak: undefined } };
const [out] = runFn([msg]);
assert.equal(out[PORT.chart_bep_rel], null, 'chart must drop msg when bepRel missing');
});
// ── fn_qh_fanout: Q-H curve → chart points ────────────────────────────
const fnQH = flow.find(n => n.id === 'fn_qh_fanout');
function runFanout(payload) {
const fn_body = new Function('msg', fnQH.func);
return fn_body({ payload });
}
test('Q-H fanout: trims trailing flat-Q tail so chart axis doesn\'t blow up', () => {
// Synthetic input mimics buildQHCurve at low ctrl%: useful range followed by
// a horizontal tail (Q clamped to env minimum across high H).
const points = [
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 },
{ Q: 20, H: 20 }, { Q: 9.5, H: 24 }, { Q: 9.5, H: 28 },
{ Q: 9.5, H: 32 }, { Q: 9.5, H: 36 }, { Q: 9.5, H: 40 },
];
const [out] = runFanout({ points });
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
// The 5 tail points at Q=9.5 should collapse to (at most) one — the first
// one to mark the curve's tail entry, not all five.
const tailPoints = curvePoints.filter(p => p.payload.Q === 9.5 || p.payload.x === 9.5);
assert.ok(tailPoints.length <= 1,
`expected ≤1 flat-tail point, got ${tailPoints.length}: ${JSON.stringify(curvePoints)}`);
});
test('Q-H fanout: still emits the rising portion of the curve unchanged', () => {
const points = [
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 }, { Q: 20, H: 20 },
{ Q: 9.5, H: 24 }, { Q: 9.5, H: 28 }, // flat tail
];
const [out] = runFanout({ points });
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
const rising = curvePoints.filter(p => p.payload.x > 10);
assert.equal(rising.length, 4, `expected 4 rising points, got ${rising.length}`);
// First rising point preserves Q=100, H=7.
assert.equal(rising[0].payload.x, 100);
assert.equal(rising[0].payload.y, 7);
});
test('Q-H fanout: empty/error input → null msg', () => {
assert.equal(runFanout({ error: 'no curve', points: [] }), null);
assert.equal(runFanout({ points: [] }), null);
});
test('contract: no output ever emits { payload: null } for any of the three states', () => {
// The original η-null bug. Re-asserted across all three states because a
// regression here crashes the FlowFuse ui-chart with TypeError on .y.
const states = runFn([initialMsg, postSetupMsg, postDemandMsg]);
for (let s = 0; s < states.length; s++) {
const out = states[s];
for (let i = 0; i < out.length; i++) {
const msg = out[i];
if (msg && Object.prototype.hasOwnProperty.call(msg, 'payload')) {
assert.notEqual(msg.payload, null,
`state ${s} port ${i} → { payload: null } would crash ui-chart`);
}
}
}
});