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>
87 lines
3.9 KiB
JavaScript
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);
|
|
});
|