linkedin-studio/scripts/analytics/tests/monthly.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

181 lines
6.7 KiB
TypeScript

import { describe, test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { generateMonthlyReport } from "../src/reports/monthly.js";
import { saveBatch } from "../src/utils/storage.js";
import type { PostAnalytics, AnalyticsBatch, MonthlyReport } from "../src/models/types.js";
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
return {
id: `post-${date}-${impressions}`,
title: `Post on ${date}`,
publishedDate: date,
metrics: {
impressions,
reactions: Math.round(impressions * 0.05),
comments: Math.round(impressions * 0.01),
shares: Math.round(impressions * 0.005),
clicks: Math.round(impressions * 0.02),
engagementRate,
},
importedAt: "2026-04-01T00:00:00Z",
exportSource: "test.csv",
};
}
function createBatch(posts: PostAnalytics[]): AnalyticsBatch {
const dates = posts.map(p => p.publishedDate).sort();
return {
batchId: "test-batch-" + Math.random().toString(36).slice(2, 10),
importedAt: "2026-04-01T00:00:00Z",
exportFilename: "test.csv",
dateRange: { from: dates[0], to: dates[dates.length - 1] },
postCount: posts.length,
posts,
};
}
let tmpDir: string;
function setupTestRoot(posts: PostAnalytics[]): string {
tmpDir = mkdtempSync(join(tmpdir(), "monthly-test-"));
for (const dir of ["exports", "posts", "weekly-reports", "monthly-reports"]) {
mkdirSync(join(tmpDir, dir), { recursive: true });
}
if (posts.length > 0) {
saveBatch(tmpDir, createBatch(posts));
}
return tmpDir;
}
afterEach(() => {
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
});
describe("generateMonthlyReport", () => {
const marchPosts: PostAnalytics[] = [
createPost("2026-03-03", 1000, 3.0),
createPost("2026-03-05", 2000, 4.0),
createPost("2026-03-10", 1500, 3.5),
createPost("2026-03-17", 3000, 5.0),
createPost("2026-03-24", 2500, 4.5),
];
const febPosts: PostAnalytics[] = [
createPost("2026-02-03", 800, 2.5),
createPost("2026-02-10", 1200, 3.0),
createPost("2026-02-17", 900, 2.8),
];
test("filters posts to correct month", () => {
const root = setupTestRoot([...marchPosts, ...febPosts]);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalPosts, 5);
});
test("calculates correct monthly totals", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalImpressions, 10000); // 1000+2000+1500+3000+2500
assert.equal(report.summary.totalPosts, 5);
assert.equal(report.summary.avgImpressionsPerPost, 2000);
});
test("sums saves into totalSaves when posts carry manual saves data", () => {
const withSaves = (p: PostAnalytics, saves: number): PostAnalytics => ({
...p,
metrics: { ...p.metrics, saves },
});
const posts: PostAnalytics[] = [
withSaves(createPost("2026-03-03", 1000, 3.0), 8),
createPost("2026-03-05", 2000, 4.0), // no saves — partial coverage
withSaves(createPost("2026-03-10", 1500, 3.5), 13),
];
const root = setupTestRoot(posts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalSaves, 21, "totalSaves should sum 8 + 13");
});
test("leaves totalSaves undefined for saves-free months (backward-compat)", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalSaves, undefined);
});
test("rolls out-of-network shares up as an impressions-weighted average", () => {
const withReach = (p: PostAnalytics, outOfNetworkPct: number): PostAnalytics => ({
...p,
metrics: { ...p.metrics, outOfNetworkPct },
});
const posts: PostAnalytics[] = [
withReach(createPost("2026-03-03", 10000, 3.0), 20),
withReach(createPost("2026-03-05", 1000, 4.0), 80),
createPost("2026-03-10", 5000, 3.5), // no share entered — must not dilute
];
const root = setupTestRoot(posts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(
report.summary.avgOutOfNetworkPct,
25.5,
"Should weight by impressions (25.5), not average the shares flat (50)"
);
});
test("leaves avgOutOfNetworkPct undefined for reach-free months (backward-compat)", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.avgOutOfNetworkPct, undefined);
});
test("generates weekly breakdown within month", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.ok(report.byWeek.length > 0);
const totalPostsInWeeks = report.byWeek.reduce((sum, w) => sum + w.postCount, 0);
assert.equal(totalPostsInWeeks, 5);
});
test("calculates MoM deltas when previous month exists", () => {
const root = setupTestRoot([...febPosts, ...marchPosts]);
// First generate February report so it exists for comparison
generateMonthlyReport(root, "2026-02");
const report = generateMonthlyReport(root, "2026-03");
assert.notEqual(report.trends.comparedTo, null);
assert.equal(report.trends.comparedTo, "2026-02");
assert.notEqual(report.trends.percentChange.impressions, null);
assert.notEqual(report.trends.percentChange.postCount, null);
});
test("handles no previous month data", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.trends.comparedTo, null);
assert.equal(report.trends.percentChange.impressions, null);
assert.equal(report.trends.percentChange.engagement, null);
});
test("handles month with no posts", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-01");
assert.equal(report.summary.totalPosts, 0);
assert.equal(report.summary.totalImpressions, 0);
assert.equal(report.summary.avgImpressionsPerPost, 0);
assert.equal(report.byWeek.length, 0);
});
test("identifies top performers", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.ok(report.topPerformers.length > 0);
assert.equal(report.topPerformers[0].metrics.impressions, 3000);
});
test("sets correct month field", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.month, "2026-03");
});
});