Files
machineGroupControl/test/basic/movement-gate.basic.test.js
znetsixe b59d8e60f7 feat(mgc): demand telemetry + movement gate (demand debounce)
- Movement gate: hold non-urgent demand while the group is 'working'
  (mid-ramp/sequencing) and flush it once 'ready', instead of aborting
  in-flight ramps on every incoming demand — which could freeze pumps at 0.
  Urgent demand (stop, mode/priority change, large step) still pre-empts.
- getMovementState()/_isUrgentDemand()/_maybeFlushPendingDemand() helpers.
- Demand telemetry: emit demandFlow (m³/h) and demandPct (0..100 of envelope)
  resolved by the last dispatch; omitted before the first demand (degraded).
- Capacity envelope now emitted in output flow unit (m³/h) not raw m³/s.
- Manifest + populated/degraded tests for the new outputs.

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

78 lines
3.5 KiB
JavaScript

// Unit tests for the MGC movement state + dispatch-gate helpers
// (getMovementState / _isUrgentDemand). 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');
});
function urgent(demandQ, {
mode = 'optimalControl', lastMode = 'optimalControl',
last = 10, priorityList = null, lastPriorityKey = 'null', span = 100, thr,
} = {}) {
return MachineGroup.prototype._isUrgentDemand.call({
_lastDemand: last == null ? null : { canonical: last },
mode, _lastDispatchedMode: lastMode, _lastPriorityKey: lastPriorityKey,
calcDynamicTotals: () => ({ flow: { max: span } }),
config: { planner: thr == null ? {} : { urgentDemandFraction: thr } },
}, demandQ, priorityList);
}
test('urgent: a stop (≤0) always pre-empts', () => {
assert.equal(urgent(0), true);
assert.equal(urgent(-5), true);
});
test('urgent: the first demand (no prior) dispatches immediately', () => {
assert.equal(urgent(50, { last: null }), true);
});
test('urgent: a control-mode switch is a new intent', () => {
assert.equal(urgent(10, { mode: 'priorityControl', lastMode: 'optimalControl' }), true);
});
test('urgent: a changed priority order is a new intent', () => {
assert.equal(urgent(10, { priorityList: ['eff', 'std'], lastPriorityKey: 'null' }), true);
});
test('urgent: a small same-mode nudge is held (not urgent)', () => {
assert.equal(urgent(12, { last: 10, span: 100 }), false); // 2% of span < 25%
});
test('urgent: a large same-mode step pre-empts', () => {
assert.equal(urgent(60, { last: 10, span: 100 }), true); // 50% of span ≥ 25%
});
test('urgent: threshold is configurable via planner.urgentDemandFraction', () => {
assert.equal(urgent(15, { last: 10, span: 100, thr: 0.02 }), true); // 5% ≥ 2%
assert.equal(urgent(15, { last: 10, span: 100, thr: 0.5 }), false); // 5% < 50%
});