'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); });