137 lines
5.5 KiB
JavaScript
137 lines
5.5 KiB
JavaScript
|
|
'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']);
|
||
|
|
});
|