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
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 16:24:22 +02:00
|
|
|
|
// Indices into the 18-output return array. Kept here as the manifest contract
|
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
|
|
|
|
// 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,
|
2026-05-27 16:24:22 +02:00
|
|
|
|
chart_pctcap: 17,
|
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
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-27 16:24:22 +02:00
|
|
|
|
test('manifest: function has exactly 18 outputs and wires array matches', () => {
|
|
|
|
|
|
assert.equal(fn.outputs, 18);
|
|
|
|
|
|
assert.equal(fn.wires.length, 18);
|
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
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-27 16:24:22 +02:00
|
|
|
|
// % 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');
|
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
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|