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/); });