linkedin-studio/scripts/trends/tests/store.test.ts
Kjell Tore Guttormsen b185db9a12 feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]
The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
  field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
  R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
  rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
  CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
  the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).

score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.

Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 01:08:43 +02:00

854 lines
32 KiB
TypeScript

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
normalizeField,
trendId,
emptyStore,
loadStore,
saveStore,
addTrend,
queryByTopic,
history,
newestCaptureDate,
effectiveStatus,
setStatus,
markSurfaced,
} from "../src/store.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendStore } from "../src/types.js";
const tmp = () => mkdtempSync(join(tmpdir(), "trends-store-"));
describe("trends store", () => {
describe("normalizeField + trendId", () => {
test("normalizeField collapses whitespace + lowercases", () => {
assert.equal(normalizeField(" AI Trends\n2026 "), "ai trends 2026");
});
test("id is deterministic and (title+url)-derived", () => {
assert.equal(
trendId("AI agents go mainstream", "https://example.com/a"),
trendId("AI agents go mainstream", "https://example.com/a"),
);
});
test("id is stable across case + surrounding/collapsed whitespace in title AND url", () => {
const a = trendId("AI agents go mainstream", "https://example.com/article");
const b = trendId(" ai AGENTS go mainstream ", " HTTPS://EXAMPLE.COM/article ");
assert.equal(a, b, "normalization should collapse case + whitespace on both fields");
});
test("different title yields a different id (same url)", () => {
assert.notEqual(
trendId("AI agents go mainstream", "https://example.com/a"),
trendId("AI copilots go mainstream", "https://example.com/a"),
);
});
test("different url yields a different id (same title)", () => {
assert.notEqual(
trendId("Same headline", "https://example.com/a"),
trendId("Same headline", "https://example.com/b"),
);
});
});
describe("emptyStore + load/save round-trip", () => {
test("emptyStore has the schema version and no trends", () => {
const s = emptyStore();
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(s.trends, []);
});
test("loadStore on a missing file returns an empty store (never throws)", () => {
const dir = tmp();
try {
const s = loadStore(join(dir, "does-not-exist.json"));
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(s.trends, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("saveStore creates the parent dir, then loadStore round-trips", () => {
const dir = tmp();
const path = join(dir, "nested", "trends.json");
try {
let store: TrendStore = emptyStore();
store = addTrend(store, {
title: "Norway opens public-sector AI sandbox",
url: "https://example.com/sandbox",
source: "tavily",
capturedAt: "2026-06-20",
topics: ["public-sector", "ai-policy"],
summary: "A regulatory sandbox for public bodies.",
}).store;
saveStore(path, store);
assert.ok(existsSync(path));
const loaded = loadStore(path);
assert.equal(loaded.trends.length, 1);
assert.equal(loaded.trends[0].title, "Norway opens public-sector AI sandbox");
assert.equal(loaded.trends[0].source, "tavily");
assert.equal(loaded.trends[0].summary, "A regulatory sandbox for public bodies.");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("addTrend", () => {
test("adds a new trend with a computed id and reports added:true", () => {
const res = addTrend(emptyStore(), {
title: "Agentic workflows hit production",
url: "https://example.com/agentic",
source: "websearch",
capturedAt: "2026-06-20",
topics: ["agents", "engineering"],
});
assert.equal(res.added, true);
assert.equal(res.merged, false);
assert.equal(res.store.trends.length, 1);
assert.equal(res.store.trends[0].id, trendId(res.store.trends[0].title, res.store.trends[0].url));
});
test("preserves title/url/summary verbatim (normalizes only for the id)", () => {
const title = " Mixed CASE Headline ";
const url = " https://EXAMPLE.com/Path ";
const summary = " A summary with spacing. ";
const res = addTrend(emptyStore(), {
title,
url,
source: "manual",
capturedAt: "2026-06-20",
topics: ["x"],
summary,
});
assert.equal(res.store.trends[0].title, title, "title must not be mutated");
assert.equal(res.store.trends[0].url, url, "url must not be mutated");
assert.equal(res.store.trends[0].summary, summary, "summary must not be mutated");
});
test("omitting summary stores no summary key", () => {
const res = addTrend(emptyStore(), {
title: "No summary here",
url: "https://example.com/n",
source: "manual",
capturedAt: "2026-06-20",
topics: ["x"],
});
assert.equal("summary" in res.store.trends[0], false);
});
test("dedupes on normalized title+url: same trend twice → one entry, topics unioned", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Reasoning models reshape RAG",
url: "https://example.com/rag",
source: "tavily",
capturedAt: "2026-06-19",
topics: ["rag"],
}).store;
const res2 = addTrend(store, {
title: " reasoning MODELS reshape rag ",
url: " HTTPS://EXAMPLE.COM/rag ",
source: "gemini",
capturedAt: "2026-06-20",
topics: ["search", "rag"],
});
assert.equal(res2.added, false, "duplicate title+url must not add a second entry");
assert.equal(res2.merged, true, "new topics on a duplicate must report merged:true");
assert.equal(res2.store.trends.length, 1);
assert.deepEqual(
[...res2.store.trends[0].topics].sort(),
["rag", "search"],
"topics must be unioned on re-capture",
);
});
test("a duplicate keeps the first sighting's source + capturedAt (provenance of first sight)", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same trend",
url: "https://example.com/s",
source: "first-source",
capturedAt: "2026-06-01",
topics: ["a"],
}).store;
store = addTrend(store, {
title: "Same trend",
url: "https://example.com/s",
source: "second-source",
capturedAt: "2026-06-20",
topics: ["b"],
}).store;
assert.equal(store.trends[0].source, "first-source");
assert.equal(store.trends[0].capturedAt, "2026-06-01");
});
test("a duplicate with no new topics reports merged:false", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Stable trend",
url: "https://example.com/st",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a", "b"],
}).store;
const res2 = addTrend(store, {
title: "Stable trend",
url: "https://example.com/st",
source: "tavily",
capturedAt: "2026-06-02",
topics: ["b", "a"],
});
assert.equal(res2.added, false);
assert.equal(res2.merged, false, "no new tags → merged:false");
});
// ── RE-R2a: publishedAt persistence ──
test("RED: persists publishedAt on a new record when present", () => {
const res = addTrend(emptyStore(), {
title: "Dated trend",
url: "https://example.com/d",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
publishedAt: "2026-06-20",
});
assert.equal(res.store.trends[0].publishedAt, "2026-06-20");
});
test("regression guard: omits publishedAt when absent (no undefined-valued key)", () => {
const res = addTrend(emptyStore(), {
title: "Undated trend",
url: "https://example.com/u",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
});
assert.equal("publishedAt" in res.store.trends[0], false);
});
test("RED: re-capture keeps the first sighting's publishedAt (no overwrite), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same dated trend",
url: "https://example.com/sd",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a"],
publishedAt: "2026-05-30",
}).store;
const res2 = addTrend(store, {
title: "Same dated trend",
url: "https://example.com/sd",
source: "gemini",
capturedAt: "2026-06-20",
topics: ["a", "b"],
publishedAt: "2026-06-15",
});
assert.equal(res2.added, false);
assert.equal(res2.merged, true);
assert.equal(res2.store.trends[0].publishedAt, "2026-05-30", "first-sight publishedAt kept");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
test("regression guard: no back-fill — re-capture carrying publishedAt onto a record that lacked one does NOT add it", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Undated first sight",
url: "https://example.com/uf",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a"],
}).store; // no publishedAt on first sight
const res2 = addTrend(store, {
title: "Undated first sight",
url: "https://example.com/uf",
source: "tavily",
capturedAt: "2026-06-20",
topics: ["a", "b"],
publishedAt: "2026-06-15",
});
assert.equal(res2.merged, true, "topic union still reported");
assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance");
});
// ── RE-R3a: relevance score first-sight persistence (SC3) ──
const kortScore = {
mode: "kortform" as const,
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate" as const,
};
test("RED: persists score on a new record when present (first-sight)", () => {
const res = addTrend(emptyStore(), {
title: "Scored trend",
url: "https://example.com/sc",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
score: kortScore,
});
assert.deepEqual(res.store.trends[0].score, kortScore);
});
test("regression guard: omits score when absent (no undefined-valued key)", () => {
const res = addTrend(emptyStore(), {
title: "Unscored trend",
url: "https://example.com/us",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
});
assert.equal("score" in res.store.trends[0], false);
});
test("re-capture REFRESHES the score (last-wins, RE-R3b reverses R3a's D3), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a"],
score: kortScore,
}).store;
const lowerScore = {
mode: "kortform" as const,
dimensions: { pillar: 6, audience: 5, timing: 6, angle: 5, authority: 5 },
composite: 5.6,
priority: "Medium" as const,
};
const res2 = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "gemini",
capturedAt: "2026-06-20",
topics: ["a", "b"],
score: lowerScore,
});
assert.equal(res2.added, false);
assert.equal(res2.merged, true);
assert.deepEqual(res2.store.trends[0].score, lowerScore, "score refreshed last-wins (RE-R3b reverses R3a's D3)");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
});
describe("queryByTopic", () => {
const seed = (): TrendStore => {
let store = emptyStore();
store = addTrend(store, {
title: "Trend A",
url: "https://example.com/a",
source: "tavily",
capturedAt: "2026-05-01",
topics: ["agents", "engineering"],
}).store;
store = addTrend(store, {
title: "Trend B",
url: "https://example.com/b",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["agents", "engineering", "public-sector"],
}).store;
store = addTrend(store, {
title: "Trend C",
url: "https://example.com/c",
source: "manual",
capturedAt: "2026-06-10",
topics: ["privacy"],
}).store;
return store;
};
test("ranks hits by topic overlap (desc)", () => {
// B carries both queried topics; A carries only "agents" → distinct overlaps.
const hits = queryByTopic(seed(), ["agents", "public-sector"]);
assert.equal(hits.length, 2);
assert.equal(hits[0].trend.title, "Trend B"); // overlap 2
assert.equal(hits[0].topicOverlap, 2);
assert.equal(hits[1].trend.title, "Trend A"); // overlap 1 (agents only)
assert.equal(hits[1].topicOverlap, 1);
});
test("matching is case-insensitive on topics", () => {
const hits = queryByTopic(seed(), ["AGENTS"]);
assert.equal(hits.length, 2);
});
test("returns [] when nothing matches", () => {
assert.deepEqual(queryByTopic(seed(), ["nothing"]), []);
});
test("ties on overlap are broken by capturedAt (newest first)", () => {
const hits = queryByTopic(seed(), ["agents"]); // A and B both overlap 1
assert.equal(hits[0].trend.title, "Trend B"); // 2026-06-01 newer than 2026-05-01
assert.equal(hits[1].trend.title, "Trend A");
});
});
describe("history", () => {
const seed = (): TrendStore => {
let store = emptyStore();
for (const [title, capturedAt] of [
["Oldest", "2026-04-01"],
["Middle", "2026-05-01"],
["Newest", "2026-06-01"],
] as const) {
store = addTrend(store, {
title,
url: `https://example.com/${title}`,
source: "tavily",
capturedAt,
topics: ["x"],
}).store;
}
return store;
};
test("returns all trends newest-first", () => {
const h = history(seed());
assert.deepEqual(h.map((t) => t.title), ["Newest", "Middle", "Oldest"]);
});
test("respects an inclusive `since` filter", () => {
const h = history(seed(), { since: "2026-05-01" });
assert.deepEqual(h.map((t) => t.title), ["Newest", "Middle"]);
});
test("respects `limit`", () => {
const h = history(seed(), { limit: 1 });
assert.deepEqual(h.map((t) => t.title), ["Newest"]);
});
});
describe("newestCaptureDate (B-S3 staleness signal)", () => {
test("empty store → null (no captures yet → no nudge)", () => {
assert.equal(newestCaptureDate(emptyStore()), null);
});
test("single trend → its capturedAt", () => {
const store = addTrend(emptyStore(), {
title: "Solo",
url: "https://example.com/solo",
source: "tavily",
capturedAt: "2026-06-05",
topics: ["x"],
}).store;
assert.equal(newestCaptureDate(store), "2026-06-05");
});
test("multiple trends → the max (newest) capturedAt, regardless of insertion order", () => {
let store = emptyStore();
for (const [title, capturedAt] of [
["Middle", "2026-05-01"],
["Newest", "2026-06-01"],
["Oldest", "2026-04-01"],
] as const) {
store = addTrend(store, {
title,
url: `https://example.com/${title}`,
source: "tavily",
capturedAt,
topics: ["x"],
}).store;
}
assert.equal(newestCaptureDate(store), "2026-06-01");
});
});
describe("schema migration (RE-R2a / publishedAt v1→v2)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// ── genuinely RED: old loadStore returns the on-disk/garbage version unchanged ──
test("RED: a v1 store loads stamped as v2, records intact, no publishedAt invented", () => {
const v1 = JSON.stringify({
schemaVersion: 1,
trends: [
{
id: "abc123",
title: "Old trend",
url: "https://example.com/o",
source: "tavily",
capturedAt: "2026-05-01",
topics: ["ai"],
},
],
});
withFixture(v1, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v1 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
assert.deepEqual(s.trends[0].topics, ["ai"]);
assert.equal("publishedAt" in s.trends[0], false, "migration must not invent publishedAt");
});
});
test("RED: round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("RED: a non-numeric schemaVersion is coerced to the current version (old code passes the string through)", () => {
withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => {
assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION);
});
});
// ── GREEN-only regression guards: old code already returns the current version ──
test("regression guard: a v2 store loads idempotent (records + publishedAt intact)", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "x",
title: "T",
url: "https://example.com/t",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
publishedAt: "2026-05-30",
},
],
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].publishedAt, "2026-05-30");
});
});
test("regression guard: a store with no schemaVersion is stamped to the current version", () => {
const noVer = JSON.stringify({
trends: [
{
id: "x",
title: "T",
url: "https://example.com/t",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
},
],
});
withFixture(noVer, (path) => {
assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION);
});
});
});
describe("schema migration (RE-R3a / score v2→v3)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// RE-R3a v2→v3 block, reconciled to the v4 bump (RE-R3b): the stamped-version assertions track
// SCHEMA_VERSION (a v2 store now migrates to the current version), the v2 INPUT fixture stays literal.
test("a v2 store (no score) loads stamped as the current version, records intact, no score invented", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "abc123",
title: "Old dated trend",
url: "https://example.com/o",
source: "tavily",
capturedAt: "2026-05-01",
topics: ["ai"],
publishedAt: "2026-04-30",
},
],
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v2 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old dated trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
assert.equal(s.trends[0].publishedAt, "2026-04-30");
assert.deepEqual(s.trends[0].topics, ["ai"]);
assert.equal("score" in s.trends[0], false, "migration must not invent a score");
});
});
test("round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("a v3 store with score migrates to the current version, score preserved", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Scored",
url: "https://example.com/s",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score: {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
},
},
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(s.trends[0].score, {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
});
});
});
test("RED: a v3 store's score survives load → save → load (field preservation)", () => {
const score = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Survivor",
url: "https://example.com/sv",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score,
},
],
});
withFixture(v3, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.deepEqual(second.trends[0].score, score, "score must survive a load+resave (no field stripping)");
});
});
});
// ── RE-R3b: re-score on re-capture (last-wins; A2) ──
describe("addTrend — re-score on re-capture (RE-R3b)", () => {
const mkScore = (composite: number, priority: string, timing = 5) => ({
mode: "kortform",
dimensions: { pillar: 5, audience: 5, timing, angle: 5, authority: 5 },
composite,
priority,
});
const seed = (score?: unknown) => ({
title: "Re-scored trend",
url: "https://example.com/rs",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
...(score !== undefined ? { score } : {}),
});
test("RED: a duplicate with a DIFFERENT score replaces the stored score (last-wins), merged:true", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, {
...seed(mkScore(4.0, "Medium", 3)),
capturedAt: "2026-06-08",
topics: ["ai", "rag"],
});
assert.equal(res.added, false);
assert.equal(res.merged, true, "a changed score (or new topics) → merged:true");
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score must be the fresh one");
assert.equal(res.store.trends[0].source, "tavily", "provenance source unchanged");
assert.equal(res.store.trends[0].capturedAt, "2026-06-01", "provenance capturedAt unchanged (first-sight)");
assert.deepEqual([...res.store.trends[0].topics].sort(), ["ai", "rag"], "topics still unioned");
});
test("RED: a re-capture with a BYTE-IDENTICAL score and no new topics → merged:false (no false-merge)", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(mkScore(8.1, "Immediate", 9)), capturedAt: "2026-06-09" });
assert.equal(res.added, false);
assert.equal(res.merged, false, "identical score + same topics → not a merge");
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9));
});
test("RED: a duplicate with NO score leaves the stored score unchanged", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(), capturedAt: "2026-06-10" });
assert.equal(res.added, false);
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9), "no input score → keep the stored one");
});
test("RED: re-scoring an ACTED trend updates the score but never resets status/surfacedCount", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
// Simulate a handled, surfaced record (status/surfacedCount are set by act/markSurfaced, not addTrend).
(store.trends[0] as Record<string, unknown>).status = "acted";
(store.trends[0] as Record<string, unknown>).surfacedCount = 3;
const res = addTrend(store, { ...seed(mkScore(4.0, "Medium", 3)), capturedAt: "2026-06-11" });
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score refreshed");
assert.equal((res.store.trends[0] as Record<string, unknown>).status, "acted", "status must NOT reset on re-score");
assert.equal((res.store.trends[0] as Record<string, unknown>).surfacedCount, 3, "surfacedCount must NOT change on re-score");
});
});
// ── RE-R3b: schema migration v3→v4 (additive-optional lifecycle fields) ──
describe("schema migration (RE-R3b / lifecycle v3→v4)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const scored = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
// ── genuinely RED while SCHEMA_VERSION=3: loadStore(v3).schemaVersion===3 ≠ 4 (hard-4 device) ──
test("RED: a v3 store (no lifecycle fields) loads stamped as v4, records intact, no field invented", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{ id: "x", title: "Scored", url: "https://example.com/s", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], score: scored },
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v3 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.deepEqual(s.trends[0].score, scored, "score intact");
assert.equal("status" in s.trends[0], false, "migration must not invent a status");
assert.equal("surfacedCount" in s.trends[0], false, "migration must not invent a surfacedCount");
assert.equal("lastSurfacedAt" in s.trends[0], false, "migration must not invent a lastSurfacedAt");
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:4 to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 3, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("a v4 store with lifecycle fields loads idempotent", () => {
const v4 = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
trends: [
{ id: "y", title: "Handled", url: "https://example.com/h", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "acted", surfacedCount: 3, lastSurfacedAt: "2026-06-25" },
],
});
withFixture(v4, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].status, "acted");
assert.equal(s.trends[0].surfacedCount, 3);
assert.equal(s.trends[0].lastSurfacedAt, "2026-06-25");
});
});
test("a v4 store's lifecycle fields survive load → save → load (field preservation)", () => {
const v4 = JSON.stringify({
schemaVersion: 4,
trends: [
{ id: "z", title: "Persist", url: "https://example.com/p", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "skipped", surfacedCount: 2, lastSurfacedAt: "2026-06-24" },
],
});
withFixture(v4, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.equal(second.trends[0].status, "skipped");
assert.equal(second.trends[0].surfacedCount, 2);
assert.equal(second.trends[0].lastSurfacedAt, "2026-06-24");
});
});
});
// ── RE-R3b: lifecycle functions (Phase B — RED against the stubs) ──
describe("lifecycle functions: effectiveStatus / setStatus / markSurfaced (RE-R3b)", () => {
const seedStore = () =>
addTrend(
addTrend(emptyStore(), { title: "A", url: "https://e/a", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"] }).store,
{ title: "B", url: "https://e/b", source: "tavily", capturedAt: "2026-06-02", topics: ["gov"] },
).store;
test("RED: effectiveStatus is the stored status, or 'new' when absent", () => {
assert.equal(effectiveStatus({ status: "acted" } as TrendRecord), "acted");
assert.equal(effectiveStatus({} as TrendRecord), "new");
});
test("RED: setStatus sets a present record's status (found:true); an absent id → found:false", () => {
const store = seedStore();
const idA = store.trends[0].id;
const res = setStatus(store, idA, "acted");
assert.equal(res.found, true);
assert.equal(store.trends[0].status, "acted");
assert.equal(setStatus(store, "missing-id", "skipped").found, false, "absent id → found:false (no throw)");
});
test("RED: markSurfaced increments + sets lastSurfacedAt; per-day idempotent; later day re-increments", () => {
const store = seedStore();
const idA = store.trends[0].id;
const r1 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r1.marked, 1);
assert.equal(store.trends[0].surfacedCount, 1);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-25");
assert.equal("surfacedCount" in store.trends[1], false, "an id not in the set is untouched");
const r2 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r2.marked, 0, "same-day re-mark is idempotent");
assert.equal(store.trends[0].surfacedCount, 1);
const r3 = markSurfaced(store, [idA], "2026-06-26");
assert.equal(r3.marked, 1, "a later day increments again");
assert.equal(store.trends[0].surfacedCount, 2);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-26");
});
});
});