143 lines
5.9 KiB
JavaScript
143 lines
5.9 KiB
JavaScript
|
|
'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
|
|||
|
|
});
|