P4 wave 1: extract MGC concerns into focused modules

src/groupOps/        groupOperatingPoint + groupCurves (pure functions)
  src/totals/          totalsCalculator (dynamic + absolute + active)
  src/combinatorics/   pumpCombinations (validPumpCombinations + checkSpecialCases)
  src/optimizer/       bestCombination (CoG) + bepGravitation (BEP-G + marginal-cost)
  src/efficiency/      groupEfficiency (calc + distance helpers)
  src/dispatch/        demandDispatcher (LatestWinsGate-based; replaces
                       _dispatchInFlight + _delayedCall)
  src/commands/        canonical names from start (set.mode/scaling/demand,
                       child.register) + legacy aliases
  CONTRACT.md          inputs/outputs/events surface

53 basic tests pass (52 new + 1 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P4 wave 2.

Findings flagged via agents (TODO append to OPEN_QUESTIONS.md):
  - calcGroupEfficiency.maxEfficiency is actually the mean (misleading name)
  - checkSpecialCases has a no-op `return false` inside forEach
  - MGC doesn't route cmd.startup/shutdown/estop — confirm if station broadcasts need it

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-10 20:45:23 +02:00
parent ea2857fb25
commit 619b1311d2
21 changed files with 1895 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const DemandDispatcher = require('../../src/dispatch/demandDispatcher.js');
const silentLogger = { warn() {}, error() {}, debug() {}, info() {} };
// Helper: a manually-resolvable promise so we can pin a dispatch in flight.
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('fire(50) triggers runFn with 50', async () => {
const calls = [];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => { calls.push(demand); },
);
dispatcher.fire(50);
await dispatcher.drain();
assert.deepEqual(calls, [50]);
});
test('two fires back-to-back during in-flight — only the second runs after first settles', async () => {
const calls = [];
const gates = [deferred()];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
await gates[0].promise;
},
);
dispatcher.fire(10);
// first invocation is now in flight (after a microtask)
await Promise.resolve();
await Promise.resolve();
dispatcher.fire(20);
// 20 should be pending, not yet run.
assert.deepEqual(calls, [10]);
gates[0].resolve();
await dispatcher.drain();
assert.deepEqual(calls, [10, 20]);
});
test('three rapid fires — only first + last run; middle dropped', async () => {
const calls = [];
const gate = deferred();
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
if (calls.length === 1) await gate.promise;
},
);
dispatcher.fire(1);
await Promise.resolve();
await Promise.resolve();
dispatcher.fire(2);
dispatcher.fire(3); // overwrites the pending 2
assert.deepEqual(calls, [1]);
gate.resolve();
await dispatcher.drain();
assert.deepEqual(calls, [1, 3]);
});
test('drain() resolves only when idle', async () => {
const gate = deferred();
let runs = 0;
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async () => { runs++; await gate.promise; },
);
// drain() on an idle gate resolves immediately.
await dispatcher.drain();
dispatcher.fire('a');
let drained = false;
const drainPromise = dispatcher.drain().then(() => { drained = true; });
// Let a few microtasks run — drain must NOT be resolved while in flight.
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(drained, false);
assert.equal(runs, 1);
gate.resolve();
await drainPromise;
assert.equal(drained, true);
});
test('error in runFn does not deadlock; subsequent fire still works', async () => {
const calls = [];
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async (demand) => {
calls.push(demand);
if (demand === 'boom') throw new Error('boom');
},
);
dispatcher.fire('boom');
await dispatcher.drain();
dispatcher.fire('ok');
await dispatcher.drain();
assert.deepEqual(calls, ['boom', 'ok']);
});
test('inFlight getter reports correctly', async () => {
const gate = deferred();
const dispatcher = new DemandDispatcher(
{ logger: silentLogger },
async () => { await gate.promise; },
);
assert.equal(dispatcher.inFlight, false);
dispatcher.fire(1);
// Microtask scheduling — gate flips to inFlight after one tick.
await Promise.resolve();
assert.equal(dispatcher.inFlight, true);
gate.resolve();
await dispatcher.drain();
assert.equal(dispatcher.inFlight, false);
});
test('runFn receives the ctx supplied at construction', async () => {
const seen = [];
const ctx = { logger: silentLogger, marker: 'mgc-A' };
const dispatcher = new DemandDispatcher(
ctx,
async (demand, runCtx) => { seen.push({ demand, marker: runCtx.marker }); },
);
dispatcher.fire(42);
await dispatcher.drain();
assert.deepEqual(seen, [{ demand: 42, marker: 'mgc-A' }]);
});