const test = require('node:test'); const assert = require('node:assert/strict'); const Machine = require('../../src/specificClass'); const { makeMachineConfig, makeStateConfig } = require('../helpers/factories'); /** * Regression tests for the FSM interruptible-movement fix (2026-04-13). * * Before the fix, `executeSequence("shutdown")` was silently rejected by the * state manager if the machine was mid-move (accelerating/decelerating), * because allowedTransitions for those states only permits returning to * `operational` or `emergencystop`. Operators pressing Stop during a ramp * would see the transition error-logged but no actual stop. * * The fix aborts the active movement, waits for the FSM to return to * `operational`, then runs the normal shutdown / emergency-stop sequence. */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function makeSlowMoveMachine() { // Slow movement so the test can reliably interrupt during accelerating. // speed=20%/s, interval=10ms -> 80% setpoint takes ~4s of real movement. return new Machine( makeMachineConfig(), makeStateConfig({ movement: { mode: 'staticspeed', speed: 20, maxSpeed: 1000, interval: 10 }, time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 }, }) ); } test('shutdown during accelerating aborts the move and reaches idle', async () => { const machine = makeSlowMoveMachine(); await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.getCurrentState(), 'operational'); machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' }); machine.updateMeasuredPressure(200, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' }); // Fire a setpoint that needs ~4 seconds. Do NOT await it. const movePromise = machine.handleInput('parent', 'execMovement', 80); // Wait a moment for the FSM to enter accelerating. await sleep(100); assert.equal(machine.state.getCurrentState(), 'accelerating'); // Issue shutdown while the move is still accelerating. await machine.handleInput('GUI', 'execSequence', 'shutdown'); // Let the aborted move unwind. await movePromise.catch(() => {}); assert.equal( machine.state.getCurrentState(), 'idle', 'shutdown issued mid-ramp must still drive FSM back to idle', ); }); test('emergency stop during accelerating reaches off', async () => { const machine = makeSlowMoveMachine(); await machine.handleInput('parent', 'execSequence', 'startup'); machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' }); const movePromise = machine.handleInput('parent', 'execMovement', 80); await sleep(100); assert.equal(machine.state.getCurrentState(), 'accelerating'); await machine.handleInput('GUI', 'emergencystop'); await movePromise.catch(() => {}); assert.equal( machine.state.getCurrentState(), 'off', 'emergency stop issued mid-ramp must still drive FSM to off', ); }); test('executeSequence accepts mixed-case sequence names', async () => { const machine = new Machine(makeMachineConfig(), makeStateConfig()); await machine.handleInput('parent', 'execSequence', 'startup'); assert.equal(machine.state.getCurrentState(), 'operational'); // Parent orchestrators (e.g. machineGroupControl) use "emergencyStop" with // a capital S in their configs. The sequence key in rotatingMachine.json // is lowercase. Normalization must bridge that gap without a warn. await machine.executeSequence('EmergencyStop'); assert.equal(machine.state.getCurrentState(), 'off'); });