// 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 17-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, }; 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 17 outputs and wires array matches', () => { assert.equal(fn.outputs, 17); assert.equal(fn.wires.length, 17); }); 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); }); 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`); } } } });