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
434 lines
22 KiB
TypeScript
434 lines
22 KiB
TypeScript
import { describe, test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { join } from "node:path";
|
||
|
||
import {
|
||
rankForBrief,
|
||
renderBrief,
|
||
briefSummary,
|
||
defaultBriefDir,
|
||
surfacedIds,
|
||
BRIEF_SCHEMA_VERSION,
|
||
} from "../src/brief.js";
|
||
import type { TrendRecord, TrendStore } from "../src/types.js";
|
||
|
||
const TODAY = "2026-06-24";
|
||
|
||
type TestScore = {
|
||
mode: "kortform" | "long-form";
|
||
dimensions: Record<string, number>;
|
||
composite: number;
|
||
priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
|
||
};
|
||
|
||
function mkTrend(
|
||
p: {
|
||
title: string;
|
||
url: string;
|
||
topics: string[];
|
||
capturedAt: string;
|
||
publishedAt?: string;
|
||
source?: string;
|
||
summary?: string;
|
||
score?: TestScore;
|
||
status?: "new" | "acted" | "skipped";
|
||
surfacedCount?: number;
|
||
lastSurfacedAt?: string;
|
||
},
|
||
): TrendRecord {
|
||
return {
|
||
id: p.title + "|" + p.url,
|
||
title: p.title,
|
||
url: p.url,
|
||
source: p.source ?? "tavily",
|
||
capturedAt: p.capturedAt,
|
||
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
|
||
topics: p.topics,
|
||
...(p.summary !== undefined ? { summary: p.summary } : {}),
|
||
...(p.score !== undefined ? { score: p.score } : {}),
|
||
...(p.status !== undefined ? { status: p.status } : {}),
|
||
...(p.surfacedCount !== undefined ? { surfacedCount: p.surfacedCount } : {}),
|
||
...(p.lastSurfacedAt !== undefined ? { lastSurfacedAt: p.lastSurfacedAt } : {}),
|
||
};
|
||
}
|
||
|
||
/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
|
||
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
|
||
return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
|
||
}
|
||
function mkStore(trends: TrendRecord[]): TrendStore {
|
||
return { schemaVersion: 2, trends };
|
||
}
|
||
|
||
describe("rankForBrief — grouping (SC1)", () => {
|
||
const pillars = ["AI", "gov"];
|
||
const store = mkStore([
|
||
mkTrend({ title: "T1 top", url: "https://e/1", topics: ["ai", "gov", "x"], publishedAt: "2026-06-22", capturedAt: "2026-06-23" }),
|
||
mkTrend({ title: "T2 single", url: "https://e/2", topics: ["AI"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||
mkTrend({ title: "T3 older1", url: "https://e/3", topics: ["gov"], publishedAt: "2026-06-01", capturedAt: "2026-06-01" }),
|
||
mkTrend({ title: "T4 noise", url: "https://e/4", topics: ["unrelated"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
|
||
mkTrend({ title: "T5 older2", url: "https://e/5", topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
|
||
test("topMatches = overlap>=2 & fresh only", () => {
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["T1 top"]);
|
||
});
|
||
test("singleMatches = overlap===1 & fresh only", () => {
|
||
assert.deepEqual(r.singleMatches.map((e) => e.trend.title), ["T2 single"]);
|
||
});
|
||
test("olderMatched = overlap>=1 & stale, sorted overlap desc", () => {
|
||
assert.deepEqual(r.olderMatched.map((e) => e.trend.title), ["T5 older2", "T3 older1"]);
|
||
});
|
||
test("overlap===0 excluded everywhere", () => {
|
||
const all = [...r.topMatches, ...r.singleMatches, ...r.olderMatched].map((e) => e.trend.title);
|
||
assert.ok(!all.includes("T4 noise"));
|
||
});
|
||
test("totals reflect trends/matched/fresh", () => {
|
||
assert.deepEqual(r.totals, { trends: 5, matched: 4, fresh: 2 });
|
||
});
|
||
test("matchedPillars preserve pillar case (case-insensitive match)", () => {
|
||
assert.deepEqual(r.topMatches[0].matchedPillars, ["AI", "gov"]);
|
||
});
|
||
});
|
||
|
||
describe("rankForBrief — within-group total order (SC1)", () => {
|
||
test("effectiveDate desc orders before title", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
]);
|
||
const r = rankForBrief(store, ["a", "b"], TODAY);
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]);
|
||
});
|
||
test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
]);
|
||
const r = rankForBrief(store, ["a", "b"], TODAY);
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
|
||
});
|
||
});
|
||
|
||
describe("rankForBrief — freshness (SC2)", () => {
|
||
const pillars = ["a"];
|
||
test("effectiveDate = publishedAt when present (fresh despite old capturedAt)", () => {
|
||
const r = rankForBrief(mkStore([mkTrend({ title: "P", url: "https://e/p", topics: ["a"], publishedAt: "2026-06-22", capturedAt: "2026-01-01" })]), pillars, TODAY);
|
||
assert.equal(r.singleMatches.length, 1);
|
||
assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
|
||
assert.equal(r.olderMatched.length, 0);
|
||
});
|
||
test("fallback to capturedAt when publishedAt absent", () => {
|
||
const r = rankForBrief(mkStore([mkTrend({ title: "C", url: "https://e/c", topics: ["a"], capturedAt: "2026-06-22" })]), pillars, TODAY);
|
||
assert.equal(r.singleMatches.length, 1);
|
||
assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
|
||
});
|
||
test("stale when capturedAt old and no publishedAt", () => {
|
||
const r = rankForBrief(mkStore([mkTrend({ title: "S", url: "https://e/s", topics: ["a"], capturedAt: "2026-01-01" })]), pillars, TODAY);
|
||
assert.equal(r.olderMatched.length, 1);
|
||
assert.equal(r.singleMatches.length, 0);
|
||
});
|
||
test("boundary: ageDays === freshDays is fresh (<=)", () => {
|
||
const r = rankForBrief(mkStore([mkTrend({ title: "B", url: "https://e/bd", topics: ["a"], publishedAt: "2026-06-17", capturedAt: "2026-06-17" })]), pillars, TODAY, { freshDays: 7 });
|
||
assert.equal(r.singleMatches.length, 1, "7d with freshDays 7 must be fresh");
|
||
assert.equal(r.singleMatches[0].ageDays, 7);
|
||
});
|
||
test("freshDays configurable: 10d fresh at 14, stale at 7", () => {
|
||
const t = mkTrend({ title: "X", url: "https://e/x", topics: ["a"], publishedAt: "2026-06-14", capturedAt: "2026-06-14" });
|
||
assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 14 }).singleMatches.length, 1);
|
||
assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 7 }).olderMatched.length, 1);
|
||
});
|
||
});
|
||
|
||
describe("renderBrief + briefSummary (SC3)", () => {
|
||
const pillars = ["AI", "gov"];
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", summary: "A short summary" }),
|
||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||
mkTrend({ title: "Gamma", url: "https://e/g", topics: ["gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
const md = renderBrief(r);
|
||
|
||
test("starts with YAML frontmatter", () => {
|
||
assert.ok(md.startsWith("---\n"), "brief must open with YAML frontmatter");
|
||
});
|
||
test("frontmatter carries date, store, schemaVersion", () => {
|
||
assert.match(md, /\ndate: 2026-06-24\n/);
|
||
assert.match(md, new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n"));
|
||
assert.match(md, /\nstore:/);
|
||
});
|
||
test("frontmatter summary === briefSummary(ranking); single line, no quote/newline", () => {
|
||
const summary = briefSummary(r);
|
||
assert.ok(!summary.includes('"'), "summary must not contain a double-quote");
|
||
assert.ok(!summary.includes("\n"), "summary must be a single line");
|
||
const m = md.match(/^summary: (.*)$/m);
|
||
assert.ok(m, "frontmatter has a summary line");
|
||
assert.equal(m![1], summary);
|
||
});
|
||
test("summary names the top entry when fresh matches exist", () => {
|
||
assert.ok(briefSummary(r).includes("Alpha"));
|
||
});
|
||
test("body has the three section markers", () => {
|
||
assert.ok(md.includes("Topp-treff"), "top section");
|
||
assert.ok(md.includes("Enkelt-treff"), "single section");
|
||
assert.ok(md.includes("Eldre i lager"), "older section");
|
||
});
|
||
test("deterministic: identical input -> identical bytes", () => {
|
||
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
|
||
});
|
||
test("empty ranking renders a valid no-fresh brief", () => {
|
||
const empty = rankForBrief(mkStore([]), pillars, TODAY);
|
||
const emd = renderBrief(empty);
|
||
assert.ok(emd.startsWith("---\n"));
|
||
assert.ok(briefSummary(empty).startsWith("Ingen ferske"), "no-fresh summary line");
|
||
const m = emd.match(/^summary: (.*)$/m);
|
||
assert.equal(m![1], briefSummary(empty));
|
||
});
|
||
});
|
||
|
||
describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
|
||
const pillars = ["a", "b"];
|
||
|
||
test("higher composite sorts first at the same overlap + freshness", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
|
||
mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
|
||
});
|
||
|
||
test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
|
||
});
|
||
|
||
test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
|
||
});
|
||
|
||
test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
|
||
mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
|
||
});
|
||
|
||
test("deterministic: identical scored input -> identical ranking order", () => {
|
||
const trends = [
|
||
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
|
||
];
|
||
const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
|
||
const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
|
||
assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
|
||
});
|
||
});
|
||
|
||
describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
|
||
const pillars = ["AI", "gov"];
|
||
|
||
test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
]);
|
||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(
|
||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Immediate (kortform) · Pillarer: AI, gov"),
|
||
"scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
|
||
);
|
||
});
|
||
|
||
test("unscored top-entry meta line is UNCHANGED (no token)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
]);
|
||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(
|
||
md.includes("- Kilde: tavily · Publisert: 2026-06-22 (2d) · Pillarer: AI, gov"),
|
||
"unscored top-entry meta line must be unchanged",
|
||
);
|
||
});
|
||
|
||
test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
|
||
]);
|
||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(
|
||
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
|
||
"scored bullet must carry · <priority> (<mode>) before · 🔗",
|
||
);
|
||
});
|
||
|
||
test("unscored bullet (single match) is UNCHANGED (no token)", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
|
||
]);
|
||
const md = renderBrief(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(
|
||
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
|
||
"unscored bullet must be unchanged",
|
||
);
|
||
});
|
||
|
||
test("briefSummary names the band (no mode) on a scored top", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
]);
|
||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 2d)."), `summary should carry the band: ${s}`);
|
||
assert.ok(!s.includes("kortform"), "summary must not carry the mode");
|
||
});
|
||
|
||
test("briefSummary omits the band token on an unscored top", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
]);
|
||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(s.includes("Topp: «Alpha» (AI · 2d)."), `unscored summary should omit the band: ${s}`);
|
||
});
|
||
|
||
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
]);
|
||
const s = briefSummary(rankForBrief(store, pillars, TODAY));
|
||
assert.ok(!s.includes('"'), "summary must not contain a double-quote");
|
||
assert.ok(!s.includes("\n"), "summary must be a single line");
|
||
assert.ok(s.includes("Immediate"), "summary still carries the band");
|
||
});
|
||
|
||
test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
]);
|
||
const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
|
||
assert.ok(!s.includes("· ·"), "no empty priority slot");
|
||
assert.ok(s.includes("Topp: «Solo» (AI · 2d)."), `single-pillar unscored summary: ${s}`);
|
||
assert.ok(!s.includes("\n"));
|
||
});
|
||
|
||
test("ranking: descriptor equals the exact pinned string", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
]);
|
||
const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
|
||
assert.ok(
|
||
md.includes("ranking: composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays 7"),
|
||
"the ranking descriptor must match the pinned RE-R3a string verbatim",
|
||
);
|
||
});
|
||
|
||
test("deterministic: identical scored input -> identical bytes", () => {
|
||
const store = mkStore([
|
||
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
|
||
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
|
||
});
|
||
});
|
||
|
||
describe("defaultBriefDir", () => {
|
||
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
|
||
const prev = process.env.LINKEDIN_STUDIO_DATA;
|
||
process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-brief-root";
|
||
try {
|
||
assert.equal(defaultBriefDir(), join("/tmp/lis-brief-root", "trends", "morning-brief"));
|
||
} finally {
|
||
if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
|
||
else process.env.LINKEDIN_STUDIO_DATA = prev;
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("RE-R3b — exclude acted/skipped (A3)", () => {
|
||
const pillars = ["AI", "gov"];
|
||
const store = mkStore([
|
||
mkTrend({ title: "New top", url: "https://e/n", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
|
||
mkTrend({ title: "Acted top", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
|
||
mkTrend({ title: "Skipped single", url: "https://e/s", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
|
||
]);
|
||
const r = rankForBrief(store, pillars, TODAY);
|
||
|
||
test("RED: acted/skipped are dropped from every bucket", () => {
|
||
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["New top"]);
|
||
assert.deepEqual(r.singleMatches.map((e) => e.trend.title), []);
|
||
assert.deepEqual(r.olderMatched.map((e) => e.trend.title), []);
|
||
});
|
||
test("RED: totals.trends counts the full inventory (incl. handled); matched is post-filter", () => {
|
||
assert.equal(r.totals.trends, 3, "full store count");
|
||
assert.equal(r.totals.matched, 1, "only the new record is matched");
|
||
});
|
||
test("RED: a store whose only matches are handled → no fresh + the empty summary", () => {
|
||
const s = mkStore([
|
||
mkTrend({ title: "A", url: "https://e/aa", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
|
||
mkTrend({ title: "B", url: "https://e/bb", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
|
||
]);
|
||
const rr = rankForBrief(s, pillars, TODAY);
|
||
assert.equal(rr.totals.fresh, 0);
|
||
assert.match(briefSummary(rr), /Ingen ferske tema-signaler/);
|
||
});
|
||
});
|
||
|
||
describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => {
|
||
const pillars = ["AI", "gov"];
|
||
|
||
test("RED: a top entry carries the id in backticks (copy-paste-ready for act/skip)", () => {
|
||
const s = mkStore([mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" })]);
|
||
const md = renderBrief(rankForBrief(s, pillars, TODAY));
|
||
assert.ok(md.includes("· `Top|https://e/t`"), "top entry meta line must end with the id in backticks");
|
||
});
|
||
test("RED: a single-match bullet carries the id in backticks", () => {
|
||
const s = mkStore([mkTrend({ title: "Single", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" })]);
|
||
const md = renderBrief(rankForBrief(s, pillars, TODAY));
|
||
assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks");
|
||
});
|
||
test("RED: · sett Nx appears only when surfacedCount >= 2 (prior-day count)", () => {
|
||
const s = mkStore([
|
||
mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 3 }),
|
||
mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }),
|
||
]);
|
||
const md = renderBrief(rankForBrief(s, pillars, TODAY));
|
||
assert.ok(md.includes("· sett 3x"), "surfacedCount 3 → · sett 3x");
|
||
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker");
|
||
});
|
||
test("RED: the ranking: descriptor ends with '; excludes acted/skipped'", () => {
|
||
const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
|
||
assert.match(
|
||
md,
|
||
/\nranking: composite desc, then pillar-overlap desc, then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("RE-R3b — surfacedIds (D7, Phase B)", () => {
|
||
test("RED: surfacedIds = topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)", () => {
|
||
const olders = Array.from({ length: 7 }, (_, i) =>
|
||
mkTrend({ title: "O" + i, url: "https://e/o" + i, topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
|
||
);
|
||
const s = mkStore([
|
||
mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
|
||
mkTrend({ title: "Sg", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
|
||
...olders,
|
||
]);
|
||
const r = rankForBrief(s, ["AI", "gov"], TODAY);
|
||
const expected = [...r.topMatches, ...r.singleMatches, ...r.olderMatched.slice(0, 5)].map((e) => e.trend.id);
|
||
assert.deepEqual(surfacedIds(r), expected);
|
||
assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5");
|
||
});
|
||
});
|