linkedin-studio/scripts/analytics/tests/weekly.test.ts
Kjell Tore Guttormsen e2ad190dda feat(linkedin-studio): N17 — baseline-motor (median + variansbånd + minimum-N-refusal) [skip-docs]
Every reading now leads with "vs your own baseline", and no verdict is given
when N is too small to carry one.

- stats.ts: median + medianAbsoluteDeviation (robust pair; mean/stddev stay for
  the alert engine, which wants outlier sensitivity), rollingBaseline with a
  10-post positional window, median ± 1·MAD band floored at 0, and a typed
  insufficient-data refusal below MIN_BASELINE_N=5. readAgainstBaseline returns
  above/within/below-band, or no-verdict when the baseline was refused.
- baselineByGroup + buildBaselineBlock: per-format/per-pillar baselines, each
  judged on its own N; the reported period is excluded from its own baseline and
  compared on its median, not its mean.
- queue-join.ts (new): read-only date join supplying format/pillar from the post
  queue. Every ambiguity resolves to unlabelled, an entry labels at most one
  post, and a missing/broken queue degrades to no labels.
- weekly/monthly reports attach the block unconditionally (refusal included);
  optional in the types, so pre-N17 reports load unchanged.
- CLI: report output leads with the baseline; new `baseline [--by format|pillar]`
  verb with coverage reporting.
- report.md leads with baseline framing and prints the code's reading rather
  than judging the band by eye; WoW loses to the baseline on disagreement.
  analyze.md Step 2a tests whether the drop is real before diagnosing it.

TDD: 58 analytics tests written red first (144 -> 202). test-runner Section 16x,
23 unconditional checks + self-test (247 -> 270; anti-erosion floor 228 -> 251).
tsc clean. All suites green: trends 300, brain 134, editions 72,
specifics-bank 45, contract-gate 33, hooks 191, tests 35, render 60.

Also closes the OKF phase-4 scope follow-up in docs/okf-ingestion/plan.md §8
(coord round 2026-07-25): phase 4 tracks the contract, parse is in scope, and
our claim on read_concept/navigate_bundle is withdrawn as unnecessary.

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

758 lines
24 KiB
TypeScript

