'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { plan } = require('../../src/movement/movementScheduler'); // Profile builder — same shape as buildProfile output. positionForFlow // approximates the inverse curve as a linear mapping over [min,max] for // flow ∈ [0, maxFlow], which is enough to test scheduler logic without // dragging real curve math in. function makeProfile(over = {}) { const defaults = { id: 'A', state: 'operational', position: 0, minPosition: 0, maxPosition: 100, velocityPctPerS: 2, timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 }, remainingTransitionS: null, maxFlow: 100, // synthetic — for the test mapping below }; const p = Object.assign(defaults, over); // Linear position-for-flow over [min,max]. p.positionForFlow = (flow) => { if (!Number.isFinite(flow) || flow <= 0) return p.minPosition; return p.minPosition + (flow / p.maxFlow) * (p.maxPosition - p.minPosition); }; // flowAt — inverse of the above. p.flowAt = (pos /*, pressure */) => { if (!Number.isFinite(pos)) return 0; if (p.maxPosition === p.minPosition) return 0; return ((pos - p.minPosition) / (p.maxPosition - p.minPosition)) * p.maxFlow; }; return p; } // Tick rounding helper — scheduler uses Math.round(eta/tickS). function tickRound(s, tickS = 1) { return Math.round(s / tickS); } test('plan: idle → start a single pump (no other pumps online)', () => { const profiles = [makeProfile({ id: 'A', state: 'idle', position: 0 })]; const combination = [{ machineId: 'A', flow: 60 }]; const out = plan(profiles, combination, 100_000); // Two commands: execsequence(startup) + flowmovement(60). Both at tick 0. assert.equal(out.commands.length, 2); assert.equal(out.commands[0].action, 'execsequence'); assert.equal(out.commands[0].sequence, 'startup'); assert.equal(out.commands[0].fireAtTickN, 0); assert.equal(out.commands[1].action, 'flowmovement'); assert.equal(out.commands[1].flow, 60); assert.equal(out.commands[1].fireAtTickN, 0); // tStar = full startup ladder + ramp from 0 to position-for-60 (= 60%). // = 10 + 20 + 60/2 = 60s. assert.equal(out.tStarS, 60); }); test('plan: operational up-move (no rendezvous partner)', () => { const profiles = [makeProfile({ id: 'A', state: 'operational', position: 40 })]; // Currently delivering 40 (at maxFlow=100 → linear), targeting 60. const combination = [{ machineId: 'A', flow: 60 }]; const out = plan(profiles, combination, 100_000); assert.equal(out.commands.length, 1); assert.equal(out.commands[0].action, 'flowmovement'); assert.equal(out.commands[0].flow, 60); assert.equal(out.commands[0].fireAtTickN, 0); // eta = |60−40|/2 = 10s assert.equal(out.tStarS, 10); }); test('plan: rendezvous — startup pump + running pump that needs to shed load', () => { // A: starting from idle, target 60. eta = 10 + 20 + 60/2 = 60s. // B: operational at 80 (flow=80), target 40 (down). eta_B = 40/2 = 20s. // Expectation: A fires at tick 0; B fires at tick (60−20) = 40 so B // FINISHES at the same time A reaches its target. const profiles = [ makeProfile({ id: 'A', state: 'idle', position: 0 }), makeProfile({ id: 'B', state: 'operational', position: 80 }), ]; const combination = [ { machineId: 'A', flow: 60 }, { machineId: 'B', flow: 40 }, ]; const out = plan(profiles, combination, 100_000); const cmdA_startup = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence'); const cmdA_flow = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement'); const cmdB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement'); assert.ok(cmdA_startup, 'A startup'); assert.ok(cmdA_flow, 'A flowmovement (queued)'); assert.ok(cmdB, 'B flowmovement'); assert.equal(cmdA_startup.fireAtTickN, 0); assert.equal(cmdA_flow.fireAtTickN, 0); // B delayed so it finishes at tStar=60 → fires at 60−20 = 40. assert.equal(cmdB.fireAtTickN, 40); assert.equal(out.tStarS, 60); }); test('plan: all machines moving down — all land at slowest mover\'s eta', () => { // Two operational pumps, both reducing flow. tStar = max eta over // ALL non-noop moves (not just increasing) so the slower pump // defines the rendezvous and the faster one is delayed to land // with it. Net effect: same-time landing in pure-down scenarios too, // sum-of-flows stays at the OLD setpoint until t* then drops cleanly. const profiles = [ makeProfile({ id: 'A', state: 'operational', position: 80, velocityPctPerS: 2 }), makeProfile({ id: 'B', state: 'operational', position: 70, velocityPctPerS: 2 }), ]; const combination = [ { machineId: 'A', flow: 40 }, // target position via inverse curve → 40 (identity makeProfile) { machineId: 'B', flow: 30 }, ]; const out = plan(profiles, combination, 100_000); // eta_A = |80-40|/2 = 20s, eta_B = |70-30|/2 = 20s → tStar = 20s. assert.equal(out.tStarS, 20); // Both pumps have eta == tStar so neither is delayed (fireAtTickN = 0). for (const c of out.commands) { assert.equal(c.fireAtTickN, 0, `${c.machineId} should fire at 0 when eta == tStar`); } }); test('plan: asymmetric down moves — faster one delayed to land with slower one', () => { // A and B both reduce flow but A's move is faster. The new // symmetric-rendezvous semantics delay the faster mover so both land // at tStar = max eta. const profiles = [ makeProfile({ id: 'A', state: 'operational', position: 60, velocityPctPerS: 4 }), // fast makeProfile({ id: 'B', state: 'operational', position: 80, velocityPctPerS: 2 }), // slow ]; const combination = [ { machineId: 'A', flow: 40 }, { machineId: 'B', flow: 40 }, ]; const out = plan(profiles, combination, 100_000); // eta_A = |60-40|/4 = 5s, eta_B = |80-40|/2 = 20s → tStar = 20s. assert.equal(out.tStarS, 20); const cA = out.commands.find((c) => c.machineId === 'A'); const cB = out.commands.find((c) => c.machineId === 'B'); assert.equal(cA.fireAtTickN, 15, 'A (fast) delayed by tStar − eta_A = 20 − 5 = 15'); assert.equal(cB.fireAtTickN, 0, 'B (slow) defines tStar — fires immediately'); }); test('plan: shutdown — removed machine gets execsequence(shutdown)', () => { // A staying at flow 60, B getting shut down (target 0). const profiles = [ makeProfile({ id: 'A', state: 'operational', position: 60 }), makeProfile({ id: 'B', state: 'operational', position: 50 }), ]; const combination = [ { machineId: 'A', flow: 60 }, // unchanged { machineId: 'B', flow: 0 }, ]; const out = plan(profiles, combination, 100_000); const shutdownB = out.commands.find((c) => c.machineId === 'B' && c.action === 'execsequence' && c.sequence === 'shutdown'); assert.ok(shutdownB, 'B shutdown command present'); }); test('plan: noop — machine not in combination and already off does nothing', () => { const profiles = [ makeProfile({ id: 'A', state: 'operational', position: 60 }), makeProfile({ id: 'B', state: 'idle', position: 0 }), ]; const combination = [{ machineId: 'A', flow: 60 }]; const out = plan(profiles, combination, 100_000); const bAny = out.commands.find((c) => c.machineId === 'B'); assert.equal(bAny, undefined, 'B should be omitted (no-op)'); }); test('plan: rendezvous with three pumps — slowest startup sets the pace', () => { // A: idle → 50 (full startup, slow). // B: operational at 80 → 40 (down). // C: operational at 30 → 50 (up, fast). const profiles = [ makeProfile({ id: 'A', state: 'idle', position: 0 }), makeProfile({ id: 'B', state: 'operational', position: 80 }), makeProfile({ id: 'C', state: 'operational', position: 30 }), ]; const combination = [ { machineId: 'A', flow: 50 }, { machineId: 'B', flow: 40 }, { machineId: 'C', flow: 50 }, ]; const out = plan(profiles, combination, 100_000); // eta_A = 10 + 20 + 50/2 = 55s (startup ladder + ramp; defines tStar) // eta_B = |80-40|/2 = 20s (decreasing) // eta_C = |50-30|/2 = 10s (increasing) // tStar = max(55, 20, 10) = 55. assert.equal(out.tStarS, 55); const cA = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence'); const cC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement'); const cB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement'); // A's startup must begin NOW; its delayed flowmovement lands at t* // by construction. assert.equal(cA.fireAtTickN, 0); // Symmetric rendezvous: BOTH B and C are delayed to land at t*. // C (up, fast) gets delayed by t* − eta_C = 45. // B (down, mid) gets delayed by t* − eta_B = 35. assert.equal(cC.fireAtTickN, 55 - 10, 'C delayed to land at tStar (same-time landing)'); assert.equal(cB.fireAtTickN, 55 - 20, 'B delayed to land at tStar (same-time landing)'); }); test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar together', () => { // Three idle pumps starting from min position. Different per-pump // velocities → different etas. Without the rampStart gating, each // pump's delayedMove would fire at warmup-end and ramp at its own // speed, so the FAST pump lands long before the SLOW one — visible // on the dashboard as staggered landing curves. // // Real-world reproducer: pumpingstation-complete-example with the // editor's Reaction Speed set to A=3 %/s, B=10 %/s, C=1 %/s. // // Velocities here mirror that ratio but scaled for unit-test // readability. Position range is [0,100] so rampDist = 100. const profiles = [ makeProfile({ id: 'A', state: 'idle', position: 0, velocityPctPerS: 3 }), makeProfile({ id: 'B', state: 'idle', position: 0, velocityPctPerS: 10 }), makeProfile({ id: 'C', state: 'idle', position: 0, velocityPctPerS: 1 }), ]; const combination = [ { machineId: 'A', flow: 100 }, { machineId: 'B', flow: 100 }, { machineId: 'C', flow: 100 }, ]; const out = plan(profiles, combination, 100_000); // Default ladder = starting(10) + warmingup(20) = 30 s. // ramp_A = 100/3 ≈ 33.33 s → eta_A ≈ 63.33 s // ramp_B = 100/10 = 10 s → eta_B = 40 s // ramp_C = 100/1 = 100 s → eta_C = 130 s // tStar = max(eta_A, eta_B, eta_C) = 130 s. assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`); // execsequence fires at 0 for ALL idle pumps (the ladder must start now). for (const id of ['A', 'B', 'C']) { const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence'); assert.ok(exec, `${id} execsequence present`); assert.equal(exec.fireAtTickN, 0, `${id} execsequence fires immediately`); } // flowmovement gating — each pump's ramp must FINISH at tStar=130. const flowA = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement'); const flowB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement'); const flowC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement'); // A (medium): rampStart = 130 − 33.33 ≈ 96.67 → fireAtTickN = 97. assert.equal(flowA.fireAtTickN, Math.round(130 - 100 / 3)); // B (fast): rampStart = 130 − 10 = 120 → fireAtTickN = 120. assert.equal(flowB.fireAtTickN, 120); // C (slow, defines tStar): rendezvousRampStart = 130 − 100 = 30 == ladderS, // so no extra delay needed — fall back to fireAtTickN=0 and let // the pump's delayedMove fire it naturally at warmup-end. assert.equal(flowC.fireAtTickN, 0); // Sanity: with these schedules, all three pumps' ramps end at the // same wall-clock instant (within rounding). // A: 97 + 100/3 ≈ 130.33 // B: 120 + 10 = 130 // C: 30 (delayedMove) + 100 = 130 // Max spread ≈ 0.33 s — far better than the per-eta spread of // 130 − 40 = 90 s the planner would produce without this gating. }); test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => { const profiles = [ makeProfile({ id: 'A', state: 'operational', position: 0, velocityPctPerS: 0 }), ]; const combination = [{ machineId: 'A', flow: 60 }]; const out = plan(profiles, combination, 100_000); // Eta is Infinity → filtered out of tStar computation (only finite etas count). // Command still scheduled; fireAtTickN remains 0 for increasing move. const c = out.commands.find((c) => c.action === 'flowmovement'); assert.ok(c); assert.equal(c.fireAtTickN, 0); assert.equal(out.tStarS, 0); // no finite increasing eta → tStar collapses to 0 }); test('plan: respects custom tickS option', () => { // Same as the rendezvous test but with tickS=5 → fireAt should be in // ticks-of-5-seconds, not seconds. const profiles = [ makeProfile({ id: 'A', state: 'idle', position: 0 }), makeProfile({ id: 'B', state: 'operational', position: 80 }), ]; const combination = [ { machineId: 'A', flow: 60 }, { machineId: 'B', flow: 40 }, ]; const out = plan(profiles, combination, 100_000, { tickS: 5 }); const cmdB = out.commands.find((c) => c.machineId === 'B'); assert.equal(out.tStarS, 60); assert.equal(out.tickS, 5); assert.equal(cmdB.fireAtTickN, tickRound(60 - 20, 5)); // = 8 });