'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const MoveTrajectory = require('../../src/movement/moveTrajectory'); // Reusable profile builder — keeps each test focused on the field(s) it cares // about. Anything not overridden is in a sane "operational at 0%" baseline. function makeProfile(over = {}) { return Object.assign({ id: 'P1', state: 'operational', position: 0, minPosition: 0, maxPosition: 100, velocityPctPerS: 2, timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 }, remainingTransitionS: null, flowAt: () => null, }, over); } // TC1 — idle, full startup ladder + ramp from min. test('TC1 idle → target = startingS + warmingupS + (target−min)/velocity', () => { const t = new MoveTrajectory(makeProfile({ state: 'idle' }), { targetPosition: 60 }); assert.equal(t.etaToTargetS(), 10 + 20 + 60 / 2); // 60s }); // TC2 — operational up. test('TC2 operational up = |target−position|/velocity', () => { const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 40 }), { targetPosition: 60 }); assert.equal(t.etaToTargetS(), 10); }); // TC3 — operational down. ETA is positive. test('TC3 operational down = |target−position|/velocity', () => { const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 80 }), { targetPosition: 30 }); assert.equal(t.etaToTargetS(), 25); }); // TC4 — no-op. test('TC4 operational, target == position → 0s', () => { const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 50 }), { targetPosition: 50 }); assert.equal(t.etaToTargetS(), 0); }); // TC5 — accelerating post-abort residue, same formula as operational. test('TC5 accelerating residue = operational formula', () => { const t = new MoveTrajectory(makeProfile({ state: 'accelerating', position: 35 }), { targetPosition: 60 }); assert.equal(t.etaToTargetS(), 12.5); }); // TC6 — decelerating residue. test('TC6 decelerating residue = operational formula', () => { const t = new MoveTrajectory(makeProfile({ state: 'decelerating', position: 70 }), { targetPosition: 40 }); assert.equal(t.etaToTargetS(), 15); }); // TC7 — warmingup, remaining time from stateManager. test('TC7 warmingup = remainingWarmupS + (target−min)/velocity', () => { const t = new MoveTrajectory(makeProfile({ state: 'warmingup', position: 0, remainingTransitionS: 12, }), { targetPosition: 50 }); assert.equal(t.etaToTargetS(), 12 + 50 / 2); // 37s }); // TC7b — warmingup but no remaining-time observation: falls back to full // configured warmup (worst-case). Kept for resilience when the state machine // pre-dates the getter. test('TC7b warmingup fallback to full warmingupS when no remaining provided', () => { const t = new MoveTrajectory(makeProfile({ state: 'warmingup', position: 0, remainingTransitionS: null, }), { targetPosition: 50 }); assert.equal(t.etaToTargetS(), 20 + 50 / 2); // 45s }); // TC8 — starting: remaining + full warmup + ramp. test('TC8 starting = remainingStartingS + warmingupS + (target−min)/velocity', () => { const t = new MoveTrajectory(makeProfile({ state: 'starting', position: 0, remainingTransitionS: 8, }), { targetPosition: 50 }); assert.equal(t.etaToTargetS(), 8 + 20 + 50 / 2); // 53s }); // TC8b — boundary: remaining hits 0 just before the setTimeout fires. test('TC8b starting with remainingTransitionS=0 still yields positive ETA', () => { const t = new MoveTrajectory(makeProfile({ state: 'starting', position: 0, remainingTransitionS: 0, }), { targetPosition: 50 }); assert.equal(t.etaToTargetS(), 0 + 20 + 50 / 2); // 45s }); // TC9 — shutdown ladder excluded: returns null so scheduler skips it. test('TC9a stopping → null', () => { const t = new MoveTrajectory(makeProfile({ state: 'stopping', position: 30 }), { targetPosition: 0 }); assert.equal(t.etaToTargetS(), null); }); test('TC9b coolingdown → null', () => { const t = new MoveTrajectory(makeProfile({ state: 'coolingdown', position: 0 }), { targetPosition: 0 }); assert.equal(t.etaToTargetS(), null); }); // TC10 — target above max clamps; ETA uses clamped value. test('TC10 target above maxPosition clamps to max', () => { const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, maxPosition: 100 }), { targetPosition: 120 }); assert.equal(t.targetPosition, 100); assert.equal(t.etaToTargetS(), 50); }); // TC11 — target below min clamps; ETA zero when already at min. test('TC11 target below min clamps to min; ETA = 0 when at min', () => { const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, minPosition: 0 }), { targetPosition: -5 }); assert.equal(t.targetPosition, 0); assert.equal(t.etaToTargetS(), 0); }); // TC12 — zero velocity yields Infinity, not NaN or crash. test('TC12 zero velocity → Infinity', () => { const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, velocityPctPerS: 0 }), { targetPosition: 50 }); assert.equal(t.etaToTargetS(), Infinity); }); // TC13 — non-finite target throws at construction (totality of etaToTargetS). test('TC13 non-finite target throws at construction', () => { assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: NaN }), TypeError); assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: undefined }), TypeError); }); // Extra: minPosition above 0 is honoured in ramp distance for startup cases. test('TC1b idle with minPosition=10 → ramp from 10, not 0', () => { const t = new MoveTrajectory(makeProfile({ state: 'idle', minPosition: 10 }), { targetPosition: 60 }); assert.equal(t.etaToTargetS(), 10 + 20 + (60 - 10) / 2); // 55s });