Delete the legacy _convertUnitValue helper on the domain and the duplicate convertUnitValue export on curveNormalizer; both were identical to UnitPolicy.convert. Callers in flowController, the curve normalizer, and buildQHCurve now go through this.unitPolicy. The contract in .claude/refactor/CONTRACTS.md §6 named these as the target migration; this finishes the rollout for rotatingMachine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
5.0 KiB
JavaScript
133 lines
5.0 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const FlowController = require('../../src/flow/flowController');
|
|
|
|
function makeLogger() {
|
|
const calls = { debug: [], info: [], warn: [], error: [] };
|
|
return {
|
|
calls,
|
|
debug: (m) => calls.debug.push(m),
|
|
info: (m) => calls.info.push(m),
|
|
warn: (m) => calls.warn.push(m),
|
|
error: (m) => calls.error.push(m),
|
|
};
|
|
}
|
|
|
|
function makeHost({
|
|
mode = 'auto',
|
|
allowedActions = new Set(['execsequence', 'execmovement', 'flowmovement', 'emergencystop', 'statuscheck', 'entermaintenance', 'exitmaintenance']),
|
|
allowedSources = true,
|
|
setpointError,
|
|
} = {}) {
|
|
const logger = makeLogger();
|
|
const host = {
|
|
logger,
|
|
currentMode: mode,
|
|
unitPolicy: {
|
|
canonical: { flow: 'm3/s' },
|
|
output: { flow: 'm3/h' },
|
|
convert: (val, from, to, label) => {
|
|
host.calls.convertUnit.push({ val, from, to, label });
|
|
return val * 1000; // pretend m3/h -> m3/s factor
|
|
},
|
|
},
|
|
isValidActionForMode: (action) => allowedActions.has(action),
|
|
isValidSourceForMode: () => allowedSources,
|
|
calls: { executeSequence: [], setpoint: [], calcCtrl: [], convertUnit: [] },
|
|
async executeSequence(seq) { host.calls.executeSequence.push(seq); return { ran: seq }; },
|
|
async setpoint(sp) {
|
|
host.calls.setpoint.push(sp);
|
|
if (setpointError) throw setpointError;
|
|
return { moved: sp };
|
|
},
|
|
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; },
|
|
};
|
|
return host;
|
|
}
|
|
|
|
test('handle("parent","execSequence","startup") triggers executeSequence', async () => {
|
|
const host = makeHost();
|
|
const fc = new FlowController({ host });
|
|
const result = await fc.handle('parent', 'execSequence', 'startup');
|
|
assert.deepEqual(host.calls.executeSequence, ['startup']);
|
|
assert.deepEqual(result, { ran: 'startup' });
|
|
});
|
|
|
|
test('handle("parent","execMovement",50) invokes setpoint(50)', async () => {
|
|
const host = makeHost();
|
|
const fc = new FlowController({ host });
|
|
const result = await fc.handle('parent', 'execMovement', 50);
|
|
assert.deepEqual(host.calls.setpoint, [50]);
|
|
assert.deepEqual(result, { moved: 50 });
|
|
});
|
|
|
|
test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => {
|
|
const host = makeHost();
|
|
const fc = new FlowController({ host });
|
|
await fc.handle('parent', 'flowMovement', 36);
|
|
assert.equal(host.calls.convertUnit.length, 1);
|
|
assert.equal(host.calls.convertUnit[0].from, 'm3/h');
|
|
assert.equal(host.calls.convertUnit[0].to, 'm3/s');
|
|
assert.deepEqual(host.calls.calcCtrl, [36 * 1000]);
|
|
assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]);
|
|
});
|
|
|
|
test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => {
|
|
const host = makeHost();
|
|
const fc = new FlowController({ host });
|
|
await fc.handle('parent', 'emergencyStop');
|
|
assert.deepEqual(host.calls.executeSequence, ['emergencystop']);
|
|
assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m)));
|
|
});
|
|
|
|
test('handle rejects non-string action', async () => {
|
|
const host = makeHost();
|
|
const fc = new FlowController({ host });
|
|
await fc.handle('parent', 123, 'x');
|
|
assert.deepEqual(host.calls.executeSequence, []);
|
|
assert.deepEqual(host.calls.setpoint, []);
|
|
assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m)));
|
|
});
|
|
|
|
test('handle bails out when action not allowed for mode', async () => {
|
|
const host = makeHost({ allowedActions: new Set(['statuscheck']) });
|
|
const fc = new FlowController({ host });
|
|
await fc.handle('parent', 'execSequence', 'startup');
|
|
assert.deepEqual(host.calls.executeSequence, []);
|
|
});
|
|
|
|
test('handle bails out when source not allowed for mode', async () => {
|
|
const host = makeHost({ allowedSources: false });
|
|
const fc = new FlowController({ host });
|
|
await fc.handle('externalApi', 'execSequence', 'startup');
|
|
assert.deepEqual(host.calls.executeSequence, []);
|
|
});
|
|
|
|
test('handle catches downstream errors and logs them (does not propagate)', async () => {
|
|
const host = makeHost({ setpointError: new Error('boom') });
|
|
const fc = new FlowController({ host });
|
|
const result = await fc.handle('parent', 'execMovement', 12);
|
|
assert.equal(result, undefined);
|
|
assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m)));
|
|
});
|
|
|
|
test('handle returns a success envelope for statuscheck', async () => {
|
|
const host = makeHost();
|
|
const fc = new FlowController({ host });
|
|
const out = await fc.handle('parent', 'statusCheck');
|
|
assert.equal(out.status, true);
|
|
assert.ok(out.feedback.includes('statuscheck'));
|
|
});
|
|
|
|
test('handle warns on unimplemented action', async () => {
|
|
const host = makeHost({ allowedActions: new Set(['weirdaction']) });
|
|
const fc = new FlowController({ host });
|
|
await fc.handle('parent', 'weirdAction');
|
|
assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m)));
|
|
});
|
|
|
|
test('constructor validates host', () => {
|
|
assert.throws(() => new FlowController({}), /ctx\.host is required/);
|
|
});
|