Compare commits

..

1 Commits

Author SHA1 Message Date
znetsixe
c84dd781a3 P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:09 +02:00
9 changed files with 636 additions and 285 deletions

View File

@@ -1,65 +1,83 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _loadConfig / _setupClass private methods are gone —
// config build is exposed via buildDomainConfig (override hook in
// CONTRACTS.md §2), and engine selection is observable via
// `inst.source.engine instanceof Reactor_CSTR | Reactor_PFR` after a
// full `new nodeClass(...)` construction.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass'); const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass');
const { makeUiConfig } = require('../helpers/factories'); const { makeUiConfig } = require('../helpers/factories');
// These tests pinned the old private _loadConfig / _setupClass methods on function makeRED() { return { nodes: { getNode: () => null } }; }
// the pre-refactor nodeClass. After the BaseNodeAdapter migration the
// same logic lives in buildDomainConfig + the Reactor wrapper's engine function makeNode(id = 'reactor-1') {
// selector. We exercise both surfaces directly. const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('buildDomainConfig coerces numeric fields and builds initial state vector', () => { test('buildDomainConfig coerces numeric fields and builds initial state vector', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
inst.node = { id: 'n-reactor-1' }; const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.name = 'reactor'; try {
const dc = inst.buildDomainConfig( const dc = inst.buildDomainConfig(
makeUiConfig({ makeUiConfig({
volume: '12.5', volume: '12.5',
length: '9', length: '9',
resolution_L: '7', resolution_L: '7',
alpha: '0.5', alpha: '0.5',
n_inlets: '3', n_inlets: '3',
timeStep: '2', timeStep: '2',
S_O_init: '1.1', S_O_init: '1.1',
}), }),
); );
assert.equal(dc.reactor.volume, 12.5); assert.equal(dc.reactor.volume, 12.5);
assert.equal(dc.reactor.length, 9); assert.equal(dc.reactor.length, 9);
assert.equal(dc.reactor.resolution_L, 7); assert.equal(dc.reactor.resolution_L, 7);
assert.equal(dc.reactor.alpha, 0.5); assert.equal(dc.reactor.alpha, 0.5);
assert.equal(dc.reactor.n_inlets, 3); assert.equal(dc.reactor.n_inlets, 3);
assert.equal(dc.reactor.timeStep, 2); assert.equal(dc.reactor.timeStep, 2);
assert.equal(Object.keys(dc.initialState).length, 13); assert.equal(Object.keys(dc.initialState).length, 13);
assert.equal(dc.initialState.S_O, 1.1); assert.equal(dc.initialState.S_O, 1.1);
} finally {
closeNode(node);
}
}); });
test('Reactor wrapper instantiates CSTR engine when configured as CSTR', () => { test('Reactor wrapper instantiates CSTR engine when configured as CSTR', () => {
const Reactor = require('../../src/specificClass'); const node = makeNode();
const config = { const inst = new nodeClass(makeUiConfig({ reactor_type: 'CSTR' }), makeRED(), node, 'reactor');
general: { name: 'reactor', id: 'n', logging: { enabled: false, logLevel: 'error' } }, try {
functionality: { softwareType: 'reactor', positionVsParent: 'atEquipment' }, assert.ok(inst.source.engine instanceof Reactor_CSTR);
reactor: { reactor_type: 'CSTR', volume: 100, length: 10, resolution_L: 5, alpha: 0, } finally {
n_inlets: 1, kla: NaN, timeStep: 1 }, closeNode(node);
initialState: { S_O: 0, S_I: 30, S_S: 100, S_NH: 16, S_N2: 0, S_NO: 0, S_HCO: 5, }
X_I: 25, X_S: 75, X_H: 30, X_STO: 0, X_A: 0.001, X_TS: 125 },
};
const r = new Reactor(config);
assert.ok(r.engine instanceof Reactor_CSTR);
}); });
test('Reactor wrapper instantiates PFR engine when configured as PFR', () => { test('Reactor wrapper instantiates PFR engine when configured as PFR', () => {
const Reactor = require('../../src/specificClass'); const node = makeNode();
const config = { const inst = new nodeClass(makeUiConfig({ reactor_type: 'PFR' }), makeRED(), node, 'reactor');
general: { name: 'reactor', id: 'n', logging: { enabled: false, logLevel: 'error' } }, try {
functionality: { softwareType: 'reactor', positionVsParent: 'atEquipment' }, assert.ok(inst.source.engine instanceof Reactor_PFR);
reactor: { reactor_type: 'PFR', volume: 100, length: 10, resolution_L: 5, alpha: 0, } finally {
n_inlets: 1, kla: NaN, timeStep: 1 }, closeNode(node);
initialState: { S_O: 0, S_I: 30, S_S: 100, S_NH: 16, S_N2: 0, S_NO: 0, S_HCO: 5, }
X_I: 25, X_S: 75, X_H: 30, X_STO: 0, X_A: 0.001, X_TS: 125 },
};
const r = new Reactor(config);
assert.ok(r.engine instanceof Reactor_PFR);
}); });

