Files
machineGroupControl/test/integration/dashboard-fanout.integration.test.js
znetsixe 26e92b54f7 governance + unit-self-describing demand + dashboard fixes
Two governance items from the 2026-05-14 quality review:
- test/_output-manifest.md enumerates every Port 0/1/2 key MGC emits, its
  source, type, range, and which tests cover it in populated/degraded states
  (per .claude/rules/output-coverage.md).
- src/control/strategies.js extracts computeEqualFlowDistribution as a pure
  function so the equal-flow algorithm is testable without an MGC fixture.
  test/basic/equalFlowDistribution.basic.test.js (6 tests) covers all three
  demand branches and pins the legacy quirk where the default branch counts
  active machines but iterates priority-ordered first-N (documented in the
  test so the future cleanup is a deliberate change).

Plus rolled-up session work that landed alongside:
- set.demand is now unit-self-describing ({value, unit:'m3/h'|'l/s'|'%'|...}
  or bare number = %); setScaling/scaling.current removed from MGC, commands,
  editor (mgc.html), specificClass.
- _optimalControl + equalFlowControl now compute eta = (Q*dP)/P_shaft rather
  than Q/P, keeping the metric in the same scale as each child's cog.
- groupEfficiency.calcRelativeDistanceFromPeak returns undefined (was 1) when
  pumps are homogeneous (|max-min| < 1e-9). Dashboard treats undefined as
  '-' instead of showing a misleading 100% / 0% reading.
- examples/02-Dashboard.json: auto-init inject so the dashboard populates at
  deploy, NCog formatter normalizes the SUM emitted by MGC by
  machineCountActive, Q-H fanout trims the flat-Q tail so the H axis isn't
  stretched to 40m by curve-envelope clamp points, num/pct treat null AND
  undefined as no-data (closes the +null === 0 trap).
- new test/integration/dashboard-fanout.integration.test.js (17 tests),
  bep-distance-demand-sweep.integration.test.js (3 tests),
  group-bep-cascade.integration.test.js -- total suite now 108/108 green.
- .gitignore: wiki/test.gif (143 MB screen recording, kept locally only).

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

241 lines
10 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 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`);
}
}
}
});