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>
This commit is contained in:
znetsixe
2026-05-10 20:18:49 +02:00
parent da50403c76
commit 7afcd6e54a
27 changed files with 2533 additions and 463 deletions

View File

@@ -0,0 +1,126 @@
// Unit tests for the level-based control strategy.
// Run with: node --test test/basic/control-levelBased.basic.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const levelBased = require('../../src/control/levelBased');
function makeMeasurements(levelMeters) {
// Minimal MeasurementContainer stand-in. The strategy only calls
// getUnit('level') and a chain ending in getCurrentValue(unit).
const chain = {
type() { return chain; },
variant() { return chain; },
position() { return chain; },
getCurrentValue() {
return Number.isFinite(levelMeters) ? levelMeters : null;
},
};
return {
getUnit: () => 'm',
type: () => chain,
};
}
function makeGroup(name) {
const calls = { handleInput: [], turnOff: 0 };
return {
config: { general: { name } },
handleInput: async (...args) => { calls.handleInput.push(args); },
turnOffAllMachines: () => { calls.turnOff += 1; },
_calls: calls,
};
}
function makeCtx(levelMeters, opts = {}) {
const groups = {
a: makeGroup('A'),
b: makeGroup('B'),
c: makeGroup('C'),
};
return {
measurements: makeMeasurements(levelMeters),
config: {
control: { levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, ...(opts.levelbased || {}) } },
},
logger: { warn: () => {}, debug: () => {}, info: () => {}, error: () => {} },
machineGroups: groups,
machines: {},
levelVariants: ['measured', 'predicted'],
};
}
test('level < minLevel → STOP: turnOffAllMachines on every group, percControl = 0', async () => {
const ctx = makeCtx(0.5);
const state = { percControl: 42 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 1, 'turnOffAllMachines called once per group');
assert.equal(g._calls.handleInput.length, 0, 'no demand sent in stop zone');
}
});
test('minLevel ≤ level < startLevel → DEAD ZONE: no calls, percControl unchanged', async () => {
const ctx = makeCtx(1.5);
const state = { percControl: 17 };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 17, 'percControl untouched in dead zone');
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
}
});
test('level == startLevel → percControl == 0 (lower edge of ramp)', async () => {
const ctx = makeCtx(2);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 0);
});
test('level == maxLevel → percControl == 100 (upper edge of ramp)', async () => {
const ctx = makeCtx(4);
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 100);
});
test('level above maxLevel → percControl clamped at 100 (interpolation limit_input behaviour)', async () => {
const ctx = makeCtx(10);
const state = { percControl: null };
await levelBased.run(ctx, state);
// interpolate_lin_single_point clamps via limit_input(o_min, o_max).
assert.equal(state.percControl, 100);
});
test('percControl forwarded to every group via handleInput("parent", percControl)', async () => {
const ctx = makeCtx(3); // halfway between startLevel=2 and maxLevel=4 → 50%
const state = { percControl: null };
await levelBased.run(ctx, state);
assert.equal(state.percControl, 50);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.handleInput.length, 1, 'one forward per group');
assert.deepEqual(g._calls.handleInput[0], ['parent', 50]);
assert.equal(g._calls.turnOff, 0);
}
});
test('no valid level → warns and returns without mutating percControl or calling groups', async () => {
const ctx = makeCtx(NaN);
let warned = false;
ctx.logger.warn = () => { warned = true; };
const state = { percControl: 7 };
await levelBased.run(ctx, state);
assert.equal(warned, true);
assert.equal(state.percControl, 7);
for (const g of Object.values(ctx.machineGroups)) {
assert.equal(g._calls.turnOff, 0);
assert.equal(g._calls.handleInput.length, 0);
}
});