View File

@@ -1,56 +1,111 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _attachInputHandler private switch is gone — input
// dispatch goes through the commands registry that BaseNodeAdapter builds
// at construction. Tests fire msgs through `node.handlers.input` and
// observe via `node.sends`, `inst.source.engine.*`, and per-fire calls
// captured on a child stub registered through `RED.nodes.getNode(id)`.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands'); const { makeUiConfig } = require('../helpers/factories');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
// Post-refactor: dispatch goes through the commands registry built by function makeNode(id = 'reactor-1') {
// BaseNodeAdapter (this._commands). We seed the registry on a prototype- const sends = [];
// derived instance, then drive _attachInputHandler the same way the live const statuses = [];
// adapter would. const handlers = {};
return {
test('input handler routes legacy topic aliases to engine setters', async () => { id, sends, statuses, handlers,
const inst = Object.create(NodeClass.prototype); send(arr) { sends.push(arr); },
const node = makeNodeStub(); status(b) { statuses.push(b); },
const calls = []; on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
const source = {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
updateState(t) { calls.push(['clock', t]); },
childRegistrationUtils: {
registerChild(childSource, position) { calls.push(['registerChild', childSource, position]); },
},
}; };
}
Object.defineProperty(source, 'setInfluent', { set(v) { calls.push(['Fluent', v]); } }); function makeRED(nodeMap = {}) {
Object.defineProperty(source, 'setOTR', { set(v) { calls.push(['OTR', v]); } }); return { nodes: { getNode: (id) => nodeMap[id] || null } };
Object.defineProperty(source, 'setTemperature', { set(v) { calls.push(['Temperature', v]); } }); }
Object.defineProperty(source, 'setDispersion', { set(v) { calls.push(['Dispersion', v]); } });
inst.node = node; function closeNode(node) {
inst.RED = makeREDStub({ childA: { source: { id: 'child-source-A' } } }); if (node.handlers.close) node.handlers.close(() => {});
inst.source = source; }
inst._commands = createRegistry(commands, { logger: source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input; test('legacy alias topics drive engine setters and updateState', async () => {
let doneCount = 0; const childSource = {
const done = () => { doneCount += 1; }; id: 'child-source-A',
config: { general: { id: 'child-source-A' }, functionality: { softwareType: 'measurement', positionVsParent: 'upstream' }, asset: { type: 'temperature' } },
};
const node = makeNode();
const RED = makeRED({ childA: { source: childSource } });
const inst = new nodeClass(makeUiConfig(), RED, node, 'reactor');
await onInput({ topic: 'clock', timestamp: 1000 }, () => {}, done); try {
await onInput({ topic: 'Fluent', payload: { inlet: 0, F: 10, C: [] } }, () => {}, done); let doneCount = 0;
await onInput({ topic: 'OTR', payload: 3.5 }, () => {}, done); const done = () => { doneCount += 1; };
await onInput({ topic: 'Temperature', payload: 18.2 }, () => {}, done);
await onInput({ topic: 'Dispersion', payload: 0.2 }, () => {}, done);
await onInput({ topic: 'registerChild', payload: 'childA', positionVsParent: 'upstream' }, () => {}, done);
assert.equal(doneCount, 6); // data.clock alias → updateState(timestamp). Capture currentTime
assert.deepEqual(calls[0], ['clock', 1000]); // before/after to verify the engine advanced.
assert.equal(calls.some((x) => x[0] === 'Fluent'), true); const t0 = inst.source.engine.currentTime;
assert.equal(calls.some((x) => x[0] === 'OTR'), true); await node.handlers.input({ topic: 'clock', timestamp: t0 + 1 }, () => {}, done);
assert.equal(calls.some((x) => x[0] === 'Temperature'), true);
assert.equal(calls.some((x) => x[0] === 'Dispersion'), true); // Fluent alias → engine setInfluent setter.
assert.deepEqual(calls.at(-1), ['registerChild', { id: 'child-source-A' }, 'upstream']); await node.handlers.input(
{ topic: 'Fluent', payload: { inlet: 0, F: 7, C: [1,2,3,4,5,6,7,8,9,10,11,12,13] } },
() => {}, done,
);
assert.equal(inst.source.engine.Fs[0], 7);
assert.deepEqual(inst.source.engine.Cs_in[0], [1,2,3,4,5,6,7,8,9,10,11,12,13]);
// OTR alias → engine setOTR setter.
await node.handlers.input({ topic: 'OTR', payload: 3.5 }, () => {}, done);
assert.equal(inst.source.engine.OTR, 3.5);
// Temperature alias → engine setTemperature setter.
await node.handlers.input({ topic: 'Temperature', payload: 18.2 }, () => {}, done);
assert.equal(inst.source.engine.temperature, 18.2);
// Dispersion alias — CSTR engine does not own a setDispersion setter
// (only PFR does); the Reactor wrapper guards on engine type and the
// dispatch should silently return without throwing.
await node.handlers.input({ topic: 'Dispersion', payload: 0.2 }, () => {}, done);
// registerChild alias → registers via childRegistrationUtils.
// The handler resolves the child via RED.nodes.getNode(payload).source.
await node.handlers.input(
{ topic: 'registerChild', payload: 'childA', positionVsParent: 'upstream' },
() => {}, done,
);
assert.equal(doneCount, 6);
} finally {
closeNode(node);
}
});
test('canonical topics are accepted (data.fluent, data.otr, data.temperature)', async () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await node.handlers.input(
{ topic: 'data.fluent', payload: { inlet: 0, F: 11, C: [0,0,0,0,0,0,0,0,0,0,0,0,0] } },
() => {}, () => { done += 1; },
);
assert.equal(inst.source.engine.Fs[0], 11);
await node.handlers.input({ topic: 'data.otr', payload: 4.2 }, () => {}, () => { done += 1; });
assert.equal(inst.source.engine.OTR, 4.2);
await node.handlers.input({ topic: 'data.temperature', payload: 19.7 }, () => {}, () => { done += 1; });
assert.equal(inst.source.engine.temperature, 19.7);
assert.equal(done, 3);
} finally {
closeNode(node);
}
}); });

