const test = require('node:test'); const assert = require('node:assert/strict'); const { makeMeasurementInstance } = require('../helpers/factories'); /** * Unit coverage for the three outlier detection strategies shipped by the * measurement node. Each test seeds the storedValues window first, then * probes the classifier directly. This keeps the assertions focused on the * detection logic rather than the full calculateInput pipeline. */ function makeDetector(method, threshold) { return makeMeasurementInstance({ scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -1000, absMax: 1000, offset: 0 }, smoothing: { smoothWindow: 20, smoothMethod: 'none' }, outlierDetection: { enabled: true, method, threshold }, }); } function seed(m, values) { // bypass calculateInput so we don't trigger outlier filtering while seeding m.storedValues = [...values]; } test("zScore flags a value far above the mean as an outlier", () => { const m = makeDetector('zScore', 3); seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); assert.equal(m.outlierDetection(100), true); }); test("zScore does not flag a value inside the distribution", () => { const m = makeDetector('zScore', 3); seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); assert.equal(m.outlierDetection(11), false); }); test("iqr flags a value outside Q1/Q3 fences", () => { const m = makeDetector('iqr'); seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); assert.equal(m.outlierDetection(100), true); }); test("iqr does not flag a value inside Q1/Q3 fences", () => { const m = makeDetector('iqr'); seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); assert.equal(m.outlierDetection(5), false); }); test("modifiedZScore flags heavy-tailed outliers", () => { const m = makeDetector('modifiedZScore', 3.5); seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); assert.equal(m.outlierDetection(1000), true); }); test("modifiedZScore accepts normal data", () => { const m = makeDetector('modifiedZScore', 3.5); seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]); assert.equal(m.outlierDetection(11), false); }); test("unknown outlier method falls back to schema default (zScore) and still runs", () => { // validateEnum replaces unknown values with the schema default. The // schema default is "zScore"; the dispatcher normalizes to lowercase // and routes to zScoreOutlierDetection. With a tight window, value=100 // is a clear outlier -> returns true. const m = makeDetector('bogus', 3); seed(m, [1, 2, 3, 4, 5]); assert.equal(m.outlierDetection(100), true); }); test("outlier detection returns false when window has < 2 samples", () => { const m = makeDetector('zScore', 3); m.storedValues = []; assert.equal(m.outlierDetection(500), false); }); test("calculateInput ignores a value flagged as outlier", () => { const m = makeDetector('zScore', 3); // Build a tight baseline then throw a spike at it. [10, 10, 10, 10, 10].forEach((v) => m.calculateInput(v)); const before = m.outputAbs; m.calculateInput(9999); // Output must not move to the spike (outlier rejected). assert.equal(m.outputAbs, before); }); test("toggleOutlierDetection flips the flag without corrupting config", () => { const m = makeDetector('zScore', 3); const initial = m.config.outlierDetection.enabled; m.toggleOutlierDetection(); assert.equal(m.config.outlierDetection.enabled, !initial); // Re-toggle restores m.toggleOutlierDetection(); assert.equal(m.config.outlierDetection.enabled, initial); // Method is preserved (enum values are normalized to lowercase by validateEnum). assert.equal(m.config.outlierDetection.method.toLowerCase(), 'zscore'); });