linkedin-studio/scripts/trends/tests/item.test.ts
Kjell Tore Guttormsen 7a158030b6 feat(linkedin-studio): RE-R2a — item→store capture bridge + publishedAt persistence (schema v1→v2, lossless migrate) [skip-docs]
Closes the research-engine capture loop RE-R1 deferred:
- itemToInput(item, capturedAt): pure envelope→TrendInput bridge in item.ts —
  injects capturedAt, carries publishedAt verbatim; no id, no re-validate
- publishedAt persisted: TrendRecord/TrendInput gain it; addTrend conditional-spread,
  first-sight kept on re-capture (no back-fill). SCHEMA_VERSION 1→2 with a lossless
  forward migrate-on-load: Math.max(onDisk, current) + numeric-typeof coercion
  (string/NaN/absent → current; non-array trends coercion preserved verbatim)
- `capture` CLI: stdin raw item|batch → normalize → bridge → addTrend → saveStore once;
  tally {added,duplicates,merged,errors} from AddResult; content-invalid → errors[],
  exit 2 only on bad stdin; --json summary
- wiring: trend-spotter.md Step 4.5 N×`add` → one normalizing `capture` batch; README
  add/capture framing corrected; test-runner Section 16h (capture wiring, unconditional)
  + floors bumped (trends 62→79, ASSERT 87→90)