View File

@@ -1,32 +1,84 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _registerChild method was renamed to
// _scheduleRegistration inside BaseNodeAdapter and now fires automatically
// 100ms after construction. We verify the emission by capturing the Port-2
// message on `node.sends` after the registration delay elapses.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const { makeNodeStub } = require('../helpers/factories'); const { makeUiConfig } = require('../helpers/factories');
// Post-refactor: BaseNodeAdapter handles registration via _scheduleRegistration function makeRED() { return { nodes: { getNode: () => null } }; }
// (was _registerChild). Topic moved from 'registerChild' to 'child.register'.
test('_scheduleRegistration emits delayed child.register message on output 2', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node; function makeNode(id = 'reactor-node-1') {
inst.config = { functionality: { positionVsParent: 'downstream', distance: null } }; const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
const originalSetTimeout = global.setTimeout; function closeNode(node) {
const delays = []; if (node.handlers.close) node.handlers.close(() => {});
global.setTimeout = (fn, ms) => { delays.push(ms); fn(); return 1; }; }
test('scheduled child.register message lands on Port 2 after construction', async () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ positionVsParent: 'downstream' }),
makeRED(),
node,
'reactor',
);
try { try {
inst._scheduleRegistration(); // BaseNodeAdapter._scheduleRegistration uses a 100ms setTimeout; wait
} finally { // slightly longer to let it fire.
global.setTimeout = originalSetTimeout; await new Promise((r) => setTimeout(r, 130));
}
assert.deepEqual(delays, [100]); // The registration send is the [null, null, {child.register}] triple.
assert.equal(node._sent.length, 1); const regSends = node.sends.filter(
assert.equal(Array.isArray(node._sent[0]), true); (s) => Array.isArray(s) && s[0] === null && s[1] === null && s[2] && s[2].topic === 'child.register',
assert.equal(node._sent[0][2].topic, 'child.register'); );
assert.equal(node._sent[0][2].payload, node.id); assert.equal(regSends.length, 1, 'exactly one child.register message expected');
assert.equal(node._sent[0][2].positionVsParent, 'downstream'); const msg = regSends[0][2];
assert.equal(msg.topic, 'child.register');
assert.equal(msg.payload, node.id);
assert.equal(msg.positionVsParent, 'downstream');
// After construction the source is exposed on the node for sibling lookup.
assert.strictEqual(node.source, inst.source);
} finally {
closeNode(node);
}
});
test('child.register handler ignores unknown child ids without throwing', async () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'child.register', payload: 'missing-child', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
// No child should have been registered into the engine's registry.
const registered = inst.source.engine.childRegistrationUtils;
assert.ok(registered, 'childRegistrationUtils exists on engine');
} finally {
closeNode(node);
}
}); });

