'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 under default threshold → unstable', () => { // Resolved 2026-05-11: config-driven absolute stabilityThreshold replaces // the old `stdDev < stdDev*marginFactor` tautology. Default threshold is // 0.01 (scaling-units); a 0..100 spread blows past it. const { cal } = makeCalibrator([0, 100, 0, 100], {}); const r = cal.isStable(); assert.strictEqual(r.isStable, false); assert.ok(r.stdDev > 0); }); test('isStable: high-variance array with relaxed threshold → stable', () => { const cfg = { calibration: { stabilityThreshold: 100 } }; const { cal } = makeCalibrator([0, 100, 0, 100], cfg); const r = cal.isStable(); assert.strictEqual(r.isStable, true); assert.ok(r.stdDev > 0); }); test('isStable: zero stdDev (constant) is stable regardless of threshold', () => { const cfg = { calibration: { stabilityThreshold: 0 } }; const { cal } = makeCalibrator([7, 7, 7, 7], cfg); const r = cal.isStable(); assert.strictEqual(r.isStable, true); assert.strictEqual(r.stdDev, 0); }); test('isStable: stdDev just above threshold → unstable', () => { const cfg = { calibration: { stabilityThreshold: 0.5 } }; // stdDev of [10, 11] = 0.5; nudge the spread up so stdDev > 0.5. const { cal } = makeCalibrator([10, 12], cfg); const r = cal.isStable(); assert.strictEqual(r.isStable, false); assert.ok(r.stdDev > 0.5); }); test('isStable: missing config.calibration → falls back to default 0.01', () => { // stdDev of [10, 10.001] ≈ 0.0005, well under the 0.01 default. const { cal: stable } = makeCalibrator([10, 10.001], {}); assert.strictEqual(stable.isStable().isStable, true); // stdDev of [10, 10.1] ≈ 0.05, above the 0.01 default. const { cal: unstable } = makeCalibrator([10, 10.1], {}); assert.strictEqual(unstable.isStable().isStable, false); }); 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 under default threshold → null', () => { // Resolved 2026-05-11: with the real stability check in place, a noisy // buffer fails isStable() and repeatability reports null with reason. const cfg = { smoothing: { smoothMethod: 'mean' } }; const { cal, logger } = makeCalibrator([0, 50, 0, 50], cfg); const r = cal.evaluateRepeatability(); assert.strictEqual(r.repeatability, null); assert.strictEqual(r.reason, 'unstable'); assert.match(logger.calls.warn[0], /not stable/); }); test('evaluateRepeatability: high-variance with relaxed threshold → returns stdDev', () => { const cfg = { smoothing: { smoothMethod: 'mean' }, calibration: { stabilityThreshold: 100 }, }; const { cal } = makeCalibrator([0, 50, 0, 50], cfg); const r = cal.evaluateRepeatability(); assert.ok(r.repeatability > 0); });