diff --git a/test/integration/idle-startup-deadlock.integration.test.js b/test/integration/idle-startup-deadlock.integration.test.js index 4793df0..ed32123 100644 --- a/test/integration/idle-startup-deadlock.integration.test.js +++ b/test/integration/idle-startup-deadlock.integration.test.js @@ -211,6 +211,113 @@ test('Scenario 3 — pumps with NO pressure measurements injected', async () => console.log(' (Scenario 3 is exploratory — no asserts; review the snapshot above.)'); }); +// --------------------------------------------------------------------------- +test('Scenario 5 — full up/down/up cycle through shutdown', async () => { + // Hypothesis E: when demand goes 100% → 0% → 100% (basin fills, drains + // past stopLevel, then refills), pumps pass through stopping → + // coolingdown → idle. If a fresh flow>0 demand arrives while a pump is + // mid-shutdown, the current MGC dispatch saves flowmovement to + // delayedMove (good) but doesn't issue execsequence-startup because + // state !== 'idle' (bug). The pump completes shutdown, reaches 'idle', + // and stays there because transitionToState('idle') doesn't fire + // delayedMove — only the transition INTO 'operational' does. Pump is + // stuck with delayedMove orphaned. + + const { mgc, pumps } = buildGroup(); + console.log('\n[Scenario 5] cycle: 100% → 0% → 100% with mid-shutdown re-engage'); + printSnapshots('before any handleInput', pumps); + + // Phase 1: drive up to 100% from idle. + await mgc.handleInput('parent', 100); + await sleep(5000); // full startup + ramp + printSnapshots('after settle at 100%', pumps); + for (const p of pumps) { + assert.equal(p.state.getCurrentState(), 'operational', + `Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`); + } + + // Phase 2: demand drops to 0% — pumps begin shutdown sequence. + // FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which + // awaits the full per-pump shutdown sequence. We need the next 100% + // demand to arrive WHILE pumps are still in stopping/coolingdown, + // not after they've reached idle. + mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`)); + // Wait briefly so the shutdown sequence enters but does NOT complete. + // shutdown=['stopping','coolingdown','idle'] with stopping=1s, + // coolingdown=2s. 500ms puts us solidly inside 'stopping'. + await sleep(500); + printSnapshots('mid-shutdown (pumps should be in stopping/coolingdown)', pumps); + + const midShutdownStates = pumps.map(p => p.state.getCurrentState()); + console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`); + + // Phase 3: demand returns to 100% while pumps are mid-shutdown. + await mgc.handleInput('parent', 100); + // Generous: full coolingdown remaining + full startup + ramp. + await sleep(8000); + printSnapshots('after re-engage to 100%', pumps); + + expectAllRunningAt100(pumps, 'Scenario 5'); +}); + +// --------------------------------------------------------------------------- +test('Scenario 6 — full up sweep then full down sweep', async () => { + // Hypothesis F: the user observed "going up stuck ~60%, going down + // not reacting". Mirror that with an explicit up-then-down monotonic + // sweep, every step holding 600 ms (slightly longer than DWELL on + // production basin model). After the sweep, we expect the LATEST + // demand (the final value of the down-sweep, which is 10%) to be + // honoured: pumps either at 1-pump combo's split or all idle if that + // demand falls below the per-pump minimum. + + const { mgc, pumps } = buildGroup(); + console.log('\n[Scenario 6] up-sweep 10%→100% then down-sweep 100%→10%, each step 600 ms'); + printSnapshots('before any handleInput', pumps); + + const upSteps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const downSteps = [90, 80, 70, 60, 50, 40, 30, 20, 10]; + + console.log(' --- up sweep ---'); + for (const pct of upSteps) { + mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`)); + await sleep(600); + const snaps = pumps.map(snapshot); + const totalQ = snaps.reduce((s, x) => s + x.flow, 0); + console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`); + } + printSnapshots('top of up-sweep (cmd=100%) after full settle', pumps); + await sleep(2000); + printSnapshots('top of up-sweep + 2s drain', pumps); + + console.log(' --- down sweep ---'); + for (const pct of downSteps) { + mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`)); + await sleep(600); + const snaps = pumps.map(snapshot); + const totalQ = snaps.reduce((s, x) => s + x.flow, 0); + console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`); + } + printSnapshots('bottom of down-sweep (cmd=10%) after sequence', pumps); + await sleep(3000); + printSnapshots('bottom of down-sweep + 3s drain', pumps); + + // Final demand was 10% (≈ 148 m³/h). At head 1100 mbar with per-pump + // min ≈ 89.5, this is solvable by a 1-pump combo near 148 m³/h. + // Optimizer typically picks the 1-pump combo. Either way, pumps are + // NOT supposed to be stuck at the prior up-sweep's 100% setpoint. + const flowMin_m3h = mgc.calcDynamicTotals().flow.min * 3600; + const flowMax_m3h = mgc.calcDynamicTotals().flow.max * 3600; + const expectedQ_m3h = flowMin_m3h + (flowMax_m3h - flowMin_m3h) * 0.10; // 10% scaled + console.log(` expected total flow at 10%: ~${expectedQ_m3h.toFixed(1)} m³/h`); + + const snaps = pumps.map(snapshot); + const totalQ = snaps.reduce((s, x) => s + x.flow, 0); + // Loose: total within 30 m³/h of expectation. Catches the obvious + // stuck-at-old-position regression. + assert.ok(Math.abs(totalQ - expectedQ_m3h) < 30, + `Scenario 6: total flow ${totalQ.toFixed(1)} m³/h diverged from expected ${expectedQ_m3h.toFixed(1)} after down-sweep — pumps did not adopt latest demand. Per-pump: ${snaps.map(s => `${s.state}@${s.ctrl.toFixed(0)}%`).join(', ')}`); +}); + // --------------------------------------------------------------------------- test('Scenario 4 — varying demand during startup (combo flips)', async () => { // Hypothesis D: in production the demand is NOT constant — as basin