View File

@@ -1,28 +1,52 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface for
// the nodeClass-level checks, and the public Reactor_CSTR engine surface
// for the domain-level checks. The pre-refactor private nodeClass methods
// are gone — `buildDomainConfig` is the documented override hook
// (CONTRACTS.md §2) and is fair game to call on a real constructed
// instance.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR } = require('../../src/specificClass'); const { Reactor_CSTR } = require('../../src/specificClass');
const NodeClass = require('../../src/nodeClass');
const { makeReactorConfig, makeUiConfig } = require('../helpers/factories'); const { makeReactorConfig, makeUiConfig } = require('../helpers/factories');
/** function makeRED() { return { nodes: { getNode: () => null } }; }
* Smoke tests for Fix 3: configurable speedUpFactor on Reactor.
*/
test('specificClass defaults speedUpFactor to 1 when not in config', () => { function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('Reactor_CSTR engine defaults speedUpFactor to 1 when not in config', () => {
const config = makeReactorConfig(); const config = makeReactorConfig();
const reactor = new Reactor_CSTR(config); const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 1, 'speedUpFactor should default to 1'); assert.equal(reactor.speedUpFactor, 1, 'speedUpFactor should default to 1');
}); });
test('specificClass accepts speedUpFactor from config', () => { test('Reactor_CSTR engine accepts speedUpFactor from config', () => {
const config = makeReactorConfig(); const config = makeReactorConfig();
config.speedUpFactor = 10; config.speedUpFactor = 10;
const reactor = new Reactor_CSTR(config); const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 10, 'speedUpFactor should be read from config'); assert.equal(reactor.speedUpFactor, 10, 'speedUpFactor should be read from config');
}); });
test('specificClass accepts speedUpFactor = 60 for accelerated simulation', () => { test('Reactor_CSTR engine accepts speedUpFactor = 60 for accelerated simulation', () => {
const config = makeReactorConfig(); const config = makeReactorConfig();
config.speedUpFactor = 60; config.speedUpFactor = 60;
const reactor = new Reactor_CSTR(config); const reactor = new Reactor_CSTR(config);
@@ -30,21 +54,27 @@ test('specificClass accepts speedUpFactor = 60 for accelerated simulation', () =
}); });
test('buildDomainConfig propagates speedUpFactor from uiConfig', () => { test('buildDomainConfig propagates speedUpFactor from uiConfig', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
inst.node = { id: 'n-reactor' }; const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.name = 'reactor'; try {
const dc = inst.buildDomainConfig(makeUiConfig({ speedUpFactor: 5 })); const dc = inst.buildDomainConfig(makeUiConfig({ speedUpFactor: 5 }));
assert.equal(dc.reactor.speedUpFactor, 5); assert.equal(dc.reactor.speedUpFactor, 5);
} finally {
closeNode(node);
}
}); });
test('buildDomainConfig defaults speedUpFactor to 1 when missing from uiConfig', () => { test('buildDomainConfig defaults speedUpFactor to 1 when missing from uiConfig', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
inst.node = { id: 'n-reactor' }; const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.name = 'reactor'; try {
const ui = makeUiConfig(); const ui = makeUiConfig();
delete ui.speedUpFactor; delete ui.speedUpFactor;
const dc = inst.buildDomainConfig(ui); const dc = inst.buildDomainConfig(ui);
assert.equal(dc.reactor.speedUpFactor, 1); assert.equal(dc.reactor.speedUpFactor, 1);
} finally {
closeNode(node);
}
}); });
test('updateState with speedUpFactor=1 advances roughly real-time', () => { test('updateState with speedUpFactor=1 advances roughly real-time', () => {

View File

@@ -1,21 +1,65 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The schema validator coerces `reactor_type` through the enum — values
// outside `CSTR` / `PFR` are remapped to the default `CSTR` at validation
// time. The Reactor wrapper additionally falls back to CSTR if anything
// unrecognised slips through (defensive guard). Either way, the observable
// effect after `new nodeClass(...)` is `inst.source.engine instanceof
// Reactor_CSTR`.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const Reactor = require('../../src/specificClass'); const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR } = require('../../src/specificClass'); const { Reactor_CSTR } = require('../../src/specificClass');
const { makeUiConfig } = require('../helpers/factories');
// Post-refactor: an unknown reactor_type falls back to CSTR and warns, function makeRED() { return { nodes: { getNode: () => null } }; }
// rather than throwing.
test('Reactor wrapper falls back to CSTR when reactor_type is unknown', () => { function makeNode(id = 'reactor-node-1') {
const config = { const sends = [];
general: { name: 'reactor', id: 'n', logging: { enabled: false, logLevel: 'error' } }, const statuses = [];
functionality: { softwareType: 'reactor', positionVsParent: 'atEquipment' }, const handlers = {};
reactor: { reactor_type: 'UNKNOWN_TYPE', volume: 100, length: 10, resolution_L: 5, return {
alpha: 0, n_inlets: 1, kla: NaN, timeStep: 1 }, id, sends, statuses, handlers,
initialState: { S_O: 0, S_I: 30, S_S: 100, S_NH: 16, S_N2: 0, S_NO: 0, S_HCO: 5, send(arr) { sends.push(arr); },
X_I: 25, X_S: 75, X_H: 30, X_STO: 0, X_A: 0.001, X_TS: 125 }, status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
}; };
}
const r = new Reactor(config); function closeNode(node) {
assert.ok(r.engine instanceof Reactor_CSTR); if (node.handlers.close) node.handlers.close(() => {});
}
test('Reactor wrapper falls back to CSTR when reactor_type is unknown', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'UNKNOWN_TYPE' }),
makeRED(),
node,
'reactor',
);
try {
assert.ok(inst.source.engine instanceof Reactor_CSTR);
} finally {
closeNode(node);
}
});
test('Reactor wrapper falls back to CSTR when reactor_type is empty string', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: '' }),
makeRED(),
node,
'reactor',
);
try {
assert.ok(inst.source.engine instanceof Reactor_CSTR);
} finally {
closeNode(node);
}
}); });

