Files
machineGroupControl/test/basic/movement-gate.basic.test.js
znetsixe 2af6c904da feat(mgc): rendezvous lock + emergency bypass (no re-plan mid-rendezvous)
Once a rendezvous plan is committed it now runs to completion untouched: an
ordinary new setpoint arriving while the group is 'working' is remembered
(latest wins) and dispatched sequentially when the group reaches 'ready',
instead of aborting + re-planning. A re-plan mid-flight dropped the in-flight
schedule and re-deferred a pump that was mid-sequence, parking starting pumps
at minimum flow.

Only an EMERGENCY pre-empts the lock: a stop (≤0) or a pressure excursion.
_isUrgentDemand (which pre-empted on any large step) is replaced by
_isEmergencyDemand; the large-step pre-emption is gone — large operator steps
now defer like any other setpoint. _pressureEmergency() reads
planner.emergencyPressurePa and is INERT until that threshold is configured;
handlePressureChange fires a latched bypass dispatch when it breaches.

Verified live on the E2E Isolated MGC rig: a 1→2 pump staging transition ramps
the added pump straight through (no wait-at-minimum, no start-then-stop) and the
group total climbs monotonically. (The Pump-tab node's hunting is a separate
demand-feedback-loop issue in that flow's wiring, not the rendezvous.)

Integration tests now settle to 'ready' between demands (waitReady) since the
lock defers setpoints arriving mid-move.

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

87 lines
3.9 KiB
JavaScript

// Unit tests for the MGC movement state + rendezvous-lock helpers
// (getMovementState / _isEmergencyDemand / _pressureEmergency). Exercised via
// prototype.call with a
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
function machine(state, { delayedMove = null, moveTimeLeft = 0 } = {}) {
return { state: { getCurrentState: () => state, delayedMove, getMoveTimeLeft: () => moveTimeLeft } };
}
function movementStateOf(machines, pending = 0) {
return MachineGroup.prototype.getMovementState.call({
machines,
movementExecutor: { pending: () => pending },
});
}
test('movementState: ready when no machines are registered', () => {
assert.equal(movementStateOf({}), 'ready');
});
test('movementState: ready when every machine is settled and nothing is pending', () => {
assert.equal(movementStateOf({ a: machine('operational'), b: machine('idle') }), 'ready');
});
test('movementState: working while a machine is mid-ramp', () => {
assert.equal(movementStateOf({ a: machine('operational'), b: machine('accelerating') }), 'working');
});
test('movementState: working during a start/stop sequence step', () => {
assert.equal(movementStateOf({ a: machine('warmingup') }), 'working');
});
test('movementState: working when a setpoint is queued (delayedMove)', () => {
assert.equal(movementStateOf({ a: machine('operational', { delayedMove: 50 }) }), 'working');
});
test('movementState: working while move time remains', () => {
assert.equal(movementStateOf({ a: machine('operational', { moveTimeLeft: 1.2 }) }), 'working');
});
test('movementState: working when the executor still has scheduled commands', () => {
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
});
// Rendezvous lock: only an EMERGENCY pre-empts an in-flight rendezvous; every
// ordinary setpoint (any size, mode/priority change included) defers.
function emergency(demandQ, { last = 10, emergency = false } = {}) {
return MachineGroup.prototype._isEmergencyDemand.call({
_lastDemand: last == null ? null : { canonical: last },
}, demandQ, { emergency });
}
test('emergency: a stop (≤0) always pre-empts', () => {
assert.equal(emergency(0), true);
assert.equal(emergency(-5), true);
});
test('emergency: the first demand (no prior) dispatches immediately', () => {
assert.equal(emergency(50, { last: null }), true);
});
test('emergency: an explicit emergency flag pre-empts', () => {
assert.equal(emergency(60, { last: 10, emergency: true }), true);
});
test('emergency: an ordinary same-mode step defers (large or small)', () => {
assert.equal(emergency(12, { last: 10 }), false); // small nudge — defer
assert.equal(emergency(60, { last: 10 }), false); // large step — also defers now
});
// Pressure-excursion detector — inert until planner.emergencyPressurePa is set.
function pressureEmergency({ thr, headerPa } = {}) {
return MachineGroup.prototype._pressureEmergency.call({
config: { planner: thr == null ? {} : { emergencyPressurePa: thr } },
operatingPoint: { headerDiffPa: headerPa },
});
}
test('pressureEmergency: inert (false) when no threshold is configured', () => {
assert.equal(pressureEmergency({ headerPa: 999999 }), false);
});
test('pressureEmergency: false when header is below the configured threshold', () => {
assert.equal(pressureEmergency({ thr: 200000, headerPa: 150000 }), false);
});
test('pressureEmergency: true when header breaches the configured threshold', () => {
assert.equal(pressureEmergency({ thr: 200000, headerPa: 210000 }), true);
});
test('pressureEmergency: false when header pressure is unknown', () => {
assert.equal(pressureEmergency({ thr: 200000, headerPa: undefined }), false);
});