linkedin-studio/scripts/analytics/tests/stats.test.ts
Kjell Tore Guttormsen 63506f7d5c feat(linkedin-studio): N16 — out-of-network-andel + patterns-oppdatering + boundary-map [skip-docs]
Reach-splitten (in/out-of-network) er native i LinkedIns post-analytics siden juni
2026, men vises som PROSENT og finnes ikke i CSV-eksporten. Planen antok to
manuelle antall; verifiseringen viste prosent, så modellen er ett felt —
outOfNetworkPct — og in-network er komplementet.

- parseOptionalPercent: egen parser, ikke parseOptionalCount. Komma er desimal
  (36,5 -> 36.5, aldri 365), og verdi >100 avvises: i én kolonne kan ikke et
  absolutt antall skilles fra en andel, så svaret er unknown, ikke en gjetning.
  Blank/ikke-numerisk/negativ -> unknown; ekte 0 beholdes.
- Ett lagret halvpart, kryssjekket: In-network godtas og lagres som komplement;
  et transkribert par som ikke summerer til ~100 (±1 avrunding) forkastes som
  unknown i stedet for å bli halvveis trodd.
- weightedOutOfNetworkPct: impressions-vektet roll-up (avgOutOfNetworkPct, uke +
  måned). Flatt snitt lar en 50-visnings-post slå en på 10 000; poster uten
  avlesning ekskluderes, og null vekt gir undefined — aldri 0, aldri NaN.
- Reach inngår ALDRI i engagementRate (distribusjon != engasjement). Rapporten
  leser den som akvisisjon (ut) vs resonans (inn), og sier «ikke ført for denne
  perioden» framfor å estimere. En reach-innsikt går inn i N15s do-next-kanal.
- Step 7c (A2-F11): rapporten tilbyr diff mot brukerens engagement-patterns.md
  med eksplisitt go — aldri stille skriving, aldri inn i den shippede malen.
- Boundary-map (E#9): dwell eksplisitt umålbar, saves partner-gated, reach
  native men CSV-eksport uverifisert.
- Reach-frie importer er byte-identiske med før, på skjerm og på disk.

TDD: rødt bevist først (10 feilende), analytics 119 -> 144 tester, tsc ren.
test-runner 232 -> 247 (Section 16w, gulv 213 -> 228). Alle suiter grønne.
CHANGELOG: N15-oppføringen manglet og er backfilt sammen med N16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
2026-07-25 15:56:04 +02:00

196 lines
6.5 KiB
TypeScript

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