View File

@@ -1,31 +1,68 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface. The
// commands registry built by BaseNodeAdapter logs a warn on unknown topics
// and still calls done — no throw.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands'); const { makeUiConfig } = require('../helpers/factories');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories'); function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('unknown input topic does not throw and still calls done', async () => { test('unknown input topic does not throw and still calls done', async () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
const node = makeNodeStub(); new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.node = node; try {
inst.RED = makeREDStub(); let doneCalled = 0;
inst.source = { await assert.doesNotReject(async () => {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }, await node.handlers.input(
childRegistrationUtils: { registerChild() {} }, { topic: 'somethingUnknown', payload: 1 },
updateState() {}, () => {},
}; () => { doneCalled += 1; },
inst._commands = createRegistry(commands, { logger: inst.source.logger }); );
inst._attachInputHandler();
let doneCalled = 0;
await assert.doesNotReject(async () => {
await node._handlers.input({ topic: 'somethingUnknown', payload: 1 }, () => {}, () => {
doneCalled += 1;
}); });
}); assert.equal(doneCalled, 1);
} finally {
assert.equal(doneCalled, 1); closeNode(node);
}
});
test('missing topic field is handled gracefully', async () => {
const node = makeNode();
new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let doneCalled = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ payload: 'no-topic-here' },
() => {},
() => { doneCalled += 1; },
);
});
assert.equal(doneCalled, 1);
} finally {
closeNode(node);
}
}); });

