Files
machineGroupControl/test/basic/moveTrajectory.basic.test.js
znetsixe 472402c62d feat(mgc): rendezvous planner — same-time landing across all modes
Routes every dispatch through a tick-aware planner so all pumps reach
their setpoint at the same wall-clock instant t* = max(eta_i),
regardless of control strategy or per-pump reaction speed.

Architecture (src/movement/):
- machineProfile.js   – pure snapshot of a registered child (state,
                        position, velocityPctPerS, ladder timings,
                        flowAt / positionForFlow). Reads timings from
                        child.state.config.time (the actual storage
                        location — previous fallback paths silently
                        produced 0 s, collapsing every eta to ramp-only).
- moveTrajectory.js   – seconds-to-target per machine; handles
                        idle / starting / warmingup / operational / cooling.
- movementScheduler.js – t* = max eta over ALL non-noop moves. Every
                        command is delayed so its move finishes at t*.
                        Startup execsequence fires at 0; its flowmovement
                        is gated by max(ladderS, t* − rampS) so a fast
                        pump waits before ramping rather than landing
                        early. useRendezvous=false collapses to all
                        fireAtTickN=0 (legacy fire-and-forget).
- movementExecutor.js – wall-clock virtual cursor: each tick fires
                        every command whose fireAtTickN ≤ floor(elapsed/tickS).
                        tick() no longer awaits pending fireCommand
                        promises — the synchronous prologue of
                        handleInput claims the latest-wins gate, which
                        is what race-favouring relies on.

Shared dispatch path (src/specificClass.js):
- _dispatchFlowDistribution(distribution) — extracted from
  _optimalControl. Builds profiles, calls movementScheduler.plan,
  replans the executor, ticks once. Reads
  config.planner.useRendezvous (default true).
- _optimalControl computes its bestCombination and hands off.
- equalFlowControl (priorityControl mode) computes its
  flowDistribution and hands off via ctx.mgc._dispatchFlowDistribution.
  Same-time landing now applies in BOTH modes.

Editor toggle (mgc.html + src/nodeClass.js):
- New "Same-time landing" checkbox under Control Strategy.
- nodeClass.buildDomainConfig bridges uiConfig.useRendezvous →
  config.planner.useRendezvous. Default ON.

Tests:
- New: planner-convergence.integration.test.js (real-time end-to-end
  diagnostic — drives a 3-pump mixed-state dispatch and asserts both
  convergence to the demand setpoint AND same-time landing within
  one tick).
- New: planner-rendezvous.integration.test.js (schedule-shape
  assertions against real pump objects).
- New: movementScheduler.basic.test.js — includes a mixed-speed
  multi-startup case proving the fast pumps wait so all three land
  together (the regression that prompted this work).
- New: movementExecutor.basic.test.js + moveTrajectory.basic.test.js.
- Updated executor contract test: tick() must NOT await pending fires.

Commands + wiki:
- handlers.js: source/mode allow-list gate moved into a shared _gate()
  helper; every command now checks isValidActionForMode +
  isValidSourceForMode before dispatching. Status-level commands
  (set.mode, set.scaling) are allowed in every mode.
- commands.basic.test.js: coverage for the new gate behaviour.
- wiki regen: Home.md visual-first rewrite + Reference-{Architecture,
  Contracts,Examples,Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:55 +02:00

143 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 + (targetmin)/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 = |targetposition|/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 = |targetposition|/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 + (targetmin)/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 + (targetmin)/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
});