TDD: 17 new tests (12 genuinely-RED logic-RED + 5 regression guards), tsc clean,
gate 105/0/0. No version bump (additive, v0.5.2 dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 11:12:50 +02:00

227 lines
8.6 KiB
TypeScript

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
import { normalizeField } from "../src/store.js";
describe("trends item normalizer (RE-R1 / B1)", () => {
describe("normalizeItem — well-formed", () => {
test("a well-formed raw item normalizes to a canonical item (string fields verbatim)", () => {
const raw = {
source: "tavily",
title: "OpenAI ships a new reasoning model",
url: "https://example.com/Article-Path",
topics: ["ai", "reasoning"],
summary: "A short summary.",
};
const res = normalizeItem(raw);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.source, "tavily");
assert.equal(res.item.title, "OpenAI ships a new reasoning model"); // verbatim, case preserved
assert.equal(res.item.url, "https://example.com/Article-Path"); // verbatim, case-sensitive path
assert.equal(res.item.summary, "A short summary.");
assert.deepEqual(res.item.topics, ["ai", "reasoning"]);
});
test("topics are normalized (lowercase + whitespace) and deduped, order-stable", () => {
const res = normalizeItem({
source: "manual",
title: "T",
url: "https://example.com/t",
topics: ["AI", " Machine Learning ", "ai", "Machine Learning"],
});
assert.equal(res.ok, true);
if (!res.ok) return;
// "AI"/"ai" dedupe -> "ai"; " Machine Learning "/"Machine Learning" dedupe -> "machine learning"
assert.deepEqual(res.item.topics, ["ai", "machine learning"]);
// each topic equals store.normalizeField of the raw form (the same normalization)
assert.equal(res.item.topics[1], normalizeField(" Machine Learning "));
});
test("the canonical item carries NO id (the store derives it via addTrend->trendId)", () => {
const res = normalizeItem({
source: "tavily",
title: "No id here",
url: "https://example.com/x",
topics: ["x"],
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal((res.item as Record<string, unknown>).id, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "id"), false);
});
test("summary is optional — absent -> no summary key", () => {
const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t", topics: ["x"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("summary" in res.item, false);
});
test("topics absent -> empty topics array", () => {
const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t" });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.topics, []);
});
});
describe("normalizeItem — required-field validation", () => {
for (const field of ["source", "title", "url"] as const) {
test(`missing ${field} -> {ok:false} naming the field`, () => {
const base: Record<string, unknown> = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
};
delete base[field];
const res = normalizeItem(base);
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(
res.errors.some((e) => e.includes(field)),
`error should name ${field}: ${res.errors.join("; ")}`,
);
});
test(`empty/whitespace ${field} -> {ok:false} naming the field (no silent partial)`, () => {
const base: Record<string, unknown> = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
};
base[field] = " ";
const res = normalizeItem(base);
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes(field)));
});
}
test("a non-object raw -> {ok:false}", () => {
const res = normalizeItem("not an object" as unknown);
assert.equal(res.ok, false);
});
});
describe("normalizeItem — publishedAt", () => {
test("present and valid ISO date -> kept", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
publishedAt: "2026-06-20",
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.publishedAt, "2026-06-20");
});
test("absent -> undefined (no key)", () => {
const res = normalizeItem({ source: "tavily", title: "T", url: "https://example.com/t", topics: ["x"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.publishedAt, undefined);
assert.equal("publishedAt" in res.item, false);
});
test("present but invalid -> {ok:false} naming publishedAt", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
publishedAt: "not-a-date",
});
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("publishedAt")));
});
test("present but impossible calendar date -> {ok:false}", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["x"],
publishedAt: "2026-13-45",
});
assert.equal(res.ok, false);
});
});
describe("normalizeItems — batch partition", () => {
test("partitions a batch into {items, errors} with error indices", () => {
const raw = [
{ source: "tavily", title: "Good A", url: "https://example.com/a", topics: ["x"] },
{ source: "tavily", title: "", url: "https://example.com/b", topics: ["y"] }, // bad: empty title
{ source: "manual", title: "Good C", url: "https://example.com/c", topics: ["z", "z"] },
];
const { items, errors } = normalizeItems(raw);
assert.equal(items.length, 2);
assert.equal(errors.length, 1);
assert.equal(errors[0].index, 1);
assert.ok(errors[0].errors.some((e) => e.includes("title")));
assert.deepEqual(
items.map((i) => i.title),
["Good A", "Good C"],
);
assert.deepEqual(items[1].topics, ["z"]); // deduped
});
test("an empty batch -> empty partition", () => {
const { items, errors } = normalizeItems([]);
assert.deepEqual(items, []);
assert.deepEqual(errors, []);
});
});
describe("itemToInput — bridge to the store input (RE-R2a / B-bridge)", () => {
// Build a realistic envelope via the normalizer (the actual upstream path).
const mkItem = (extra: Record<string, unknown> = {}) => {
const r = normalizeItem({
source: "tavily",
title: "OpenAI ships a reasoning model",
url: "https://example.com/Article",
topics: ["ai", "reasoning"],
summary: "A short summary.",
...extra,
});
assert.equal(r.ok, true);
if (!r.ok) throw new Error("fixture item failed to normalize");
return r.item;
};
test("injects capturedAt and carries source/title/url/topics/summary/publishedAt verbatim", () => {
const input = itemToInput(mkItem({ publishedAt: "2026-06-20" }), "2026-06-24");
assert.equal(input.capturedAt, "2026-06-24");
assert.equal(input.source, "tavily");
assert.equal(input.title, "OpenAI ships a reasoning model");
assert.equal(input.url, "https://example.com/Article");
assert.deepEqual(input.topics, ["ai", "reasoning"]);
assert.equal(input.summary, "A short summary.");
assert.equal(input.publishedAt, "2026-06-20");
});
test("derives NO id (the store owns id via addTrend->trendId)", () => {
const input = itemToInput(mkItem(), "2026-06-24") as Record<string, unknown>;
assert.equal("id" in input, false);
});
test("an item without publishedAt -> input omits the key (not undefined-valued)", () => {
const input = itemToInput(mkItem(), "2026-06-24"); // mkItem carries no publishedAt
assert.equal("publishedAt" in input, false);
});
test("field-confusion guard: capturedAt (injected) is distinct from publishedAt (carried)", () => {
const input = itemToInput(mkItem({ publishedAt: "2026-06-20" }), "2026-06-24");
assert.notEqual(input.capturedAt, input.publishedAt);
assert.equal(input.publishedAt, "2026-06-20");
assert.equal(input.capturedAt, "2026-06-24");
});
});
});