View File

@@ -1,29 +1,91 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// A child.register / registerChild msg with an unknown id should resolve
// to no-op (the handler logs warn, no throw) and still call done.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands'); const { makeUiConfig } = require('../helpers/factories');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('registerChild with unknown node id is ignored without throwing', async () => { function makeRED(nodeMap = {}) {
const inst = Object.create(NodeClass.prototype); return { nodes: { getNode: (id) => nodeMap[id] || null } };
const node = makeNodeStub(); }
inst.node = node; function makeNode(id = 'reactor-node-1') {
inst.RED = makeREDStub(); const sends = [];
inst.source = { const statuses = [];
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }, const handlers = {};
childRegistrationUtils: { registerChild() {} }, return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
}; };
inst._commands = createRegistry(commands, { logger: inst.source.logger }); }
inst._attachInputHandler();
await assert.doesNotReject(async () => { function closeNode(node) {
await node._handlers.input( if (node.handlers.close) node.handlers.close(() => {});
{ topic: 'registerChild', payload: 'missing-child', positionVsParent: 'upstream' }, }
() => {},
() => {}, test('registerChild alias with unknown id is ignored without throwing', async () => {
); const node = makeNode();
}); new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'registerChild', payload: 'missing-child', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
} finally {
closeNode(node);
}
});
test('child.register canonical topic with unknown id is ignored without throwing', async () => {
const node = makeNode();
new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'child.register', payload: 'missing-child', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
} finally {
closeNode(node);
}
});
test('child.register with a child that has no .source is ignored without throwing', async () => {
const node = makeNode();
// The looked-up RED node exists but lacks a `.source` — the handler
// guards against this and logs warn.
new nodeClass(makeUiConfig(), makeRED({ orphan: {} }), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'child.register', payload: 'orphan', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
} finally {
closeNode(node);
}
}); });

View File

