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
215 lines
7.9 KiB
TypeScript
215 lines
7.9 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");
|
|
});
|
|
|
|
/** Baseline framing (N17) — same contract as the weekly report. */
|
|
test("attaches a baseline built only from earlier months", () => {
|
|
const root = setupTestRoot([
|
|
createPost("2026-01-05", 100, 4),
|
|
createPost("2026-01-12", 110, 4),
|
|
createPost("2026-02-02", 120, 4),
|
|
createPost("2026-02-09", 130, 4),
|
|
createPost("2026-02-16", 140, 4),
|
|
// In-month outlier: must not feed the baseline it is compared against.
|
|
createPost("2026-03-03", 99999, 9),
|
|
]);
|
|
|
|
const report = generateMonthlyReport(root, "2026-03");
|
|
|
|
assert.ok(report.baseline, "A generated monthly report must carry a baseline block");
|
|
assert.equal(report.baseline!.historyBefore, "2026-03");
|
|
assert.equal(report.baseline!.impressions.status, "ok");
|
|
if (report.baseline!.impressions.status !== "ok") return;
|
|
assert.equal(report.baseline!.impressions.n, 5);
|
|
assert.equal(report.baseline!.impressions.median, 120);
|
|
});
|
|
|
|
test("refuses a monthly baseline verdict on thin history", () => {
|
|
const root = setupTestRoot([
|
|
createPost("2026-02-02", 120, 4),
|
|
createPost("2026-03-03", 500, 9),
|
|
]);
|
|
|
|
const report = generateMonthlyReport(root, "2026-03");
|
|
|
|
assert.ok(report.baseline);
|
|
assert.equal(report.baseline!.impressions.status, "insufficient-data");
|
|
});
|
|
});
|