From 2b9ad5fd19f2f99f55cc9b7d7a786597b68f71a4 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:37:42 +0100 Subject: [PATCH] before functional changes by codex --- examples/README.md | 8 + examples/basic.flow.json | 6 + examples/edge.flow.json | 6 + examples/integration.flow.json | 6 + package.json | 2 +- test/README.md | 12 ++ test/basic/.gitkeep | 0 test/basic/constructor.basic.test.js | 55 +++++++ test/basic/cstr-tick.basic.test.js | 42 +++++ test/basic/effluent-shape.basic.test.js | 38 +++++ test/basic/input-routing.basic.test.js | 77 +++++++++ test/basic/pfr-operators.basic.test.js | 27 ++++ test/basic/register-child.basic.test.js | 39 +++++ .../basic/structure-module-load.basic.test.js | 8 + test/edge/.gitkeep | 0 test/edge/invalid-reactor-type.edge.test.js | 15 ++ test/edge/invalid-topic.edge.test.js | 30 ++++ test/edge/missing-child.edge.test.js | 28 ++++ test/edge/pfr-measurement-grid.edge.test.js | 15 ++ .../structure-examples-node-type.edge.test.js | 11 ++ test/edge/zero-dispersion.edge.test.js | 27 ++++ test/helpers/.gitkeep | 0 test/helpers/factories.js | 149 ++++++++++++++++++ test/integration/.gitkeep | 0 ...easurement-temperature.integration.test.js | 26 +++ test/integration/otr-kla.integration.test.js | 85 ++++++++++ .../pfr-boundary.integration.test.js | 35 ++++ .../structure-examples.integration.test.js | 23 +++ .../integration/tick-loop.integration.test.js | 89 +++++++++++ .../upstream-reactor.integration.test.js | 48 ++++++ 30 files changed, 906 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/basic.flow.json create mode 100644 examples/edge.flow.json create mode 100644 examples/integration.flow.json create mode 100644 test/README.md create mode 100644 test/basic/.gitkeep create mode 100644 test/basic/constructor.basic.test.js create mode 100644 test/basic/cstr-tick.basic.test.js create mode 100644 test/basic/effluent-shape.basic.test.js create mode 100644 test/basic/input-routing.basic.test.js create mode 100644 test/basic/pfr-operators.basic.test.js create mode 100644 test/basic/register-child.basic.test.js create mode 100644 test/basic/structure-module-load.basic.test.js create mode 100644 test/edge/.gitkeep create mode 100644 test/edge/invalid-reactor-type.edge.test.js create mode 100644 test/edge/invalid-topic.edge.test.js create mode 100644 test/edge/missing-child.edge.test.js create mode 100644 test/edge/pfr-measurement-grid.edge.test.js create mode 100644 test/edge/structure-examples-node-type.edge.test.js create mode 100644 test/edge/zero-dispersion.edge.test.js create mode 100644 test/helpers/.gitkeep create mode 100644 test/helpers/factories.js create mode 100644 test/integration/.gitkeep create mode 100644 test/integration/measurement-temperature.integration.test.js create mode 100644 test/integration/otr-kla.integration.test.js create mode 100644 test/integration/pfr-boundary.integration.test.js create mode 100644 test/integration/structure-examples.integration.test.js create mode 100644 test/integration/tick-loop.integration.test.js create mode 100644 test/integration/upstream-reactor.integration.test.js diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..da90902 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,8 @@ +# reactor Example Flows + +Import-ready Node-RED examples for reactor. + +## Files +- basic.flow.json +- integration.flow.json +- edge.flow.json diff --git a/examples/basic.flow.json b/examples/basic.flow.json new file mode 100644 index 0000000..2703b64 --- /dev/null +++ b/examples/basic.flow.json @@ -0,0 +1,6 @@ +[ + {"id":"reactor_basic_tab","type":"tab","label":"reactor basic","disabled":false,"info":"reactor basic example"}, + {"id":"reactor_basic_node","type":"reactor","z":"reactor_basic_tab","name":"reactor basic","x":420,"y":180,"wires":[["reactor_basic_dbg"]]}, + {"id":"reactor_basic_inj","type":"inject","z":"reactor_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["reactor_basic_node"]]}, + {"id":"reactor_basic_dbg","type":"debug","z":"reactor_basic_tab","name":"reactor basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]} +] diff --git a/examples/edge.flow.json b/examples/edge.flow.json new file mode 100644 index 0000000..583c804 --- /dev/null +++ b/examples/edge.flow.json @@ -0,0 +1,6 @@ +[ + {"id":"reactor_edge_tab","type":"tab","label":"reactor edge","disabled":false,"info":"reactor edge example"}, + {"id":"reactor_edge_node","type":"reactor","z":"reactor_edge_tab","name":"reactor edge","x":420,"y":180,"wires":[["reactor_edge_dbg"]]}, + {"id":"reactor_edge_inj","type":"inject","z":"reactor_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["reactor_edge_node"]]}, + {"id":"reactor_edge_dbg","type":"debug","z":"reactor_edge_tab","name":"reactor edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]} +] diff --git a/examples/integration.flow.json b/examples/integration.flow.json new file mode 100644 index 0000000..4832265 --- /dev/null +++ b/examples/integration.flow.json @@ -0,0 +1,6 @@ +[ + {"id":"reactor_int_tab","type":"tab","label":"reactor integration","disabled":false,"info":"reactor integration example"}, + {"id":"reactor_int_node","type":"reactor","z":"reactor_int_tab","name":"reactor integration","x":420,"y":180,"wires":[["reactor_int_dbg"]]}, + {"id":"reactor_int_inj","type":"inject","z":"reactor_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["reactor_int_node"]]}, + {"id":"reactor_int_dbg","type":"debug","z":"reactor_int_tab","name":"reactor integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]} +] diff --git a/package.json b/package.json index 4651d19..60aa193 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "author": "P.R. van der Wilt", "main": "reactor.js", "scripts": { - "test": "node reactor.js" + "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js" }, "node-red": { "nodes": { diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..fb6ff5a --- /dev/null +++ b/test/README.md @@ -0,0 +1,12 @@ +# reactor Test Suite Layout + +Required EVOLV layout: +- basic/ +- integration/ +- edge/ +- helpers/ + +Baseline structure tests: +- basic/structure-module-load.basic.test.js +- integration/structure-examples.integration.test.js +- edge/structure-examples-node-type.edge.test.js diff --git a/test/basic/.gitkeep b/test/basic/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/basic/constructor.basic.test.js b/test/basic/constructor.basic.test.js new file mode 100644 index 0000000..e727719 --- /dev/null +++ b/test/basic/constructor.basic.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass'); +const { makeUiConfig, makeReactorConfig, makeNodeStub } = require('../helpers/factories'); + +test('_loadConfig coerces numeric fields and builds initial state vector', () => { + const inst = Object.create(NodeClass.prototype); + inst.node = { id: 'n-reactor-1' }; + inst.name = 'reactor'; + + inst._loadConfig( + makeUiConfig({ + volume: '12.5', + length: '9', + resolution_L: '7', + alpha: '0.5', + n_inlets: '3', + timeStep: '2', + S_O_init: '1.1', + }), + ); + + assert.equal(inst.config.volume, 12.5); + assert.equal(inst.config.length, 9); + assert.equal(inst.config.resolution_L, 7); + assert.equal(inst.config.alpha, 0.5); + assert.equal(inst.config.n_inlets, 3); + assert.equal(inst.config.timeStep, 2); + assert.equal(inst.config.initialState.length, 13); + assert.equal(inst.config.initialState[0], 1.1); +}); + +test('_setupClass selects Reactor_CSTR when configured as CSTR', () => { + const inst = Object.create(NodeClass.prototype); + inst.node = makeNodeStub(); + inst.config = makeReactorConfig({ reactor_type: 'CSTR' }); + + inst._setupClass(); + + assert.ok(inst.source instanceof Reactor_CSTR); + assert.equal(inst.node.source, inst.source); +}); + +test('_setupClass selects Reactor_PFR when configured as PFR', () => { + const inst = Object.create(NodeClass.prototype); + inst.node = makeNodeStub(); + inst.config = makeReactorConfig({ reactor_type: 'PFR', length: 10, resolution_L: 5 }); + + inst._setupClass(); + + assert.ok(inst.source instanceof Reactor_PFR); + assert.equal(inst.node.source, inst.source); +}); diff --git a/test/basic/cstr-tick.basic.test.js b/test/basic/cstr-tick.basic.test.js new file mode 100644 index 0000000..9986bb8 --- /dev/null +++ b/test/basic/cstr-tick.basic.test.js @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_CSTR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +const NUM_SPECIES = 13; + +test('Reactor_CSTR tick clips negative concentrations to zero', () => { + const reactor = new Reactor_CSTR( + makeReactorConfig({ + reactor_type: 'CSTR', + volume: 1, + n_inlets: 1, + kla: NaN, + S_O_init: 0.1, + S_I_init: 0.1, + S_S_init: 0.1, + S_NH_init: 0.1, + S_N2_init: 0.1, + S_NO_init: 0.1, + S_HCO_init: 0.1, + X_I_init: 0.1, + X_S_init: 0.1, + X_H_init: 0.1, + X_STO_init: 0.1, + X_A_init: 0.1, + X_TS_init: 0.1, + }), + ); + + reactor.asm = { + compute_dC: () => Array(NUM_SPECIES).fill(0), + }; + reactor.Fs[0] = 1; + reactor.Cs_in[0] = Array(NUM_SPECIES).fill(0); + + reactor.tick(1); + + assert.equal(reactor.state.every((v) => Number.isFinite(v) && v >= 0), true); + assert.equal(reactor.state.every((v) => v === 0), true); +}); diff --git a/test/basic/effluent-shape.basic.test.js b/test/basic/effluent-shape.basic.test.js new file mode 100644 index 0000000..66f77ff --- /dev/null +++ b/test/basic/effluent-shape.basic.test.js @@ -0,0 +1,38 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +test('CSTR getEffluent returns flat concentration vector', () => { + const reactor = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1 })); + reactor.state = Array.from({ length: 13 }, (_, i) => i + 1); + reactor.Fs[0] = 5; + + const effluent = reactor.getEffluent; + + assert.equal(effluent.topic, 'Fluent'); + assert.equal(effluent.payload.inlet, 0); + assert.equal(effluent.payload.F, 5); + assert.deepEqual(effluent.payload.C, reactor.state); +}); + +test('PFR getEffluent returns last slice concentration vector', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', n_inlets: 1, length: 10, resolution_L: 4 }), + ); + + reactor.state = [ + Array(13).fill(10), + Array(13).fill(20), + Array(13).fill(30), + Array(13).fill(40), + ]; + reactor.Fs[0] = 7; + + const effluent = reactor.getEffluent; + + assert.equal(effluent.topic, 'Fluent'); + assert.equal(effluent.payload.F, 7); + assert.deepEqual(effluent.payload.C, Array(13).fill(40)); +}); diff --git a/test/basic/input-routing.basic.test.js b/test/basic/input-routing.basic.test.js new file mode 100644 index 0000000..abfa4f3 --- /dev/null +++ b/test/basic/input-routing.basic.test.js @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub, makeREDStub } = require('../helpers/factories'); + +test('_attachInputHandler routes supported topics to source methods/setters', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + const calls = []; + + const source = { + updateState(timestamp) { + calls.push(['clock', timestamp]); + }, + childRegistrationUtils: { + registerChild(childSource, position) { + calls.push(['registerChild', childSource, position]); + }, + }, + }; + + Object.defineProperty(source, 'setInfluent', { + set(v) { + calls.push(['Fluent', v]); + }, + }); + + Object.defineProperty(source, 'setOTR', { + set(v) { + calls.push(['OTR', v]); + }, + }); + + Object.defineProperty(source, 'setTemperature', { + set(v) { + calls.push(['Temperature', v]); + }, + }); + + Object.defineProperty(source, 'setDispersion', { + set(v) { + calls.push(['Dispersion', v]); + }, + }); + + inst.node = node; + inst.RED = makeREDStub({ + childA: { + source: { id: 'child-source-A' }, + }, + }); + inst.source = source; + + inst._attachInputHandler(); + + const onInput = node._handlers.input; + const sent = []; + let doneCount = 0; + + onInput({ topic: 'clock', timestamp: 1000 }, (msg) => sent.push(msg), () => doneCount++); + onInput({ topic: 'Fluent', payload: { inlet: 0, F: 10, C: [] } }, () => {}, () => doneCount++); + onInput({ topic: 'OTR', payload: 3.5 }, () => {}, () => doneCount++); + onInput({ topic: 'Temperature', payload: 18.2 }, () => {}, () => doneCount++); + onInput({ topic: 'Dispersion', payload: 0.2 }, () => {}, () => doneCount++); + onInput({ topic: 'registerChild', payload: 'childA', positionVsParent: 'upstream' }, () => {}, () => doneCount++); + + assert.equal(doneCount, 6); + assert.equal(sent.length, 1); + assert.equal(Array.isArray(sent[0]), true); + assert.deepEqual(calls[0], ['clock', 1000]); + assert.equal(calls.some((x) => x[0] === 'Fluent'), true); + assert.equal(calls.some((x) => x[0] === 'OTR'), true); + assert.equal(calls.some((x) => x[0] === 'Temperature'), true); + assert.equal(calls.some((x) => x[0] === 'Dispersion'), true); + assert.deepEqual(calls.at(-1), ['registerChild', { id: 'child-source-A' }, 'upstream']); +}); diff --git a/test/basic/pfr-operators.basic.test.js b/test/basic/pfr-operators.basic.test.js new file mode 100644 index 0000000..e503329 --- /dev/null +++ b/test/basic/pfr-operators.basic.test.js @@ -0,0 +1,27 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_PFR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +test('Reactor_PFR derivative operators have expected dimensions and boundary rows', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ + reactor_type: 'PFR', + length: 12, + resolution_L: 6, + volume: 60, + n_inlets: 1, + }), + ); + + assert.equal(reactor.D_op.length, reactor.n_x); + assert.equal(reactor.D2_op.length, reactor.n_x); + assert.equal(reactor.D_op.every((row) => row.length === reactor.n_x), true); + assert.equal(reactor.D2_op.every((row) => row.length === reactor.n_x), true); + + assert.deepEqual(reactor.D_op[0], Array(reactor.n_x).fill(0)); + assert.deepEqual(reactor.D_op[reactor.n_x - 1], Array(reactor.n_x).fill(0)); + assert.deepEqual(reactor.D2_op[0], Array(reactor.n_x).fill(0)); + assert.deepEqual(reactor.D2_op[reactor.n_x - 1], Array(reactor.n_x).fill(0)); +}); diff --git a/test/basic/register-child.basic.test.js b/test/basic/register-child.basic.test.js new file mode 100644 index 0000000..92b8705 --- /dev/null +++ b/test/basic/register-child.basic.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub } = require('../helpers/factories'); + +test('_registerChild emits delayed registration message on output 2', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + + inst.node = node; + inst.config = { + functionality: { + positionVsParent: 'downstream', + }, + }; + + const originalSetTimeout = global.setTimeout; + const delays = []; + + global.setTimeout = (fn, ms) => { + delays.push(ms); + fn(); + return 1; + }; + + try { + inst._registerChild(); + } finally { + global.setTimeout = originalSetTimeout; + } + + assert.deepEqual(delays, [100]); + assert.equal(node._sent.length, 1); + assert.equal(Array.isArray(node._sent[0]), true); + assert.equal(node._sent[0][2].topic, 'registerChild'); + assert.equal(node._sent[0][2].payload, node.id); + assert.equal(node._sent[0][2].positionVsParent, 'downstream'); +}); diff --git a/test/basic/structure-module-load.basic.test.js b/test/basic/structure-module-load.basic.test.js new file mode 100644 index 0000000..b86e876 --- /dev/null +++ b/test/basic/structure-module-load.basic.test.js @@ -0,0 +1,8 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +test('reactor module load smoke', () => { + assert.doesNotThrow(() => { + require('../../reactor.js'); + }); +}); diff --git a/test/edge/.gitkeep b/test/edge/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/edge/invalid-reactor-type.edge.test.js b/test/edge/invalid-reactor-type.edge.test.js new file mode 100644 index 0000000..6892f9c --- /dev/null +++ b/test/edge/invalid-reactor-type.edge.test.js @@ -0,0 +1,15 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub, makeUiConfig } = require('../helpers/factories'); + +test('_setupClass with unknown reactor_type throws (known error-path behavior)', () => { + const inst = Object.create(NodeClass.prototype); + inst.node = makeNodeStub(); + inst.config = makeUiConfig({ reactor_type: 'UNKNOWN_TYPE' }); + + assert.throws(() => { + inst._setupClass(); + }); +}); diff --git a/test/edge/invalid-topic.edge.test.js b/test/edge/invalid-topic.edge.test.js new file mode 100644 index 0000000..e6bde22 --- /dev/null +++ b/test/edge/invalid-topic.edge.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub, makeREDStub } = require('../helpers/factories'); + +test('unknown input topic does not throw and still calls done', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + + inst.node = node; + inst.RED = makeREDStub(); + inst.source = { + childRegistrationUtils: { + registerChild() {}, + }, + updateState() {}, + }; + + inst._attachInputHandler(); + + let doneCalled = 0; + assert.doesNotThrow(() => { + node._handlers.input({ topic: 'somethingUnknown', payload: 1 }, () => {}, () => { + doneCalled += 1; + }); + }); + + assert.equal(doneCalled, 1); +}); diff --git a/test/edge/missing-child.edge.test.js b/test/edge/missing-child.edge.test.js new file mode 100644 index 0000000..6d8184a --- /dev/null +++ b/test/edge/missing-child.edge.test.js @@ -0,0 +1,28 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub, makeREDStub } = require('../helpers/factories'); + +test('registerChild with unknown node id currently throws (known robustness gap)', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + + inst.node = node; + inst.RED = makeREDStub(); + inst.source = { + childRegistrationUtils: { + registerChild() {}, + }, + }; + + inst._attachInputHandler(); + + assert.throws(() => { + node._handlers.input( + { topic: 'registerChild', payload: 'missing-child', positionVsParent: 'upstream' }, + () => {}, + () => {}, + ); + }); +}); diff --git a/test/edge/pfr-measurement-grid.edge.test.js b/test/edge/pfr-measurement-grid.edge.test.js new file mode 100644 index 0000000..16889ec --- /dev/null +++ b/test/edge/pfr-measurement-grid.edge.test.js @@ -0,0 +1,15 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_PFR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +test('oxygen measurement at exact reactor length overflows PFR grid index (known bounds gap)', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', length: 10, resolution_L: 5, n_inlets: 1 }), + ); + + assert.throws(() => { + reactor._updateMeasurement('quantity (oxygen)', 2.5, 10, {}); + }); +}); diff --git a/test/edge/structure-examples-node-type.edge.test.js b/test/edge/structure-examples-node-type.edge.test.js new file mode 100644 index 0000000..1a20777 --- /dev/null +++ b/test/edge/structure-examples-node-type.edge.test.js @@ -0,0 +1,11 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8')); + +test('basic example includes node type reactor', () => { + const count = flow.filter((n) => n && n.type === 'reactor').length; + assert.equal(count >= 1, true); +}); diff --git a/test/edge/zero-dispersion.edge.test.js b/test/edge/zero-dispersion.edge.test.js new file mode 100644 index 0000000..282c759 --- /dev/null +++ b/test/edge/zero-dispersion.edge.test.js @@ -0,0 +1,27 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_PFR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +const DAY_MS = 1000 * 60 * 60 * 24; + +test('updateState warns when local Peclet number is too high at zero dispersion', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', length: 10, resolution_L: 5, volume: 50, n_inlets: 1 }), + ); + + const warnings = []; + reactor.logger.warn = (msg) => warnings.push(String(msg)); + + reactor.currentTime = 0; + reactor.timeStep = 1; + reactor.speedUpFactor = 1; + reactor.Fs[0] = 2; + reactor.D = 0; + reactor.tick = () => reactor.state; + + reactor.updateState(DAY_MS); + + assert.equal(warnings.some((w) => w.includes('Péclet number') || w.includes('Peclet number')), true); +}); diff --git a/test/helpers/.gitkeep b/test/helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/helpers/factories.js b/test/helpers/factories.js new file mode 100644 index 0000000..892670e --- /dev/null +++ b/test/helpers/factories.js @@ -0,0 +1,149 @@ +const EventEmitter = require('node:events'); + +function makeUiConfig(overrides = {}) { + return { + name: 'reactor-test', + reactor_type: 'CSTR', + volume: 100, + length: 10, + resolution_L: 5, + alpha: 0, + n_inlets: 1, + kla: NaN, + S_O_init: 0, + S_I_init: 30, + S_S_init: 100, + S_NH_init: 16, + S_N2_init: 0, + S_NO_init: 0, + S_HCO_init: 5, + X_I_init: 25, + X_S_init: 75, + X_H_init: 30, + X_STO_init: 0, + X_A_init: 0.001, + X_TS_init: 125, + timeStep: 1, + enableLog: false, + logLevel: 'error', + positionVsParent: 'atEquipment', + ...overrides, + }; +} + +function makeReactorConfig(overrides = {}) { + const ui = makeUiConfig(overrides); + return { + general: { + id: 'reactor-node-1', + name: ui.name, + unit: null, + logging: { + enabled: ui.enableLog, + logLevel: ui.logLevel, + }, + }, + functionality: { + positionVsParent: ui.positionVsParent || 'atEquipment', + softwareType: 'reactor', + }, + reactor_type: ui.reactor_type, + volume: Number(ui.volume), + length: Number(ui.length), + resolution_L: Number(ui.resolution_L), + alpha: Number(ui.alpha), + n_inlets: Number(ui.n_inlets), + kla: Number(ui.kla), + initialState: [ + Number(ui.S_O_init), + Number(ui.S_I_init), + Number(ui.S_S_init), + Number(ui.S_NH_init), + Number(ui.S_N2_init), + Number(ui.S_NO_init), + Number(ui.S_HCO_init), + Number(ui.X_I_init), + Number(ui.X_S_init), + Number(ui.X_H_init), + Number(ui.X_STO_init), + Number(ui.X_A_init), + Number(ui.X_TS_init), + ], + timeStep: Number(ui.timeStep), + }; +} + +function makeNodeStub() { + const handlers = {}; + const sent = []; + const warns = []; + const errors = []; + const statuses = []; + + return { + id: 'reactor-node-1', + source: null, + on(event, cb) { + handlers[event] = cb; + }, + send(msg) { + sent.push(msg); + }, + warn(msg) { + warns.push(msg); + }, + error(msg) { + errors.push(msg); + }, + status(msg) { + statuses.push(msg); + }, + _handlers: handlers, + _sent: sent, + _warns: warns, + _errors: errors, + _statuses: statuses, + }; +} + +function makeREDStub(nodeMap = {}) { + return { + nodes: { + getNode(id) { + return nodeMap[id] || null; + }, + createNode() {}, + registerType() {}, + }, + httpAdmin: { + get() {}, + }, + }; +} + +function makeMeasurementChild({ + id = 'measurement-1', + name = 'temp-sensor-1', + distance = 'atEquipment', + positionVsParent = 'atEquipment', + type = 'temperature', +} = {}) { + return { + config: { + general: { id, name }, + functionality: { distance, positionVsParent, softwareType: 'measurement' }, + asset: { type }, + }, + measurements: { + emitter: new EventEmitter(), + }, + }; +} + +module.exports = { + makeUiConfig, + makeReactorConfig, + makeNodeStub, + makeREDStub, + makeMeasurementChild, +}; diff --git a/test/integration/.gitkeep b/test/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/measurement-temperature.integration.test.js b/test/integration/measurement-temperature.integration.test.js new file mode 100644 index 0000000..e82a6d9 --- /dev/null +++ b/test/integration/measurement-temperature.integration.test.js @@ -0,0 +1,26 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_CSTR } = require('../../src/specificClass'); +const { makeReactorConfig, makeMeasurementChild } = require('../helpers/factories'); + +test('measurement child temperature event updates reactor temperature', () => { + const reactor = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' })); + + const measurement = makeMeasurementChild({ + type: 'temperature', + distance: 'atEquipment', + positionVsParent: 'upstream', + }); + + reactor.registerChild(measurement, 'measurement'); + + measurement.measurements.emitter.emit('temperature.measured.atEquipment', { + childName: 'T-1', + value: 27.5, + unit: 'C', + timestamp: Date.now(), + }); + + assert.equal(reactor.temperature, 27.5); +}); diff --git a/test/integration/otr-kla.integration.test.js b/test/integration/otr-kla.integration.test.js new file mode 100644 index 0000000..8855a30 --- /dev/null +++ b/test/integration/otr-kla.integration.test.js @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +const NUM_SPECIES = 13; + +test('CSTR uses external OTR when kla is NaN', () => { + const reactor = new Reactor_CSTR( + makeReactorConfig({ reactor_type: 'CSTR', kla: NaN, n_inlets: 1 }), + ); + + reactor.asm = { + compute_dC: () => Array(NUM_SPECIES).fill(0), + }; + reactor.Fs[0] = 0; + reactor.OTR = 4; + reactor.state = Array(NUM_SPECIES).fill(0); + + reactor.tick(1); + + assert.equal(reactor.state[0], 4); +}); + +test('CSTR uses kla-based oxygen transfer when kla is finite', () => { + const reactor = new Reactor_CSTR( + makeReactorConfig({ reactor_type: 'CSTR', kla: 2, n_inlets: 1 }), + ); + + reactor.asm = { + compute_dC: () => Array(NUM_SPECIES).fill(0), + }; + reactor.Fs[0] = 0; + reactor.OTR = 1; + reactor.state = Array(NUM_SPECIES).fill(0); + + const expected = reactor._calcOTR(0, reactor.temperature); + reactor.tick(1); + + assert.ok(Math.abs(reactor.state[0] - expected) < 1e-9); +}); + +test('PFR uses external OTR branch when kla is NaN', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', kla: NaN, n_inlets: 1, length: 8, resolution_L: 6, volume: 40 }), + ); + + reactor.asm = { + compute_dC: () => Array(NUM_SPECIES).fill(0), + }; + reactor.Fs[0] = 0; + reactor.D = 0; + reactor.OTR = 3; + reactor.state = Array.from({ length: reactor.n_x }, () => Array(NUM_SPECIES).fill(0)); + + reactor.tick(1); + + assert.equal(reactor.state[1][0], 4.5); + assert.equal(reactor.state[2][0], 4.5); + assert.equal(reactor.state[3][0], 4.5); + assert.equal(reactor.state[4][0], 4.5); +}); + +test('PFR uses kla-based transfer branch when kla is finite', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', kla: 1, n_inlets: 1, length: 8, resolution_L: 6, volume: 40 }), + ); + + reactor.asm = { + compute_dC: () => Array(NUM_SPECIES).fill(0), + }; + reactor.Fs[0] = 0; + reactor.D = 0; + reactor.OTR = 0; + reactor.state = Array.from({ length: reactor.n_x }, () => Array(NUM_SPECIES).fill(0)); + + const expected = reactor._calcOTR(0, reactor.temperature) * (reactor.n_x / (reactor.n_x - 2)); + reactor.tick(1); + + assert.ok(Math.abs(reactor.state[1][0] - expected) < 1e-9); + assert.ok(Math.abs(reactor.state[2][0] - expected) < 1e-9); + assert.ok(Math.abs(reactor.state[3][0] - expected) < 1e-9); + assert.ok(Math.abs(reactor.state[4][0] - expected) < 1e-9); +}); diff --git a/test/integration/pfr-boundary.integration.test.js b/test/integration/pfr-boundary.integration.test.js new file mode 100644 index 0000000..18cf25d --- /dev/null +++ b/test/integration/pfr-boundary.integration.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_PFR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +test('_applyBoundaryConditions enforces Danckwerts inlet and Neumann outlet for flowing case', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', n_inlets: 1, length: 10, resolution_L: 5, volume: 50, alpha: 0.2 }), + ); + + reactor.Fs[0] = 2; + reactor.Cs_in[0] = Array(13).fill(9); + reactor.D = 1; + + const state = Array.from({ length: reactor.n_x }, (_, i) => Array(13).fill(i)); + reactor._applyBoundaryConditions(state); + + assert.deepEqual(state[reactor.n_x - 1], state[reactor.n_x - 2]); + assert.equal(state[0].every((v) => Number.isFinite(v)), true); +}); + +test('_applyBoundaryConditions copies first interior slice when no flow is present', () => { + const reactor = new Reactor_PFR( + makeReactorConfig({ reactor_type: 'PFR', n_inlets: 1, length: 10, resolution_L: 5, volume: 50 }), + ); + + reactor.Fs[0] = 0; + const state = Array.from({ length: reactor.n_x }, (_, i) => Array(13).fill(i + 10)); + + reactor._applyBoundaryConditions(state); + + assert.deepEqual(state[0], state[1]); + assert.deepEqual(state[reactor.n_x - 1], state[reactor.n_x - 2]); +}); diff --git a/test/integration/structure-examples.integration.test.js b/test/integration/structure-examples.integration.test.js new file mode 100644 index 0000000..5d92cc1 --- /dev/null +++ b/test/integration/structure-examples.integration.test.js @@ -0,0 +1,23 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const dir = path.resolve(__dirname, '../../examples'); + +function loadJson(file) { + return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); +} + +test('examples package exists for reactor', () => { + for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { + assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing'); + } +}); + +test('example flows are parseable arrays for reactor', () => { + for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) { + const parsed = loadJson(file); + assert.equal(Array.isArray(parsed), true); + } +}); diff --git a/test/integration/tick-loop.integration.test.js b/test/integration/tick-loop.integration.test.js new file mode 100644 index 0000000..f57f926 --- /dev/null +++ b/test/integration/tick-loop.integration.test.js @@ -0,0 +1,89 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const NodeClass = require('../../src/nodeClass'); +const { makeNodeStub } = require('../helpers/factories'); + +test('_tick emits source effluent on process output', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + + inst.node = node; + inst.source = { + get getEffluent() { + return { topic: 'Fluent', payload: { inlet: 0, F: 1, C: [] }, timestamp: 1 }; + }, + }; + + inst._tick(); + + assert.equal(node._sent.length, 1); + assert.equal(node._sent[0][0].topic, 'Fluent'); + assert.equal(node._sent[0][1], null); + assert.equal(node._sent[0][2], null); +}); + +test('_startTickLoop schedules periodic tick after startup delay', () => { + const inst = Object.create(NodeClass.prototype); + const delays = []; + const intervals = []; + let tickCount = 0; + + inst._tick = () => { + tickCount += 1; + }; + + const originalSetTimeout = global.setTimeout; + const originalSetInterval = global.setInterval; + + global.setTimeout = (fn, ms) => { + delays.push(ms); + fn(); + return 10; + }; + + global.setInterval = (fn, ms) => { + intervals.push(ms); + fn(); + return 22; + }; + + try { + inst._startTickLoop(); + } finally { + global.setTimeout = originalSetTimeout; + global.setInterval = originalSetInterval; + } + + assert.deepEqual(delays, [1000]); + assert.deepEqual(intervals, [1000]); + assert.equal(inst._tickInterval, 22); + assert.equal(tickCount, 1); +}); + +test('_attachCloseHandler clears tick interval and calls done callback', () => { + const inst = Object.create(NodeClass.prototype); + const node = makeNodeStub(); + inst.node = node; + inst._tickInterval = 55; + + const cleared = []; + const originalClearInterval = global.clearInterval; + global.clearInterval = (id) => { + cleared.push(id); + }; + + let doneCalled = 0; + + try { + inst._attachCloseHandler(); + node._handlers.close(() => { + doneCalled += 1; + }); + } finally { + global.clearInterval = originalClearInterval; + } + + assert.deepEqual(cleared, [55]); + assert.equal(doneCalled, 1); +}); diff --git a/test/integration/upstream-reactor.integration.test.js b/test/integration/upstream-reactor.integration.test.js new file mode 100644 index 0000000..a2eb931 --- /dev/null +++ b/test/integration/upstream-reactor.integration.test.js @@ -0,0 +1,48 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { Reactor_CSTR } = require('../../src/specificClass'); +const { makeReactorConfig } = require('../helpers/factories'); + +const DAY_MS = 1000 * 60 * 60 * 24; + +test('registering upstream reactor subscribes to upstream stateChange events', () => { + const downstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' })); + const upstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' })); + + let calledWith = null; + downstream.updateState = (timestamp) => { + calledWith = timestamp; + }; + + downstream.registerChild(upstream, 'reactor'); + upstream.emitter.emit('stateChange', 12345); + + assert.equal(downstream.upstreamReactor, upstream); + assert.equal(calledWith, 12345); +}); + +test('updateState pulls influent from upstream reactor effluent when linked', () => { + const downstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1, timeStep: 1 })); + const upstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1 })); + + upstream.Fs[0] = 3; + upstream.state = Array(13).fill(11); + + downstream.upstreamReactor = upstream; + downstream.currentTime = 0; + downstream.timeStep = 1; + downstream.speedUpFactor = 1; + + let ticks = 0; + downstream.tick = () => { + ticks += 1; + return downstream.state; + }; + + downstream.updateState(DAY_MS); + + assert.equal(ticks, 1); + assert.equal(downstream.Fs[0], 3); + assert.deepEqual(downstream.Cs_in[0], Array(13).fill(11)); +});