@@ -1,103 +1,156 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _tick / _startTickLoop methods are gone — periodic
// emission lives in `_emitOutputs()` (overridden in the reactor nodeClass
// to preserve the Fluent / GridProfile Port-0 contract; delta-compressed
// payloads can't carry the C-vector). The override is part of the
// documented BaseNodeAdapter override surface, so we exercise it
// directly. The fully-constructed adapter wires `inst.source.engine`,
// `inst._output`, etc. so we don't have to assemble stub bags.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const { makeNodeStub } = require('../helpers/factories'); const { makeUiConfig } = require('../helpers/factories');
// Post-refactor: BaseNodeAdapter drives tick + status loops. The reactor function makeRED() { return { nodes: { getNode: () => null } }; }
// nodeClass overrides _emitOutputs to preserve the Fluent / GridProfile
// Port-0 contract (delta-compressed payloads can't carry the C-vector).
test('_emitOutputs emits effluent on process output', () => { function makeNode(id = 'reactor-node-1') {
const inst = Object.create(NodeClass.prototype); const sends = [];
const node = makeNodeStub(); const statuses = [];
const handlers = {};
inst.node = node; return {
inst.config = { functionality: { softwareType: 'reactor' }, general: { id: 'r-1' } }; id, sends, statuses, handlers,
inst._output = { formatMsg() { return null; } }; send(arr) { sends.push(arr); },
inst.source = { status(b) { statuses.push(b); },
engine: { temperature: 18, getEffluent: { topic: 'Fluent', payload: { inlet: 0, F: 1, C: [] }, timestamp: 1 }, get getGridProfile() { return null; } }, on(ev, fn) { handlers[ev] = fn; },
config: inst.config, warn() {}, error() {},
updateState() {},
get getEffluent() { return this.engine.getEffluent; },
get getGridProfile() { return this.engine.getGridProfile; },
getOutput() { return {}; },
}; };
}
inst._emitOutputs(); function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
assert.equal(node._sent.length, 1); function pickEffluentSends(node) {
assert.equal(node._sent[0][0].topic, 'Fluent'); return node.sends.filter((s) => Array.isArray(s) && s[0] && s[0].topic === 'Fluent');
assert.equal(node._sent[0][1], null); }
assert.equal(node._sent[0][2], null);
function pickGridSends(node) {
return node.sends.filter((s) => Array.isArray(s) && s[0] && s[0].topic === 'GridProfile');
}
test('_emitOutputs sends the effluent message on process output (CSTR)', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
try {
// Reset sends so any construction-time emissions don't pollute the
// assertion (the registration triple lands on the same buffer).
node.sends.length = 0;
inst._emitOutputs();
const fluentSends = pickEffluentSends(node);
assert.equal(fluentSends.length, 1, 'exactly one Fluent message');
const triple = fluentSends[0];
assert.equal(triple[0].topic, 'Fluent');
assert.ok(triple[0].payload && Array.isArray(triple[0].payload.C));
// CSTR has no grid profile.
assert.equal(pickGridSends(node).length, 0);
} finally {
closeNode(node);
}
}); });
test('_emitOutputs emits reactor telemetry on influx output', () => { test('_emitOutputs emits a GridProfile message when engine exposes one (PFR)', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
const node = makeNodeStub(); const inst = new nodeClass(
let captured = null; makeUiConfig({ reactor_type: 'PFR' }),
makeRED(),
node,
'reactor',
);
inst.node = node; try {
inst.config = { functionality: { softwareType: 'reactor' }, general: { id: 'reactor-node-1' } }; node.sends.length = 0;
inst._output = { inst._emitOutputs();
formatMsg(output, _config, format) {
captured = { output, format };
return { topic: `reactor_${inst.config.general.id}`, payload: { measurement: 'reactor', fields: output } };
},
};
const effluent = { topic: 'Fluent', payload: { inlet: 0, F: 42, C: [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500] }, timestamp: 1 };
inst.source = {
engine: { temperature: 19.5, getEffluent: effluent, get getGridProfile() { return null; } },
config: inst.config,
updateState() {},
get getEffluent() { return this.engine.getEffluent; },
get getGridProfile() { return this.engine.getGridProfile; },
getOutput() {
const C = effluent.payload.C;
const out = { flow_total: effluent.payload.F, temperature: 19.5 };
const keys = ['S_O','S_I','S_S','S_NH','S_N2','S_NO','S_HCO','X_I','X_S','X_H','X_STO','X_A','X_TS'];
for (let i = 0; i < keys.length; i += 1) out[keys[i]] = C[i];
return out;
},
};
inst._emitOutputs(); assert.equal(pickGridSends(node).length, 1, 'exactly one GridProfile message');
assert.equal(pickEffluentSends(node).length, 1, 'exactly one Fluent message');
assert.equal(node._sent.length, 1); } finally {
assert.equal(node._sent[0][0].topic, 'Fluent'); closeNode(node);
assert.equal(node._sent[0][1].topic, 'reactor_reactor-node-1'); }
assert.equal(captured.format, 'influxdb');
assert.equal(captured.output.flow_total, 42);
assert.equal(captured.output.temperature, 19.5);
assert.equal(captured.output.S_O, 2.1);
assert.equal(captured.output.S_NH, 16);
assert.equal(captured.output.X_TS, 2500);
}); });
test('_emitOutputs also emits GridProfile when engine exposes one', () => { test('_emitOutputs formats per-species influx telemetry via outputUtils', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
const node = makeNodeStub(); const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
inst.node = node; try {
inst.config = { functionality: { softwareType: 'reactor' }, general: { id: 'r-1' } }; // Stub updateState so the engine integration does not overwrite the
inst._output = { formatMsg() { return null; } }; // engineered state we want the telemetry formatter to see.
const grid = { grid: [[0]], n_x: 1, d_x: 1, length: 1, species: [], timestamp: 1 }; inst.source.updateState = () => {};
inst.source = { inst.source.engine.setInfluent = {
engine: { payload: { inlet: 0, F: 42, C: [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500] },
temperature: 18, };
getEffluent: { topic: 'Fluent', payload: { inlet: 0, F: 1, C: [] }, timestamp: 1 }, inst.source.engine.state = [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500];
get getGridProfile() { return grid; }, inst.source.engine.temperature = 19.5;
},
config: inst.config,
updateState() {},
get getEffluent() { return this.engine.getEffluent; },
get getGridProfile() { return this.engine.getGridProfile; },
getOutput() { return {}; },
};
inst._emitOutputs(); let captured = null;
const realFormat = inst._output.formatMsg.bind(inst._output);
inst._output.formatMsg = (output, cfg, format) => {
if (format === 'influxdb') captured = { output, format };
return realFormat(output, cfg, format);
};
assert.equal(node._sent.length, 2); node.sends.length = 0;
assert.equal(node._sent[0][0].topic, 'GridProfile'); inst._emitOutputs();
assert.equal(node._sent[1][0].topic, 'Fluent');
assert.ok(captured, 'formatMsg was called with influxdb format');
assert.equal(captured.format, 'influxdb');
assert.equal(captured.output.flow_total, 42);
assert.equal(captured.output.temperature, 19.5);
assert.equal(captured.output.S_O, 2.1);
assert.equal(captured.output.S_NH, 16);
assert.equal(captured.output.X_TS, 2500);
} finally {
closeNode(node);
}
});
test('Reactor.tick(dt) drives the kinetics engine and advances state', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
try {
// Feed an influent so the integrator has something to chew on.
inst.source.engine.setInfluent = {
payload: { inlet: 0, F: 5, C: [0,30,100,16,0,0,5,25,75,30,0,0.001,125] },
};
const stateBefore = JSON.stringify(inst.source.engine.state);
inst.source.tick(0.001);
const stateAfter = JSON.stringify(inst.source.engine.state);
assert.notEqual(stateBefore, stateAfter, 'engine state should advance after tick(dt)');
} finally {
closeNode(node);
}
}); });

