const test = require('node:test'); const assert = require('node:assert/strict'); const Machine = require('../../src/specificClass'); const { makeMachineConfig, makeStateConfig } = require('../helpers/factories'); test('shutdown sequence from operational reaches idle', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.getCurrentState(), 'operational'); await machine.handleInput('parent', 'execSequence', 'shutdown'); assert.equal(machine.state.getCurrentState(), 'idle'); }); test('shutdown from operational ramps down position before stopping', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); await machine.handleInput('parent', 'execMovement', 50); const posBefore = machine.state.getCurrentPosition(); assert.ok(posBefore > 0, 'Machine should be at non-zero position'); await machine.handleInput('parent', 'execSequence', 'shutdown'); const posAfter = machine.state.getCurrentPosition(); assert.ok(posAfter <= posBefore, 'Position should have decreased after shutdown'); assert.equal(machine.state.getCurrentState(), 'idle'); }); test('shutdown clears predicted flow and power', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' }); await machine.handleInput('parent', 'execMovement', 50); await machine.handleInput('parent', 'execSequence', 'shutdown'); const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(); const power = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue(); assert.equal(flow, 0, 'Flow should be zero after shutdown'); assert.equal(power, 0, 'Power should be zero after shutdown'); }); test('entermaintenance sequence from operational reaches maintenance state', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.getCurrentState(), 'operational'); await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance'); assert.equal(machine.state.getCurrentState(), 'maintenance'); }); test('exitmaintenance requires mode with exitmaintenance action allowed', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); // Use auto mode (has execsequence + entermaintenance) to reach maintenance await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.getCurrentState(), 'operational'); await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance'); assert.equal(machine.state.getCurrentState(), 'maintenance'); // Switch to fysicalControl which allows exitmaintenance machine.setMode('fysicalControl'); await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance'); assert.equal(machine.state.getCurrentState(), 'idle'); }); test('shutdown clears delayedMove synchronously, before the abort/await path runs', async () => { // Regression: when MGC parks a setpoint in state.delayedMove during a // dead-zone keep-alive, then PS commands shutdown via turnOffAllMachines, // the shutdown's interruptible-abort path triggers transitionToState // ('operational'), which auto-picks up delayedMove and re-starts the // pump. Pump bounces accelerating ↔ decelerating forever and the // shutdown sequence never reaches idle. Observed live in the // pumpingstation-complete-example demo: basin drained past stopLevel // with one pump stuck at minimum flow. // // Fix: executeSequence clears state.delayedMove for shutdown/emergencystop // BEFORE the abort+await path. Asserting synchronously (race the first // microtask) is the precise behavioural check — without the fix, the // auto-pickup could still re-engage the pump on the way to idle even if // the value is null after the call returns. const slowMove = makeStateConfig({ movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 }, }); const machine = new Machine(makeMachineConfig(), slowMove); await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.getCurrentState(), 'operational'); machine.setpoint(80); await new Promise((r) => setTimeout(r, 50)); assert.equal(machine.state.getCurrentState(), 'accelerating'); machine.state.delayedMove = 75; // Kick off the shutdown but do not await — capture state before the // abort path's await yields. const shutdownPromise = machine.handleInput('parent', 'execSequence', 'shutdown'); // Yield once to allow the synchronous prelude of executeSequence to run // (lookup, lowercase, the new delayedMove=null assignment) without // letting any await resolve. await Promise.resolve(); assert.equal(machine.state.delayedMove, null, 'delayedMove must be cleared synchronously by the shutdown prelude — otherwise the abort path will auto-pick it up'); await shutdownPromise; assert.equal(machine.state.getCurrentState(), 'idle'); }); test('emergencystop also clears queued delayedMove', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); await machine.handleInput('parent', 'execMovement', 30); machine.state.delayedMove = 60; await machine.handleInput('parent', 'execSequence', 'emergencystop'); assert.equal(machine.state.delayedMove, null, 'emergency-stop must clear delayedMove'); }); test('startup does NOT clear delayedMove (only shutdown/emergencystop does)', async () => { // delayedMove serves a legitimate purpose for non-stop sequences — e.g. // setpoints arriving while the pump is in 'starting' get queued and // auto-picked-up when state lands in 'operational'. The fix must be // narrowly scoped to interruptible (stop) sequences. const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); machine.state.delayedMove = 42; // Re-running startup from operational is a no-op for state, but the // delayedMove must still be there afterwards for the auto-pickup to fire. await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.delayedMove, 42, 'non-stop sequences must preserve delayedMove for the auto-pickup'); });