Files
pumpingStation/test/basic/safetyController.basic.test.js
znetsixe 7afcd6e54a P2 wave 1: extract concerns from pumpingStation specificClass
Splits pumpingStation/src/ into focused concern modules. specificClass.js
will be slimmed to an orchestrator in P2.9 (integration); for now both
the inlined logic AND the new modules coexist so tests stay green
throughout.

  src/basin/         BasinGeometry + thresholdValidator (pure)
  src/measurement/   flowAggregator + measurementRouter + calibration
  src/control/       levelBased + flowBased(stub) + manual + index dispatcher
  src/safety/        safetyController split into dryRun + overfill rules
  src/commands/      registry array + handlers (canonical names from start)
  src/editor.js      260 lines of SVG basin-diagram redraw, was inline in .html
  examples/standalone-demo.js  was if(require.main===module) at bottom of specificClass.js
  CONTRACT.md        canonical inputs + outputs + emitted events

Modified:
  src/specificClass.js  removed the 170-line standalone demo block
  pumpingStation.html   oneditprepare/oneditsave delegate to editor.{init,save}
  pumpingStation.js     added admin endpoint serving src/editor.js

102 basic tests pass (60 new + 42 existing).
specificClass.js itself is unchanged in behaviour — integration is P2.9.

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

231 lines
7.7 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert');
const SafetyController = require('../../src/safety/safetyController');
// --------------------------- fakes ---------------------------
function fakeMeasurements(values) {
// values keyed by `${type}.${variant}.${position}` → number|null
return {
getUnit: (_type) => 'm3',
type(t) {
return {
variant(v) {
return {
position(p) {
return {
getCurrentValue() {
const k = `${t}.${v}.${p}`;
return values[k];
},
};
},
};
},
};
},
};
}
function makeMachine(positionVsParent, operational = true) {
const calls = [];
return {
config: { functionality: { positionVsParent } },
_isOperationalState: () => operational,
handleInput: (...args) => calls.push(args),
calls,
};
}
function makeStation() {
const calls = [];
return {
handleInput: (...args) => calls.push(args),
calls,
};
}
function makeGroup() {
const calls = [];
return {
turnOffAllMachines: () => calls.push(['turnOffAllMachines']),
calls,
};
}
function makeLogger() {
const warns = [];
return {
warn: (msg) => warns.push(msg),
info: () => {},
error: () => {},
debug: () => {},
warns,
};
}
function makeCtx({
vol = 50,
basin = { minVol: 10, maxVolAtOverflow: 90 },
safety = {
enableDryRunProtection: true,
enableOverfillProtection: true,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
machines = {},
stations = {},
machineGroups = {},
} = {}) {
const measurements = fakeMeasurements({
'volume.measured.atequipment': vol,
'volume.predicted.atequipment': vol,
});
const logger = makeLogger();
return {
ctx: { measurements, basin, config: { safety }, logger, machines, stations, machineGroups },
logger,
};
}
// --------------------------- tests ---------------------------
test('normal volume + filling → not blocked, no shutdowns', () => {
const m = makeMachine('downstream');
const { ctx } = makeCtx({ vol: 50, machines: { m } });
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
assert.deepStrictEqual(r, { blocked: false, reason: null, triggered: [] });
assert.strictEqual(m.calls.length, 0);
});
test('dry-run trigger: low volume + draining → blocked, downstream shut down', () => {
const down = makeMachine('downstream');
const at = makeMachine('atequipment');
const up = makeMachine('upstream');
const station = makeStation();
const group = makeGroup();
const { ctx } = makeCtx({
vol: 5, // below 10 * (1 + 10/100) = 11
machines: { down, at, up },
stations: { station },
machineGroups: { group },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'dry-run');
assert.ok(r.triggered.includes('dry-run-volume'));
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(at.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(up.calls.length, 0, 'upstream untouched in dry-run');
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(group.calls[0], ['turnOffAllMachines']);
});
test('dry-run does NOT trigger when filling', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({ vol: 5, machines: { down } });
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
// Filling at vol=5 (below overfill threshold 85.5) → no trigger at all.
assert.strictEqual(r.blocked, false);
assert.strictEqual(r.reason, null);
assert.strictEqual(down.calls.length, 0);
});
test('overfill trigger: high volume + filling → not blocked, only upstream + station shut down', () => {
const down = makeMachine('downstream');
const at = makeMachine('atequipment');
const up = makeMachine('upstream');
const station = makeStation();
const group = makeGroup();
const { ctx } = makeCtx({
vol: 88, // above 90 * 0.95 = 85.5
machines: { down, at, up },
stations: { station },
machineGroups: { group },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'filling', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, false, 'overfill must NOT block control');
assert.strictEqual(r.reason, 'overfill');
assert.ok(r.triggered.includes('overfill-volume'));
assert.deepStrictEqual(up.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(down.calls.length, 0, 'downstream must keep running');
assert.strictEqual(at.calls.length, 0, 'atequipment must keep running');
assert.deepStrictEqual(station.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.strictEqual(group.calls.length, 0, 'machine groups must keep draining');
});
test('no volume data → blocked, all machines shut down (panic)', () => {
const a = makeMachine('downstream');
const b = makeMachine('upstream');
const c = makeMachine('atequipment');
// override measurements to return null
const measurements = {
getUnit: () => 'm3',
type: () => ({ variant: () => ({ position: () => ({ getCurrentValue: () => null }) }) }),
};
const ctx = {
measurements,
basin: { minVol: 10, maxVolAtOverflow: 90 },
config: { safety: { enableDryRunProtection: true, enableOverfillProtection: true, dryRunThresholdPercent: 10, overfillThresholdPercent: 95 } },
logger: makeLogger(),
machines: { a, b, c },
stations: {},
machineGroups: {},
};
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'steady', secondsRemaining: null });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'no-volume-data');
assert.deepStrictEqual(a.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(b.calls[0], ['parent', 'execSequence', 'shutdown']);
assert.deepStrictEqual(c.calls[0], ['parent', 'execSequence', 'shutdown']);
});
test('time-based protection: short remainingTime while draining triggers dry-run shutdowns', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({
vol: 50, // well above dry-run vol threshold
safety: {
enableDryRunProtection: false, // volume rule disabled
enableOverfillProtection: false,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 60,
},
machines: { down },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 30 });
assert.strictEqual(r.blocked, true);
assert.strictEqual(r.reason, 'dry-run');
assert.ok(r.triggered.includes('time-remaining'));
assert.deepStrictEqual(down.calls[0], ['parent', 'execSequence', 'shutdown']);
});
test('disabled rules: enableDryRunProtection=false + draining low → no trigger', () => {
const down = makeMachine('downstream');
const { ctx } = makeCtx({
vol: 5, // would normally trigger dry-run
safety: {
enableDryRunProtection: false,
enableOverfillProtection: false,
dryRunThresholdPercent: 10,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
machines: { down },
});
const sc = new SafetyController(ctx);
const r = sc.evaluate({ direction: 'draining', secondsRemaining: 1000 });
assert.strictEqual(r.blocked, false);
assert.strictEqual(r.reason, null);
assert.strictEqual(down.calls.length, 0);
});