import { describe, test, afterEach } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { loadQueueEntries, labelPosts } from "../src/utils/queue-join.js"; import type { QueueEntry } from "../src/utils/queue-join.js"; /** * Format and pillar are NOT analytics data — they live in the post queue, which * the operator's create surfaces write. The per-format / per-pillar baseline * therefore rests on a JOIN, and a join that guesses is worse than no labels at * all: a post filed under the wrong format would poison the very baseline it is * being measured against. Every ambiguous case here resolves to UNLABELLED. */ describe("queue-join", () => { let tempDir: string; afterEach(() => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } }); const post = (publishedDate: string, title = "Some post") => ({ publishedDate, title, metrics: { impressions: 1000, engagementRate: 5 }, }); const entry = (overrides: Partial & { scheduled_date: string }): QueueEntry => ({ id: overrides.id ?? `q-${overrides.scheduled_date}`, scheduled_date: overrides.scheduled_date, pillar: overrides.pillar ?? "ai-agents", format: overrides.format ?? "text", hook_preview: overrides.hook_preview, }); describe("loadQueueEntries", () => { test("should return an empty list when there is no queue file", () => { tempDir = mkdtempSync(join(tmpdir(), "queue-join-")); const result = loadQueueEntries(join(tempDir, "queue.json")); assert.deepEqual(result, [], "A missing queue is unlabelled data, not an error"); }); test("should return an empty list for malformed JSON rather than throwing", () => { tempDir = mkdtempSync(join(tmpdir(), "queue-join-")); const file = join(tempDir, "queue.json"); writeFileSync(file, "{ not json", "utf8"); assert.deepEqual(loadQueueEntries(file), []); }); test("should return an empty list when the file is not an array", () => { tempDir = mkdtempSync(join(tmpdir(), "queue-join-")); const file = join(tempDir, "queue.json"); writeFileSync(file, JSON.stringify({ entries: [] }), "utf8"); assert.deepEqual(loadQueueEntries(file), []); }); test("should read entries and keep those missing pillar or format", () => { tempDir = mkdtempSync(join(tmpdir(), "queue-join-")); const file = join(tempDir, "queue.json"); writeFileSync( file, JSON.stringify([ { id: "a", scheduled_date: "2026-07-01", pillar: "ai", format: "text" }, { id: "b", scheduled_date: "2026-07-03" }, ]), "utf8" ); const result = loadQueueEntries(file); assert.equal(result.length, 2); assert.equal(result[1].pillar, undefined, "A partial entry is data; dropping it would hide posts"); }); test("should skip rows with no scheduled_date, which cannot join on anything", () => { tempDir = mkdtempSync(join(tmpdir(), "queue-join-")); const file = join(tempDir, "queue.json"); writeFileSync( file, JSON.stringify([{ id: "a", pillar: "ai", format: "text" }, { id: "b", scheduled_date: "2026-07-03" }]), "utf8" ); assert.equal(loadQueueEntries(file).length, 1); }); }); describe("labelPosts", () => { test("should label a post whose publish date matches a queue entry exactly", () => { const result = labelPosts([post("2026-07-08")], [entry({ scheduled_date: "2026-07-08", format: "carousel" })]); assert.equal(result[0].format, "carousel"); assert.equal(result[0].pillar, "ai-agents"); }); test("should label through a small reschedule gap", () => { // A queue entry is a PLAN; the operator publishing a day late is normal. const result = labelPosts([post("2026-07-09")], [entry({ scheduled_date: "2026-07-08" })]); assert.equal(result[0].format, "text"); }); test("should not label across a gap wider than two days", () => { const result = labelPosts([post("2026-07-15")], [entry({ scheduled_date: "2026-07-08" })]); assert.equal(result[0].format, undefined, "A week apart is a different post, not a reschedule"); assert.equal(result[0].pillar, undefined); }); test("should prefer an exact date match over a near one", () => { const result = labelPosts( [post("2026-07-08")], [entry({ scheduled_date: "2026-07-07", format: "video" }), entry({ scheduled_date: "2026-07-08", format: "text" })] ); assert.equal(result[0].format, "text"); }); test("should leave a post unlabelled when two entries tie on the same date", () => { const result = labelPosts( [post("2026-07-08")], [ entry({ id: "x", scheduled_date: "2026-07-08", format: "text" }), entry({ id: "y", scheduled_date: "2026-07-08", format: "carousel" }), ] ); assert.equal(result[0].format, undefined, "A coin-flip label would poison the format baseline"); }); test("should disambiguate same-date entries on the hook preview", () => { const result = labelPosts( [post("2026-07-08", "The thing nobody tells you about agent evals")], [ entry({ id: "x", scheduled_date: "2026-07-08", format: "text", hook_preview: "Three ways to lose a week" }), entry({ id: "y", scheduled_date: "2026-07-08", format: "carousel", hook_preview: "The thing nobody tells you", }), ] ); assert.equal(result[0].format, "carousel", "The hook is the only honest tiebreaker available"); }); test("should leave the post unlabelled when the queue is empty", () => { const result = labelPosts([post("2026-07-08")], []); assert.equal(result[0].format, undefined); }); test("should never consume one queue entry for two different posts", () => { // Two posts, one entry: labelling both would double-count the entry's // format and invent a second post of that format that never existed. const result = labelPosts([post("2026-07-08"), post("2026-07-09")], [entry({ scheduled_date: "2026-07-08" })]); const labelled = result.filter((r) => r.format !== undefined); assert.equal(labelled.length, 1, "An entry labels at most one post"); assert.equal(labelled[0].publishedDate, "2026-07-08", "The exact match wins the entry"); }); test("should preserve every post, labelled or not", () => { const posts = [post("2026-07-08"), post("2026-07-30")]; const result = labelPosts(posts, [entry({ scheduled_date: "2026-07-08" })]); assert.equal(result.length, 2, "Unlabelled posts stay in the list; they are just not grouped"); }); }); });