View File

@@ -85,14 +85,14 @@ flowchart TB
<!-- BEGIN AUTOGEN: topic-contract --> <!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Effect | | Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---| |---|---|---|---|---|
| `data.clock` | `clock` | `any` | Pushes a value into the node's measurement stream. | | `data.clock` | `clock` | `any` | — | Push the simulation clock tick (timestamp / dt) to the ASM solver. |
| `data.fluent` | `Fluent` | `object` | Pushes a value into the node's measurement stream. | | `data.fluent` | `Fluent` | `object` | — | Push the influent stream (payload: {F: flow m3/h, C: [concentrations mg/L]}). |
| `data.otr` | `OTR` | `any` | Pushes a value into the node's measurement stream. | | `data.otr` | `OTR` | `any` | — | Push the current oxygen-transfer rate into the reactor. |
| `data.temperature` | `Temperature` | `any` | Pushes a value into the node's measurement stream. | | `data.temperature` | `Temperature` | `any` | — | Push the current reactor temperature. |
| `data.dispersion` | `Dispersion` | `any` | Pushes a value into the node's measurement stream. | | `data.dispersion` | `Dispersion` | `any` | — | Push a dispersion/mixing parameter update. |
| `child.register` | `registerChild` | `any` | Parent/child plumbing — registers or unregisters a child node. | | `child.register` | `registerChild` | `any` | — | Register a child node (settler / measurement) with this reactor. |
<!-- END AUTOGEN: topic-contract --> <!-- END AUTOGEN: topic-contract -->