diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..9fd9dc3 --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,59 @@ +# measurement — Contract + +Hand-maintained for Phase 3; the `## Inputs` table is generated from +`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines. + +## Inputs (msg.topic on Port 0) + +| Canonical | Aliases (deprecated) | Payload | Effect | +|---|---|---|---| +| `set.simulator` | `simulator` | none (payload ignored) | Toggles `source.toggleSimulation()` — flips `config.simulation.enabled`. | +| `set.outlier-detection` | `outlierDetection` | none (payload ignored) | Toggles `source.toggleOutlierDetection()` — flips `config.outlierDetection.enabled`. | +| `cmd.calibrate` | `calibrate` | none | Calls `source.calibrate()` — captures the current input as the zero/reference offset. | +| `data.measurement` | `measurement` | mode-dependent — see below | Pushes a sensor reading into the pipeline. Analog: numeric scalar (number or numeric string) → `source.inputValue`. Digital: object payload keyed by channel name → `source.handleDigitalPayload(payload)`. Wrong shape for the configured mode logs a helpful warning suggesting the other mode. | + +Aliases log a one-time deprecation warning the first time they fire. + +## Outputs (msg.topic on Port 0/1/2) + +- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by + `outputUtils.formatMsg(..., 'process')` from `getOutput()` (analog) or + `getDigitalOutput()` (digital). Delta-compressed — only changed fields are + emitted. +- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the + `'influxdb'` formatter. +- **Port 2 (registration):** at startup the node sends one + `{ topic: 'registerChild', payload: , positionVsParent, distance }` + to its parent. + +## Events emitted by `source.measurements.emitter` + +The `MeasurementContainer` fires `.measured.` whenever a +matching series receives a new value. The type / position labels are set +from `config.asset.type` and `config.functionality.positionVsParent` +(analog), or per-channel from `config.channels[*]` (digital). Examples: + +- `pressure.measured.upstream` +- `flow.measured.atequipment` +- `level.measured.downstream` +- `temperature.measured.atequipment` + +Position labels are always lowercase in the event name. Parents subscribe +through the generic `child.measurements.emitter.on(eventName, ...)` handshake +established by `childRegistrationUtils`. + +In digital mode one input message can fan out into several events — one +per channel that accepted a value on that tick. + +The legacy internal `source.emitter` also fires `'mAbs'` with the current +scaled absolute value (analog mode only). This is deprecated in favour of +`measurements.emitter` and kept only for the editor status badge during the +refactor window. + +## Children registered by this node + +None — `measurement` is a leaf in the S88 hierarchy (Control Module). It +registers itself as a child of an upstream parent (rotatingMachine, +pumpingStation, reactor, monster, …) but does not accept its own children. +Registration goes via Port 2 at startup and is keyed off +`positionVsParent` / `distance` in the node's UI config. diff --git a/src/calibration/calibrator.js b/src/calibration/calibrator.js new file mode 100644 index 0000000..9b56688 --- /dev/null +++ b/src/calibration/calibrator.js @@ -0,0 +1,91 @@ +'use strict'; + +const { stats } = require('generalFunctions'); + +const MARGIN_FACTOR = 2; + +/** + * Calibration helper extracted from measurement/specificClass.js. + * + * The orchestrator owns the rolling buffer and the live config; this class + * reads them through accessor callbacks (`storedValuesRef` / `configRef`) + * so it never holds stale references when the orchestrator mutates either. + */ +class Calibrator { + constructor({ storedValuesRef, configRef, logger } = {}) { + if (typeof storedValuesRef !== 'function' || typeof configRef !== 'function') { + throw new Error('Calibrator requires storedValuesRef and configRef functions'); + } + this._storedValues = storedValuesRef; + this._config = configRef; + this.logger = logger || { info() {}, warn() {}, debug() {}, error() {} }; + } + + /** + * Decide whether the rolling window is stable enough to trust. + * Mirrors the original threshold check; with `stdDev=0` (constant input) + * the comparison short-circuits to true. + */ + isStable() { + const values = this._storedValues(); + if (!Array.isArray(values) || values.length < 2) { + return { isStable: false, stdDev: 0 }; + } + const stdDev = stats.stdDev(values); + const stableThreshold = stdDev * MARGIN_FACTOR; + return { isStable: stdDev < stableThreshold || stdDev === 0, stdDev }; + } + + /** + * Compute the offset that drives `currentOutputAbs` to the configured + * baseline (scaling input-min when scaling is enabled, abs-min otherwise). + * Returns null when the input is not stable — caller leaves the offset + * untouched and logs the abort. + */ + calibrate(currentOutputAbs) { + const { isStable } = this.isStable(); + if (!isStable) { + this.logger.warn('Large fluctuations detected between stored values. Calibration aborted.'); + return null; + } + const cfg = this._config(); + const scaling = (cfg && cfg.scaling) || {}; + const baseline = scaling.enabled ? scaling.inputMin : scaling.absMin; + if (typeof baseline !== 'number' || !Number.isFinite(baseline)) { + this.logger.warn('Calibration baseline missing from config.scaling. Aborted.'); + return null; + } + const offset = baseline - currentOutputAbs; + this.logger.info(`Stable input value detected. Calibration completed. Offset=${offset}`); + return { offset }; + } + + /** + * Repeatability proxy: the std-dev of the smoothed rolling buffer once + * stability is confirmed. Smoothing must be active, otherwise the buffer + * is just raw input and the metric is meaningless. + */ + evaluateRepeatability() { + const cfg = this._config(); + const method = cfg && cfg.smoothing && cfg.smoothing.smoothMethod; + const normalized = typeof method === 'string' ? method.toLowerCase() : method; + if (normalized === 'none' || normalized == null) { + this.logger.warn('Repeatability evaluation is not possible without smoothing.'); + return { repeatability: null, reason: 'smoothing-disabled' }; + } + const values = this._storedValues(); + if (!Array.isArray(values) || values.length < 2) { + this.logger.warn('Not enough data to evaluate repeatability.'); + return { repeatability: null, reason: 'insufficient-data' }; + } + const { isStable, stdDev } = this.isStable(); + if (!isStable) { + this.logger.warn('Data not stable enough to evaluate repeatability.'); + return { repeatability: null, reason: 'unstable' }; + } + this.logger.info(`Repeatability evaluated. Standard Deviation: ${stdDev}`); + return { repeatability: stdDev }; + } +} + +module.exports = Calibrator; diff --git a/src/commands/handlers.js b/src/commands/handlers.js new file mode 100644 index 0000000..d967518 --- /dev/null +++ b/src/commands/handlers.js @@ -0,0 +1,74 @@ +'use strict'; + +// Handler functions for measurement commands. Each handler receives: +// source: the domain (specificClass) instance — exposes toggleSimulation, +// toggleOutlierDetection, calibrate, handleDigitalPayload, mode, +// inputValue (settable), logger. +// msg: the Node-RED input message. +// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter. +// +// Handlers are pure functions: validation that goes beyond the registry's +// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement) +// lives here. + +function _logger(source, ctx) { + return ctx?.logger || source?.logger || null; +} + +exports.setSimulator = (source) => { + // Idempotent flip — payload is ignored; the source owns the boolean. + source.toggleSimulation(); +}; + +exports.setOutlierDetection = (source) => { + source.toggleOutlierDetection(); +}; + +exports.calibrate = (source) => { + source.calibrate(); +}; + +exports.dataMeasurement = (source, msg, ctx) => { + const log = _logger(source, ctx); + if (source.mode === 'digital') { + return _handleDigital(source, msg, log); + } + return _handleAnalog(source, msg, log); +}; + +function _handleDigital(source, msg, log) { + const p = msg.payload; + if (p && typeof p === 'object' && !Array.isArray(p)) { + return source.handleDigitalPayload(p); + } + if (typeof p === 'number') { + // Helpful hint: the user probably configured the wrong mode. + log?.warn?.( + `digital mode received a number (${p}); expected an object like {key: value, ...}. ` + + `Switch Input Mode to 'analog' in the editor or send an object payload.` + ); + return; + } + log?.warn?.(`digital mode expects an object payload; got ${typeof p}`); +} + +function _handleAnalog(source, msg, log) { + const p = msg.payload; + if (typeof p === 'number' || (typeof p === 'string' && p.trim() !== '')) { + const parsed = Number(p); + if (!Number.isNaN(parsed)) { + source.inputValue = parsed; + return; + } + log?.warn?.(`Invalid numeric measurement payload: ${p}`); + return; + } + if (p && typeof p === 'object' && !Array.isArray(p)) { + // Helpful hint: the payload is object-shaped but the node is analog. + const keys = Object.keys(p).slice(0, 3).join(', '); + log?.warn?.( + `analog mode received an object payload (keys: ${keys}). ` + + `Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric payload.` + ); + } +} diff --git a/src/commands/index.js b/src/commands/index.js new file mode 100644 index 0000000..83f491c --- /dev/null +++ b/src/commands/index.js @@ -0,0 +1,40 @@ +'use strict'; + +// measurement command registry. Consumed by BaseNodeAdapter via +// `static commands = require('./commands')`. Each descriptor maps a +// canonical msg.topic to its handler; legacy names are listed under +// `aliases` and emit a one-time deprecation warning at runtime. + +const handlers = require('./handlers'); + +module.exports = [ + { + topic: 'set.simulator', + aliases: ['simulator'], + // Toggle — payload is ignored. `any` keeps the registry validator happy + // for legacy callers that ship trigger payloads of various shapes. + payloadSchema: { type: 'any' }, + handler: handlers.setSimulator, + }, + { + topic: 'set.outlier-detection', + aliases: ['outlierDetection'], + payloadSchema: { type: 'any' }, + handler: handlers.setOutlierDetection, + }, + { + topic: 'cmd.calibrate', + aliases: ['calibrate'], + payloadSchema: { type: 'any' }, + handler: handlers.calibrate, + }, + { + topic: 'data.measurement', + aliases: ['measurement'], + // Mode-dispatched: digital expects object, analog expects number/numeric + // string. The handler validates per-mode (the registry-level typeof + // check would reject one of the two valid shapes). + payloadSchema: { type: 'any' }, + handler: handlers.dataMeasurement, + }, +]; diff --git a/src/simulation/simulator.js b/src/simulation/simulator.js new file mode 100644 index 0000000..dbd15c8 --- /dev/null +++ b/src/simulation/simulator.js @@ -0,0 +1,60 @@ +/** + * Simulator — random-walk driver for the measurement input. + * + * Lifted verbatim from Measurement.simulateInput. The orchestrator decides + * what to do with the returned value (originally written to `inputValue`), + * so this module owns nothing but the walk and its bounds. + */ +class Simulator { + constructor({ config, logger } = {}) { + if (!config || !config.scaling) { + throw new Error('Simulator requires { config.scaling }'); + } + this.config = config; + this.logger = logger || { warn() {}, info() {}, debug() {}, error() {} }; + + const s = config.scaling; + this.inputRange = Math.abs(s.inputMax - s.inputMin); + this.processRange = Math.abs(s.absMax - s.absMin); + this.simValue = 0; + } + + step() { + const s = this.config.scaling; + const sign = Math.random() < 0.5 ? -1 : 1; + let maxStep; + + if (s.enabled) { + // Step size scales with the live input window; fall back to 1 so a + // collapsed range still wanders instead of freezing at zero. + maxStep = this.inputRange > 0 ? this.inputRange * 0.05 : 1; + if (this.simValue < s.inputMin || this.simValue > s.inputMax) { + this.logger.warn(`Simulated value ${this.simValue} is outside of input range constraining between min=${s.inputMin} and max=${s.inputMax}`); + this.simValue = _constrain(this.simValue, s.inputMin, s.inputMax); + } + } else { + maxStep = this.processRange > 0 ? this.processRange * 0.05 : 1; + if (this.simValue < s.absMin || this.simValue > s.absMax) { + this.logger.warn(`Simulated value ${this.simValue} is outside of abs range constraining between min=${s.absMin} and max=${s.absMax}`); + this.simValue = _constrain(this.simValue, s.absMin, s.absMax); + } + } + + this.simValue += sign * Math.random() * maxStep; + return this.simValue; + } + + reset() { + this.simValue = 0; + } + + get current() { + return this.simValue; + } +} + +function _constrain(v, lo, hi) { + return Math.min(Math.max(v, lo), hi); +} + +module.exports = Simulator; diff --git a/test/basic/calibrator.basic.test.js b/test/basic/calibrator.basic.test.js new file mode 100644 index 0000000..4c1b34b --- /dev/null +++ b/test/basic/calibrator.basic.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const Calibrator = require('../../src/calibration/calibrator.js'); + +// Tiny logger spy so we can assert on warn() without pulling in the real +// generalFunctions logger. +function makeLogger() { + const calls = { warn: [], info: [], debug: [], error: [] }; + return { + calls, + warn: (m) => calls.warn.push(m), + info: (m) => calls.info.push(m), + debug: (m) => calls.debug.push(m), + error: (m) => calls.error.push(m), + }; +} + +function makeCalibrator(values, config) { + const logger = makeLogger(); + const cal = new Calibrator({ + storedValuesRef: () => values, + configRef: () => config, + logger, + }); + return { cal, logger }; +} + +test('isStable: constant array → stable with stdDev=0', () => { + const { cal } = makeCalibrator([5, 5, 5, 5], {}); + const r = cal.isStable(); + assert.strictEqual(r.isStable, true); + assert.strictEqual(r.stdDev, 0); +}); + +test('isStable: high-variance array → original threshold is tautological (preserved)', () => { + // BUG-PRESERVED: original check is `stdDev < stdDev*marginFactor`, which is + // always true for stdDev>0. Length>=2 ⇒ isStable=true regardless of spread. + // See calibrator stdDev-threshold note. We pin the behaviour here so the + // refactor stays byte-equivalent; a separate behavioural PR can fix the rule. + const { cal } = makeCalibrator([0, 100, 0, 100], {}); + const r = cal.isStable(); + assert.strictEqual(r.isStable, true); + assert.ok(r.stdDev > 0); +}); + +test('isStable: < 2 values → unstable', () => { + const { cal } = makeCalibrator([42], {}); + const r = cal.isStable(); + assert.strictEqual(r.isStable, false); + assert.strictEqual(r.stdDev, 0); +}); + +test('calibrate: scaling enabled → offset = inputMin - currentOutputAbs', () => { + const cfg = { scaling: { enabled: true, inputMin: 4, absMin: 0 } }; + const { cal } = makeCalibrator([10, 10, 10], cfg); + const r = cal.calibrate(10); + assert.deepStrictEqual(r, { offset: -6 }); +}); + +test('calibrate: scaling disabled → offset = absMin - currentOutputAbs', () => { + const cfg = { scaling: { enabled: false, inputMin: 4, absMin: 1 } }; + const { cal } = makeCalibrator([7, 7, 7], cfg); + const r = cal.calibrate(7); + assert.deepStrictEqual(r, { offset: -6 }); +}); + +test('calibrate: not stable (length<2) → returns null and logs warn', () => { + // Original rule has a tautological threshold, so "unstable" only triggers + // when the rolling window has < 2 samples. + const cfg = { scaling: { enabled: true, inputMin: 0, absMin: 0 } }; + const { cal, logger } = makeCalibrator([], cfg); + const r = cal.calibrate(50); + assert.strictEqual(r, null); + assert.strictEqual(logger.calls.warn.length, 1); + assert.match(logger.calls.warn[0], /Calibration aborted/); +}); + +test('evaluateRepeatability: smoothing=none → null', () => { + const cfg = { smoothing: { smoothMethod: 'none' } }; + const { cal, logger } = makeCalibrator([5, 5, 5], cfg); + const r = cal.evaluateRepeatability(); + assert.strictEqual(r.repeatability, null); + assert.strictEqual(r.reason, 'smoothing-disabled'); + assert.match(logger.calls.warn[0], /without smoothing/); +}); + +test('evaluateRepeatability: stable + smoothed → returns stdDev', () => { + const cfg = { smoothing: { smoothMethod: 'mean' } }; + const { cal } = makeCalibrator([3, 3, 3, 3], cfg); + const r = cal.evaluateRepeatability(); + assert.strictEqual(r.repeatability, 0); +}); + +test('evaluateRepeatability: insufficient data → null', () => { + const cfg = { smoothing: { smoothMethod: 'mean' } }; + const { cal } = makeCalibrator([5], cfg); + const r = cal.evaluateRepeatability(); + assert.strictEqual(r.repeatability, null); + assert.strictEqual(r.reason, 'insufficient-data'); +}); + +test('evaluateRepeatability: high-variance still returns stdDev (preserved tautology)', () => { + // BUG-PRESERVED: see isStable note. Original rule treats any length>=2 + // buffer as stable, so repeatability returns the raw stdDev even when the + // spread is large. + const cfg = { smoothing: { smoothMethod: 'mean' } }; + const { cal } = makeCalibrator([0, 50, 0, 50], cfg); + const r = cal.evaluateRepeatability(); + assert.ok(r.repeatability > 0); +}); diff --git a/test/basic/commands.basic.test.js b/test/basic/commands.basic.test.js new file mode 100644 index 0000000..9faec7c --- /dev/null +++ b/test/basic/commands.basic.test.js @@ -0,0 +1,168 @@ +// Basic tests for the measurement commands registry. +// Run with: node --test test/basic/commands.basic.test.js + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createRegistry } = require('generalFunctions'); +const commands = require('../../src/commands'); + +// --- helpers --------------------------------------------------------------- + +function makeLogger() { + const calls = { warn: [], error: [], info: [], debug: [] }; + return { + calls, + warn: (m) => calls.warn.push(String(m)), + error: (m) => calls.error.push(String(m)), + info: (m) => calls.info.push(String(m)), + debug: (m) => calls.debug.push(String(m)), + }; +} + +function makeSource({ mode = 'analog', simulator = false, outlier = false } = {}) { + const calls = { + toggleSimulation: 0, + toggleOutlierDetection: 0, + calibrate: 0, + handleDigitalPayload: [], + inputValueSets: [], + }; + const state = { simulator, outlier, _inputValue: 0 }; + const source = { + mode, + logger: makeLogger(), + toggleSimulation: () => { state.simulator = !state.simulator; calls.toggleSimulation += 1; }, + toggleOutlierDetection: () => { state.outlier = !state.outlier; calls.toggleOutlierDetection += 1; }, + calibrate: () => { calls.calibrate += 1; }, + handleDigitalPayload: (p) => { calls.handleDigitalPayload.push(p); return { ok: true }; }, + get inputValue() { return state._inputValue; }, + set inputValue(v) { state._inputValue = v; calls.inputValueSets.push(v); }, + }; + return { source, calls, state }; +} + +function makeCtx({ logger = makeLogger() } = {}) { + return { logger, RED: { nodes: { getNode: () => undefined } }, node: {}, send: () => {} }; +} + +function makeRegistry(logger) { + return createRegistry(commands, { logger }); +} + +// --- tests ----------------------------------------------------------------- + +test('canonical topics dispatch to the right handler', async () => { + const { source, calls, state } = makeSource(); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx()); + assert.equal(calls.toggleSimulation, 1); + assert.equal(state.simulator, true); + + await reg.dispatch({ topic: 'set.outlier-detection' }, source, makeCtx()); + assert.equal(calls.toggleOutlierDetection, 1); + assert.equal(state.outlier, true); + + await reg.dispatch({ topic: 'cmd.calibrate' }, source, makeCtx()); + assert.equal(calls.calibrate, 1); +}); + +test('aliases dispatch to the same handler and log a one-time deprecation', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(ctxLogger); + + for (const alias of ['simulator', 'outlierDetection', 'calibrate', 'measurement']) { + await reg.dispatch({ topic: alias, payload: 1 }, source, makeCtx({ logger: ctxLogger })); + await reg.dispatch({ topic: alias, payload: 2 }, source, makeCtx({ logger: ctxLogger })); + } + + for (const alias of ['simulator', 'outlierDetection', 'calibrate', 'measurement']) { + const hits = ctxLogger.calls.warn.filter((m) => m.includes(`'${alias}' is deprecated`)); + assert.equal(hits.length, 1, `alias '${alias}' should warn exactly once`); + } + + // sanity: side-effects fired twice per alias. + assert.equal(calls.toggleSimulation, 2); + assert.equal(calls.toggleOutlierDetection, 2); + assert.equal(calls.calibrate, 2); + // analog measurement alias with numeric payload set inputValue twice. + assert.deepEqual(calls.inputValueSets, [1, 2]); +}); + +test('data.measurement analog with numeric payload sets source.inputValue', async () => { + const { source, calls } = makeSource({ mode: 'analog' }); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'data.measurement', payload: 42 }, source, makeCtx()); + await reg.dispatch({ topic: 'data.measurement', payload: '3.5' }, source, makeCtx()); + + assert.deepEqual(calls.inputValueSets, [42, 3.5]); +}); + +test('data.measurement analog with object payload logs helpful switch-mode warn', async () => { + const { source, calls } = makeSource({ mode: 'analog' }); + const ctxLogger = makeLogger(); + const reg = makeRegistry(ctxLogger); + + await reg.dispatch( + { topic: 'data.measurement', payload: { temperature: 21.5, humidity: 45 } }, + source, + makeCtx({ logger: ctxLogger }) + ); + + assert.equal(calls.inputValueSets.length, 0); + assert.equal(calls.handleDigitalPayload.length, 0); + assert.ok( + ctxLogger.calls.warn.some((m) => m.includes('analog mode') && m.includes('digital')), + `expected helpful switch-to-digital warn, got: ${JSON.stringify(ctxLogger.calls.warn)}` + ); +}); + +test('data.measurement digital with object payload calls handleDigitalPayload', async () => { + const { source, calls } = makeSource({ mode: 'digital' }); + const reg = makeRegistry(makeLogger()); + + const payload = { tempA: 21.5, tempB: 19.8 }; + await reg.dispatch({ topic: 'data.measurement', payload }, source, makeCtx()); + + assert.equal(calls.handleDigitalPayload.length, 1); + assert.deepEqual(calls.handleDigitalPayload[0], payload); + assert.equal(calls.inputValueSets.length, 0); +}); + +test('data.measurement digital with number logs helpful switch-mode warn', async () => { + const { source, calls } = makeSource({ mode: 'digital' }); + const ctxLogger = makeLogger(); + const reg = makeRegistry(ctxLogger); + + await reg.dispatch( + { topic: 'data.measurement', payload: 7 }, + source, + makeCtx({ logger: ctxLogger }) + ); + + assert.equal(calls.handleDigitalPayload.length, 0); + assert.equal(calls.inputValueSets.length, 0); + assert.ok( + ctxLogger.calls.warn.some((m) => m.includes('digital mode') && m.includes('analog')), + `expected helpful switch-to-analog warn, got: ${JSON.stringify(ctxLogger.calls.warn)}` + ); +}); + +test('set.simulator toggles even with no payload (idempotent flip)', async () => { + const { source, calls, state } = makeSource({ simulator: false }); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx()); + assert.equal(state.simulator, true); + await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx()); + assert.equal(state.simulator, false); + await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx()); + assert.equal(state.simulator, true); + + assert.equal(calls.toggleSimulation, 3); +}); diff --git a/test/basic/simulator.basic.test.js b/test/basic/simulator.basic.test.js new file mode 100644 index 0000000..917ba46 --- /dev/null +++ b/test/basic/simulator.basic.test.js @@ -0,0 +1,121 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Simulator = require('../../src/simulation/simulator.js'); + +function makeConfig(overrides = {}) { + return { + scaling: { + enabled: true, + inputMin: 0, + inputMax: 100, + absMin: 0, + absMax: 10, + offset: 0, + ...(overrides.scaling || {}), + }, + }; +} + +function makeFakeLogger() { + const log = { warn: [], info: [], debug: [], error: [] }; + return { + log, + warn: (m) => log.warn.push(m), + info: (m) => log.info.push(m), + debug: (m) => log.debug.push(m), + error: (m) => log.error.push(m), + }; +} + +// Replace Math.random with a deterministic queue, restore on cleanup. +function stubRandom(values) { + const orig = Math.random; + let i = 0; + Math.random = () => (i < values.length ? values[i++] : 0); + return () => { Math.random = orig; }; +} + +test('constructor derives inputRange when scaling.enabled=true', () => { + const sim = new Simulator({ config: makeConfig() }); + assert.equal(sim.inputRange, 100); + assert.equal(sim.processRange, 10); + assert.equal(sim.simValue, 0); +}); + +test('step() returns a number and mutates simValue', () => { + const sim = new Simulator({ config: makeConfig() }); + const before = sim.simValue; + const out = sim.step(); + assert.equal(typeof out, 'number'); + assert.notEqual(out, before); + assert.equal(out, sim.simValue); +}); + +test('step() is deterministic when Math.random is stubbed', () => { + // sign-roll then magnitude. With scaling enabled inputRange=100 -> maxStep=5. + // 0.4 < 0.5 => sign = -1; 0.2 magnitude => -1 * 0.2 * 5 = -1. + const restore = stubRandom([0.4, 0.2]); + try { + const sim = new Simulator({ config: makeConfig() }); + const v = sim.step(); + assert.equal(v, -1); + } finally { + restore(); + } +}); + +test('step() clamps an out-of-range starting value and warns (scaling enabled)', () => { + const restore = stubRandom([0.9, 0]); // sign=+1, magnitude=0 — isolate the clamp + const fakeLogger = makeFakeLogger(); + try { + const sim = new Simulator({ config: makeConfig(), logger: fakeLogger }); + sim.simValue = 500; // outside [0,100] + sim.step(); + assert.equal(sim.simValue, 100, 'clamped to inputMax before stepping'); + assert.equal(fakeLogger.log.warn.length, 1); + assert.match(fakeLogger.log.warn[0], /outside of input range/); + } finally { + restore(); + } +}); + +test('step() clamps against abs range when scaling.enabled=false', () => { + const restore = stubRandom([0.9, 0]); + const fakeLogger = makeFakeLogger(); + try { + const cfg = makeConfig({ scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 } }); + const sim = new Simulator({ config: cfg, logger: fakeLogger }); + sim.simValue = -5; + sim.step(); + assert.equal(sim.simValue, 0, 'clamped to absMin'); + assert.match(fakeLogger.log.warn[0], /outside of abs range/); + } finally { + restore(); + } +}); + +test('reset() zeros simValue', () => { + const sim = new Simulator({ config: makeConfig() }); + sim.simValue = 42; + sim.reset(); + assert.equal(sim.simValue, 0); + assert.equal(sim.current, 0); +}); + +test('100 steps stay within (a generous superset of) the configured range', () => { + // With inputRange=100 and maxStep=5, even adversarial walks can't escape + // far past inputMax before the next-iter clamp pulls back. Pin a wide + // safety bound to make the property robust against the sign-then-step + // ordering (clamp happens BEFORE the increment, so simValue can briefly + // exceed inputMax by up to maxStep at the end of a step). + const sim = new Simulator({ config: makeConfig() }); + for (let i = 0; i < 100; i++) sim.step(); + assert.ok(sim.simValue > -10, `walked below -10: ${sim.simValue}`); + assert.ok(sim.simValue < 110, `walked above 110: ${sim.simValue}`); +}); + +test('constructor throws on missing scaling config', () => { + assert.throws(() => new Simulator({ config: {} }), /scaling/); + assert.throws(() => new Simulator({}), /scaling/); +});