'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const MovementExecutor = require('../../src/movement/movementExecutor'); function mkSchedule(commands, tStarS = 0, tickS = 1) { return { tStarS, tickS, commands }; } const noopLogger = { debug() {}, info() {}, warn() {}, error() {} }; test('executor: throws if fireCommand callback missing', () => { assert.throws(() => new MovementExecutor({}), TypeError); }); test('executor: fires commands whose fireAtTickN <= cursor', async () => { const fired = []; const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c), logger: noopLogger, }); ex.replan(mkSchedule([ { machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 }, { machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 }, { machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 }, ])); let firedThisTick = await ex.tick(); assert.equal(firedThisTick.length, 1); assert.equal(firedThisTick[0].machineId, 'A'); firedThisTick = await ex.tick(); assert.equal(firedThisTick.length, 0); firedThisTick = await ex.tick(); assert.equal(firedThisTick.length, 1); assert.equal(firedThisTick[0].machineId, 'B'); await ex.tick(); await ex.tick(); firedThisTick = await ex.tick(); assert.equal(firedThisTick.length, 1); assert.equal(firedThisTick[0].machineId, 'C'); assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']); assert.equal(ex.pending(), 0); }); test('executor: replan drops unfired commands and resets cursor', async () => { const fired = []; const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger }); ex.replan(mkSchedule([ { machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 }, { machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 }, ])); await ex.tick(); // A fires assert.deepEqual(fired, ['A']); assert.equal(ex.pending(), 1); ex.replan(mkSchedule([ { machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 }, { machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 }, ])); assert.equal(ex.cursor(), 0, 'cursor reset on replan'); await ex.tick(); // X fires assert.deepEqual(fired, ['A', 'X']); await ex.tick(); await ex.tick(); await ex.tick(); assert.ok(!fired.includes('B'), 'old B move was dropped by replan'); assert.ok(fired.includes('Y'), 'new Y move fired after delay'); }); test('executor: fires only once per command even across many ticks', async () => { const fired = []; const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger }); ex.replan(mkSchedule([ { machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 }, ])); for (let i = 0; i < 5; i++) await ex.tick(); assert.deepEqual(fired, ['A']); }); test('executor: catches fireCommand errors and continues', async () => { const fired = []; const ex = new MovementExecutor({ fireCommand: (c) => { if (c.machineId === 'B') throw new Error('boom'); fired.push(c.machineId); }, logger: noopLogger, }); ex.replan(mkSchedule([ { machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 }, { machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 }, { machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 }, ])); await ex.tick(); // B's error must not block A or C. assert.deepEqual(fired, ['A', 'C']); }); test('executor: empty / null schedule is safe to tick', async () => { const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger }); assert.deepEqual(await ex.tick(), []); ex.replan({ commands: [] }); assert.deepEqual(await ex.tick(), []); }); test('executor: tick fires commands synchronously and does NOT await their promises', async () => { // Contract: tick() returns as soon as every due fireCommand has been // invoked. It does NOT wait for the returned promises to resolve. // This matters because a flowmovement-after-startup resolves only // after the pump's entire ramp completes — awaiting it would freeze // the executor's wall-clock progression and drag every delayed // command in the schedule forward by that duration. const order = []; let resolveFire; const firePromise = new Promise((r) => { resolveFire = r; }); const ex = new MovementExecutor({ fireCommand: (c) => { order.push(`fire-start-${c.machineId}`); return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); }); }, logger: noopLogger, }); ex.replan(mkSchedule([ { machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 }, ])); const tickPromise = ex.tick().then(() => order.push('tick-resolved')); // Wait one microtask cycle: tick should already have resolved even // though fire is still pending. await new Promise((r) => setTimeout(r, 10)); assert.deepEqual(order, ['fire-start-A', 'tick-resolved'], 'tick must resolve immediately after invoking fireCommand — not wait for its promise'); resolveFire(); await tickPromise; // The fire's tail runs in the background and lands after tick resolved. assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']); });