fix: production hardening — safety fixes, prediction accuracy, test coverage

Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close

Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null

Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-07 13:41:00 +02:00
parent ea33b3bba3
commit 07af7cef40
10 changed files with 786 additions and 32 deletions

View File

@@ -0,0 +1,75 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('movement from 0 to 50% updates position and predictions', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 50);
const pos = machine.state.getCurrentPosition();
const { min, max } = machine._resolveSetpointBounds();
// Position should be constrained to bounds
assert.ok(pos >= min && pos <= max, `Position ${pos} should be within [${min}, ${max}]`);
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
assert.ok(flow > 0, 'Predicted flow should be positive at non-zero position');
});
test('flowmovement sets position based on flow setpoint', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
// Request 100 m3/h flow — the machine should calculate the control position
await machine.handleInput('parent', 'flowMovement', 100);
const pos = machine.state.getCurrentPosition();
assert.ok(pos > 0, 'Position should be non-zero for a non-zero flow setpoint');
});
test('sequential movements update position correctly', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 30);
const pos30 = machine.state.getCurrentPosition();
await machine.handleInput('parent', 'execMovement', 60);
const pos60 = machine.state.getCurrentPosition();
assert.ok(pos60 > pos30, 'Position at 60 should be greater than at 30');
});
test('movement to 0 sets flow and power predictions to minimum curve values', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 0);
const pos = machine.state.getCurrentPosition();
assert.equal(pos, 0, 'Position should be at 0');
});
test('movement is rejected in non-operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
assert.equal(machine.state.getCurrentState(), 'idle');
// Attempt movement in idle state — handleInput should process but no movement happens
await machine.handleInput('parent', 'execMovement', 50);
// Machine should still be idle (movement requires operational state via sequence first)
assert.equal(machine.state.getCurrentState(), 'idle');
});