import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js"; import type { TrendItem } from "../src/item.js"; import { normalizeField } from "../src/store.js"; import { scoreEnvelope } from "../src/score.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).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 = { 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 = { 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 = {}) => { 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; 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"); }); }); describe("normalizeItem — score validation (RE-R3a / SC2)", () => { const base = { source: "tavily", title: "Scored item", url: "https://example.com/s", topics: ["ai"], }; const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; test("a valid kortform score -> carried with the validated dims", () => { const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } }); assert.equal(res.ok, true); if (!res.ok) return; assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims }); }); test("a valid long-form score -> carried with its five dims", () => { const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 }; const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } }); assert.equal(res.ok, true); if (!res.ok) return; assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims }); }); test("absent score -> key omitted", () => { const res = normalizeItem(base); assert.equal(res.ok, true); if (!res.ok) return; assert.equal("score" in res.item, false); }); test("a bad mode -> structured error (no throw)", () => { const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; ")); }); test("a missing dimension -> structured error (no throw)", () => { const { authority, ...missing } = validDims; const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => e.includes("invalid score"))); }); test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => { const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } }); assert.equal(lo.ok, false); const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } }); assert.equal(hi.ok, false); }); test("a non-object score -> structured error (no throw)", () => { const res = normalizeItem({ ...base, score: "high" }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => e.includes("invalid score"))); }); test("an array dimensions -> structured error (no throw)", () => { const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => e.includes("invalid score"))); }); test("an array score -> structured error (no throw)", () => { const res = normalizeItem({ ...base, score: [] }); assert.equal(res.ok, false); }); }); describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => { const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => { const item: TrendItem = { source: "tavily", title: "T", url: "https://example.com/t", topics: ["ai"], score: { mode: "kortform", dimensions: validDims }, }; const input = itemToInput(item, "2026-06-24"); assert.deepEqual(input.score, scoreEnvelope("kortform", validDims)); }); test("an unscored item -> no score key", () => { const item: TrendItem = { source: "tavily", title: "T", url: "https://example.com/t", topics: ["ai"], }; const input = itemToInput(item, "2026-06-24") as Record; assert.equal("score" in input, false); }); test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => { const item: TrendItem = { source: "tavily", title: "T", url: "https://example.com/t", topics: ["ai"], score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } }, }; assert.throws(() => itemToInput(item, "2026-06-24")); }); }); });