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
758 lines
24 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|