import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { mean, standardDeviation, trendDirection, percentChange, deviationsFromMean, weightedOutOfNetworkPct, } from "../src/utils/stats.js"; describe("stats", () => { describe("mean", () => { test("should return mean of values", () => { const result = mean([10, 20, 30]); assert.equal(result, 20); }); test("should return 0 for empty array", () => { const result = mean([]); assert.equal(result, 0); }); test("should handle single value", () => { const result = mean([42]); assert.equal(result, 42); }); }); describe("standardDeviation", () => { test("should calculate correctly for known values", () => { // For [2, 4, 4, 4, 5, 5, 7, 9]: // Mean = 5 // Variance = ((2-5)^2 + (4-5)^2 + (4-5)^2 + (4-5)^2 + (5-5)^2 + (5-5)^2 + (7-5)^2 + (9-5)^2) / 8 // Variance = (9 + 1 + 1 + 1 + 0 + 0 + 4 + 16) / 8 = 32 / 8 = 4 // StdDev = 2 const result = standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]); assert.equal(result, 2); }); test("should return 0 for single value", () => { const result = standardDeviation([5]); assert.equal(result, 0); }); test("should return 0 for empty array", () => { const result = standardDeviation([]); assert.equal(result, 0); }); test("should handle uniform values", () => { const result = standardDeviation([5, 5, 5, 5]); assert.equal(result, 0); }); }); describe("trendDirection", () => { test("should detect up trend", () => { const result = trendDirection(110, 100); assert.equal(result, "up"); }); test("should detect down trend", () => { const result = trendDirection(90, 100); assert.equal(result, "down"); }); test("should detect stable trend", () => { const result = trendDirection(103, 100); assert.equal(result, "stable"); }); test("should use custom threshold", () => { const result = trendDirection(103, 100, 10); assert.equal(result, "stable"); }); test("should detect up with custom threshold", () => { const result = trendDirection(112, 100, 10); assert.equal(result, "up"); }); }); describe("percentChange", () => { test("should calculate positive change correctly", () => { const result = percentChange(110, 100); assert.equal(result, 10); }); test("should calculate negative change correctly", () => { const result = percentChange(90, 100); assert.equal(result, -10); }); test("should handle zero previous value", () => { const result = percentChange(100, 0); assert.equal(result, 0); }); test("should handle zero current value", () => { const result = percentChange(0, 100); assert.equal(result, -100); }); test("should handle no change", () => { const result = percentChange(100, 100); assert.equal(result, 0); }); }); describe("deviationsFromMean", () => { test("should calculate correctly for value above mean", () => { // Mean of [10, 20, 30] = 20 // StdDev = sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2) / 3) = sqrt((100 + 0 + 100) / 3) = sqrt(66.67) ≈ 8.165 // Deviations for 30 = (30 - 20) / 8.165 ≈ 1.225 const result = deviationsFromMean(30, [10, 20, 30]); assert.ok(Math.abs(result - 1.225) < 0.01); }); test("should calculate correctly for value below mean", () => { const result = deviationsFromMean(10, [10, 20, 30]); assert.ok(Math.abs(result + 1.225) < 0.01); // Negative deviation }); test("should return 0 for uniform data", () => { const result = deviationsFromMean(5, [5, 5, 5]); assert.equal(result, 0); }); test("should return 0 for single value", () => { const result = deviationsFromMean(5, [5]); assert.equal(result, 0); }); test("should calculate for value at mean", () => { const result = deviationsFromMean(20, [10, 20, 30]); assert.ok(Math.abs(result) < 0.01); }); }); /** * Out-of-network reach aggregate (N16). The share is per-post, so the only * honest roll-up is impressions-weighted: a 50-impression post at 90% must * not outvote a 10,000-impression post at 20%. */ describe("weightedOutOfNetworkPct", () => { const post = (impressions: number, outOfNetworkPct?: number) => ({ metrics: { impressions, outOfNetworkPct }, }); test("should weight each share by that post's impressions", () => { // (10000*20 + 1000*80) / 11000 = 25.45… — an unweighted mean would say 50. const result = weightedOutOfNetworkPct([post(10000, 20), post(1000, 80)]); assert.equal(result, 25.5, "Should be the impressions-weighted share, not the plain mean"); }); test("should exclude posts that carry no share from the weighting", () => { // The 5000-impression post has no reading; folding it in as 0 would drag // the answer to 17.5 and invent data that was never entered. const result = weightedOutOfNetworkPct([ post(10000, 20), post(1000, 80), post(5000, undefined), ]); assert.equal(result, 25.5, "Posts without a share must not dilute the aggregate"); }); test("should keep a genuine 0 share in the weighting", () => { // (1000*0 + 1000*50) / 2000 = 25 — an explicit zero is data, not absence. const result = weightedOutOfNetworkPct([post(1000, 0), post(1000, 50)]); assert.equal(result, 25); }); test("should return undefined when no post carries a share", () => { const result = weightedOutOfNetworkPct([post(1000), post(2000)]); assert.equal(result, undefined, "Absent data must stay absent, never 0"); }); test("should return undefined for an empty list", () => { assert.equal(weightedOutOfNetworkPct([]), undefined); }); test("should return undefined when the carrying posts have no impressions", () => { // A share of zero impressions has no meaning, and the weights sum to 0 — // returning 0 here would be a fabricated reading, and NaN a bug. const result = weightedOutOfNetworkPct([post(0, 40)]); assert.equal(result, undefined, "Zero total weight must yield undefined, never NaN or 0"); }); test("should round to one decimal (the UI reading is itself rounded)", () => { // (3000*33.3 + 1000*66.7) / 4000 = 41.65 → 41.7 const result = weightedOutOfNetworkPct([post(3000, 33.3), post(1000, 66.7)]); assert.equal(result, 41.7); }); }); });