import { describe, test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
getISOWeek,
getCurrentISOWeek,
getPostsForWeek,
generateWeeklyReport,
} from "../src/reports/weekly.js";
import { saveBatch, saveWeeklyReport, loadWeeklyReport } from "../src/utils/storage.js";
import type { PostAnalytics, AnalyticsBatch } from "../src/models/types.js";
// Helper function to create test post data
function createTestPost(overrides?: Partial<PostAnalytics>): PostAnalytics {
return {
id: overrides?.id || "test-post-1",
title: overrides?.title || "Test post content",
publishedDate: overrides?.publishedDate || "2026-01-15",
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 8.5,
...(overrides?.metrics || {}),
},
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
exportSource: overrides?.exportSource || "test-export.csv",
};
}
// Helper function to create test batch data
function createTestBatch(overrides?: Partial<AnalyticsBatch>): AnalyticsBatch {
const posts = overrides?.posts || [createTestPost()];
return {
batchId: overrides?.batchId || "12345678-1234-1234-1234-123456789abc",
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
exportFilename: overrides?.exportFilename || "test-export.csv",
dateRange: overrides?.dateRange || { from: "2026-01-15", to: "2026-01-20" },
postCount: posts.length,
posts,
};
}
describe("weekly", () => {
let tempDir: string;
// Create temp directory before each test
function setupTempDir(): string {
return mkdtempSync(join(tmpdir(), "analytics-test-"));
}
// Clean up temp directory after each test
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
describe("getISOWeek", () => {
test("should return correct ISO week for 2026-01-01", () => {
// 2026-01-01 is Thursday, should be week 1 of 2026
const result = getISOWeek(new Date("2026-01-01"));
assert.equal(result, "2026-W01");
});
test("should return correct ISO week for 2025-12-29", () => {
// 2025-12-29 is Monday, first day of ISO week 1, 2026
const result = getISOWeek(new Date("2025-12-29"));
assert.equal(result, "2026-W01");
});
test("should return correct ISO week for 2025-12-28", () => {
// 2025-12-28 is Sunday, last day of 2025 week 52
const result = getISOWeek(new Date("2025-12-28"));
assert.equal(result, "2025-W52");
});
test("should handle year boundaries - early January", () => {
// 2025-01-01 is Wednesday, should be in 2025-W01
const result = getISOWeek(new Date("2025-01-01"));
assert.equal(result, "2025-W01");
});
test("should handle year boundaries - late December", () => {
// 2024-12-30 is Monday, should be in 2025-W01
const result = getISOWeek(new Date("2024-12-30"));
assert.equal(result, "2025-W01");
});
test("should handle mid-year dates", () => {
// 2026-06-15 is Monday
const result = getISOWeek(new Date("2026-06-15"));
assert.equal(result, "2026-W25");
});
test("should handle leap year", () => {
// 2024 is a leap year, Feb 29 should be in week 9
const result = getISOWeek(new Date("2024-02-29"));
assert.equal(result, "2024-W09");
});
});
describe("getCurrentISOWeek", () => {
test("should return a valid ISO week format", () => {
const result = getCurrentISOWeek();
// Should match YYYY-WXX format
assert.match(result, /^\d{4}-W\d{2}$/);
});
test("should return a week in reasonable range", () => {
const result = getCurrentISOWeek();
const year = parseInt(result.split("-")[0]);
const week = parseInt(result.split("-W")[1]);
// Year should be current or adjacent
const currentYear = new Date().getFullYear();
assert.ok(year >= currentYear - 1 && year <= currentYear + 1);
// Week should be 1-53
assert.ok(week >= 1 && week <= 53);
});
});
describe("getPostsForWeek", () => {
test("should filter posts to correct week", () => {
const posts: PostAnalytics[] = [
createTestPost({
id: "post1",
publishedDate: "2026-01-05", // 2026-W02
}),
createTestPost({
id: "post2",
publishedDate: "2026-01-12", // 2026-W03
}),
createTestPost({
id: "post3",
publishedDate: "2026-01-13", // 2026-W03
}),
createTestPost({
id: "post4",
publishedDate: "2026-01-19", // 2026-W04
}),
];
const week3Posts = getPostsForWeek(posts, "2026-W03");
assert.equal(week3Posts.length, 2);
assert.ok(week3Posts.some(p => p.id === "post2"));
assert.ok(week3Posts.some(p => p.id === "post3"));
});
test("should return empty for weeks with no posts", () => {
const posts: PostAnalytics[] = [
createTestPost({
id: "post1",
publishedDate: "2026-01-05", // 2026-W02
}),
];
const result = getPostsForWeek(posts, "2026-W03");
assert.deepEqual(result, []);
});
test("should handle empty posts array", () => {
const result = getPostsForWeek([], "2026-W03");
assert.deepEqual(result, []);
});
test("should handle posts across year boundary", () => {
const posts: PostAnalytics[] = [
createTestPost({
id: "post1",
publishedDate: "2025-12-29", // 2026-W01 (Monday)
}),
createTestPost({
id: "post2",
publishedDate: "2026-01-01", // 2026-W01 (Thursday)
}),
createTestPost({
id: "post3",
publishedDate: "2025-12-28", // 2025-W52 (Sunday)
}),
];
const week1Posts = getPostsForWeek(posts, "2026-W01");
assert.equal(week1Posts.length, 2);
assert.ok(week1Posts.some(p => p.id === "post1"));
assert.ok(week1Posts.some(p => p.id === "post2"));
});
});
describe("generateWeeklyReport", () => {
test("should handle no posts", () => {
tempDir = setupTempDir();
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.week, "2026-W03");
assert.equal(report.summary.totalPosts, 0);
assert.equal(report.summary.totalImpressions, 0);
assert.equal(report.summary.avgEngagementRate, 0);
assert.deepEqual(report.topPerformers, []);
assert.deepEqual(report.underperformers, []);
});
test("should calculate correct summary metrics", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "post1",
publishedDate: "2026-01-12", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 8.5,
},
}),
createTestPost({
id: "post2",
publishedDate: "2026-01-13", // 2026-W03
metrics: {
impressions: 2000,
reactions: 100,
comments: 20,
shares: 10,
clicks: 40,
engagementRate: 8.5,
},
}),
createTestPost({
id: "post3",
publishedDate: "2026-01-14", // 2026-W03
metrics: {
impressions: 1500,
reactions: 75,
comments: 15,
shares: 7,
clicks: 30,
engagementRate: 8.47,
},
}),
];
const batch = createTestBatch({
dateRange: { from: "2026-01-12", to: "2026-01-14" },
posts,
});
saveBatch(tempDir, batch);
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.week, "2026-W03");
assert.equal(report.summary.totalPosts, 3);
assert.equal(report.summary.totalImpressions, 4500);
assert.equal(report.summary.totalReactions, 225);
assert.equal(report.summary.totalComments, 45);
assert.equal(report.summary.totalShares, 22);
assert.equal(report.summary.totalClicks, 90);
assert.equal(report.summary.avgImpressionsPerPost, 1500);
// Average engagement rate: (8.5 + 8.5 + 8.47) / 3 ≈ 8.49
assert.ok(Math.abs(report.summary.avgEngagementRate - 8.49) < 0.01);
});
test("should sum saves into totalSaves when posts carry manual saves data", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "saved1",
publishedDate: "2026-01-12", // 2026-W03
metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, saves: 12 },
}),
createTestPost({
id: "saved2",
publishedDate: "2026-01-13", // 2026-W03
// No saves on this one — partial coverage must still sum what exists.
metrics: { impressions: 2000, reactions: 100, comments: 20, shares: 10, clicks: 40, engagementRate: 8.5 },
}),
createTestPost({
id: "saved3",
publishedDate: "2026-01-14", // 2026-W03
metrics: { impressions: 1500, reactions: 75, comments: 15, shares: 7, clicks: 30, engagementRate: 8.47, saves: 30 },
}),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.summary.totalSaves, 42, "totalSaves should sum the posts that carry saves (12 + 30)");
});
test("should leave totalSaves undefined when no post carries saves (backward-compat)", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({ id: "nosave1", publishedDate: "2026-01-12" }),
createTestPost({ id: "nosave2", publishedDate: "2026-01-13" }),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.summary.totalSaves, undefined, "Saves-free data must not introduce a totalSaves field");
});
test("should roll out-of-network shares up as an impressions-weighted average", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "reach1",
publishedDate: "2026-01-12", // 2026-W03
metrics: { impressions: 10000, reactions: 500, comments: 100, shares: 50, clicks: 200, engagementRate: 8.5, outOfNetworkPct: 20 },
}),
createTestPost({
id: "reach2",
publishedDate: "2026-01-13", // 2026-W03
metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, outOfNetworkPct: 80 },
}),
createTestPost({
id: "reach3",
publishedDate: "2026-01-14", // 2026-W03 — no share entered; must not dilute.
metrics: { impressions: 5000, reactions: 250, comments: 50, shares: 25, clicks: 100, engagementRate: 8.5 },
}),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(
report.summary.avgOutOfNetworkPct,
25.5,
"Should weight by impressions (25.5), not average the shares flat (50)"
);
});
test("should leave avgOutOfNetworkPct undefined when no post carries a share (backward-compat)", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({ id: "noreach1", publishedDate: "2026-01-12" }),
createTestPost({ id: "noreach2", publishedDate: "2026-01-13" }),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(
report.summary.avgOutOfNetworkPct,
undefined,
"Reach-free data must not introduce an avgOutOfNetworkPct field"
);
});
test("should identify top performers and underperformers", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "high1",
publishedDate: "2026-01-12", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 10.0,
},
}),
createTestPost({
id: "high2",
publishedDate: "2026-01-13", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 9.0,
},
}),
createTestPost({
id: "medium",
publishedDate: "2026-01-13", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 5.0,
},
}),
createTestPost({
id: "low1",
publishedDate: "2026-01-14", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 3.0,
},
}),
createTestPost({
id: "low2",
publishedDate: "2026-01-14", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 2.0,
},
}),
];
const batch = createTestBatch({
dateRange: { from: "2026-01-12", to: "2026-01-14" },
posts,
});
saveBatch(tempDir, batch);
const report = generateWeeklyReport(tempDir, "2026-W03");
// Top 3 performers (highest engagement)
assert.equal(report.topPerformers.length, 3);
assert.equal(report.topPerformers[0].id, "high1"); // 10.0
assert.equal(report.topPerformers[1].id, "high2"); // 9.0
assert.equal(report.topPerformers[2].id, "medium"); // 5.0
// Bottom 3 underperformers (lowest engagement)
assert.equal(report.underperformers.length, 3);
assert.equal(report.underperformers[0].id, "low2"); // 2.0
assert.equal(report.underperformers[1].id, "low1"); // 3.0
assert.equal(report.underperformers[2].id, "medium"); // 5.0
});
test("should handle fewer than 3 posts", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "post1",
publishedDate: "2026-01-12", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 10.0,
},
}),
createTestPost({
id: "post2",
publishedDate: "2026-01-13", // 2026-W03
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 5.0,
},
}),
];
const batch = createTestBatch({
dateRange: { from: "2026-01-12", to: "2026-01-13" },
posts,
});
saveBatch(tempDir, batch);
const report = generateWeeklyReport(tempDir, "2026-W03");
// Should have 2 top performers
assert.equal(report.topPerformers.length, 2);
// Should have 2 underperformers (same posts, reversed)
assert.equal(report.underperformers.length, 2);
});
test("should calculate trends when previous week data exists", () => {
tempDir = setupTempDir();
// Create previous week data
const prevWeekPosts: PostAnalytics[] = [
createTestPost({
id: "prev1",
publishedDate: "2026-01-05", // 2026-W02
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 8.5,
},
}),
createTestPost({
id: "prev2",
publishedDate: "2026-01-06", // 2026-W02
metrics: {
impressions: 1000,
reactions: 50,
comments: 10,
shares: 5,
clicks: 20,
engagementRate: 8.5,
},
}),
];
const prevBatch = createTestBatch({
dateRange: { from: "2026-01-05", to: "2026-01-06" },
posts: prevWeekPosts,
});
saveBatch(tempDir, prevBatch);
// Generate previous week report
generateWeeklyReport(tempDir, "2026-W02");
// Create current week data with higher metrics
const currentWeekPosts: PostAnalytics[] = [
createTestPost({
id: "curr1",
publishedDate: "2026-01-12", // 2026-W03
metrics: {
impressions: 1500,
reactions: 75,
comments: 15,
shares: 7,
clicks: 30,
engagementRate: 8.47,
},
}),
createTestPost({
id: "curr2",
publishedDate: "2026-01-13", // 2026-W03
metrics: {
impressions: 1500,
reactions: 75,
comments: 15,
shares: 7,
clicks: 30,
engagementRate: 8.47,
},
}),
];
const currentBatch = createTestBatch({
dateRange: { from: "2026-01-12", to: "2026-01-13" },
posts: currentWeekPosts,
});
saveBatch(tempDir, currentBatch);
const report = generateWeeklyReport(tempDir, "2026-W03");
// Current impressions: 3000, Previous: 2000 → 50% increase
assert.equal(report.trends.comparedTo, "2026-W02");
assert.equal(report.trends.impressionsTrend, "up");
assert.equal(report.trends.percentChange.impressions, 50);
// Engagement rate essentially the same
assert.equal(report.trends.engagementTrend, "stable");
});
test("should default to stable trends when no previous week data", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "post1",
publishedDate: "2026-01-12", // 2026-W03
}),
];
const batch = createTestBatch({
dateRange: { from: "2026-01-12", to: "2026-01-12" },
posts,
});
saveBatch(tempDir, batch);
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.trends.impressionsTrend, "stable");
assert.equal(report.trends.engagementTrend, "stable");
assert.equal(report.trends.percentChange.impressions, 0);
assert.equal(report.trends.percentChange.engagement, 0);
});
test("should filter posts correctly for target week", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "w02-post",
publishedDate: "2026-01-05", // 2026-W02
}),
createTestPost({
id: "w03-post1",
publishedDate: "2026-01-12", // 2026-W03
}),
createTestPost({
id: "w03-post2",
publishedDate: "2026-01-13", // 2026-W03
}),
createTestPost({
id: "w04-post",
publishedDate: "2026-01-19", // 2026-W04
}),
];
const batch = createTestBatch({
dateRange: { from: "2026-01-05", to: "2026-01-19" },
posts,
});
saveBatch(tempDir, batch);
const report = generateWeeklyReport(tempDir, "2026-W03");
// Should only include W03 posts
assert.equal(report.summary.totalPosts, 2);
assert.equal(report.topPerformers.length, 2);
assert.ok(report.topPerformers.some(p => p.id === "w03-post1"));
assert.ok(report.topPerformers.some(p => p.id === "w03-post2"));
});
test("should use current week if week parameter not provided", () => {
tempDir = setupTempDir();
const report = generateWeeklyReport(tempDir);
// Should match current ISO week format
assert.match(report.week, /^\d{4}-W\d{2}$/);
});
});
/**
* Baseline framing (N17). The report's job is to answer "is this different
* from my own normal?" — which requires a baseline built from history BEFORE
* the reported week, and an explicit refusal when that history is too thin.
*/
describe("generateWeeklyReport — baseline", () => {
// 2026-W03 is Mon 2026-01-12 .. Sun 2026-01-18.
function priorPost(day: string, impressions: number): PostAnalytics {
return createTestPost({
id: `prior-${day}`,
publishedDate: day,
metrics: { impressions, reactions: 5, comments: 1, shares: 0, clicks: 2, engagementRate: 4 },
});
}
test("should attach a baseline built only from earlier weeks", () => {
tempDir = setupTempDir();
const posts = [
priorPost("2025-12-15", 100),
priorPost("2025-12-17", 110),
priorPost("2025-12-29", 120),
priorPost("2025-12-31", 130),
priorPost("2026-01-05", 140),
// In-week post, deliberately extreme: if it leaked into the baseline the
// median would move and the week would be compared against itself.
createTestPost({
id: "in-week",
publishedDate: "2026-01-14",
metrics: { impressions: 99999, reactions: 5, comments: 1, shares: 0, clicks: 2, engagementRate: 4 },
}),
];
saveBatch(tempDir, createTestBatch({ posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.ok(report.baseline, "A generated report must carry a baseline block");
assert.equal(report.baseline!.historyBefore, "2026-W03");
assert.equal(report.baseline!.impressions.status, "ok");
if (report.baseline!.impressions.status !== "ok") return;
assert.equal(report.baseline!.impressions.n, 5, "Only the five earlier posts feed it");
assert.equal(report.baseline!.impressions.median, 120, "The in-week outlier must not leak in");
});
test("should refuse a baseline verdict when prior history is too thin", () => {
tempDir = setupTempDir();
const posts = [
priorPost("2025-12-15", 100),
priorPost("2025-12-17", 110),
createTestPost({ id: "in-week", publishedDate: "2026-01-14" }),
];
saveBatch(tempDir, createTestBatch({ posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.ok(report.baseline);
assert.equal(report.baseline!.impressions.status, "insufficient-data");
if (report.baseline!.impressions.status !== "insufficient-data") return;
assert.equal(report.baseline!.impressions.n, 2);
assert.equal(report.baseline!.impressions.required, 5);
});
test("should refuse rather than omit the block when there is no history at all", () => {
tempDir = setupTempDir();
saveBatch(tempDir, createTestBatch({
posts: [createTestPost({ id: "in-week", publishedDate: "2026-01-14" })],
}));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.ok(report.baseline, "An absent block is indistinguishable from a pre-N17 report");
assert.equal(report.baseline!.impressions.status, "insufficient-data");
});
test("should carry the baseline through to the saved report on disk", () => {
tempDir = setupTempDir();
const posts = [
priorPost("2025-12-15", 100),
priorPost("2025-12-17", 110),
priorPost("2025-12-29", 120),
priorPost("2025-12-31", 130),
priorPost("2026-01-05", 140),
createTestPost({ id: "in-week", publishedDate: "2026-01-14" }),
];
saveBatch(tempDir, createTestBatch({ posts }));
generateWeeklyReport(tempDir, "2026-W03");
const reloaded = loadWeeklyReport(tempDir, "2026-W03");
assert.ok(reloaded?.baseline, "The block must survive the round-trip to disk");
assert.equal(reloaded!.baseline!.impressions.status, "ok");
});
});
});