linkedin-studio/scripts/trends/tests/cli.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

161 lines
6.4 KiB
TypeScript

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the
// `tsx` loader resolve regardless of the runner's cwd.
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
function run(args: string[], input: string): { status: number | null; stdout: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
input,
encoding: "utf8",
cwd: trendsDir,
});
return { status: res.status, stdout: res.stdout };
}
describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
describe("normalize (stdin JSON in, JSON out)", () => {
test("happy path: a JSON batch on stdin -> exit 0 + {items,errors} JSON", () => {
const batch = JSON.stringify([
{ source: "tavily", title: "Good", url: "https://example.com/a", topics: ["AI", "ai"] },
{ source: "tavily", title: "", url: "https://example.com/b", topics: ["x"] }, // bad: empty title
]);
const { status, stdout } = run(["normalize"], batch);
assert.equal(status, 0);
const out = JSON.parse(stdout);
assert.equal(out.items.length, 1);
assert.deepEqual(out.items[0].topics, ["ai"]); // deduped + lowercased
assert.equal(out.errors.length, 1);
assert.equal(out.errors[0].index, 1);
});
test("bad invocation: unparseable stdin -> exit 2", () => {
const { status } = run(["normalize"], "not json at all");
assert.equal(status, 2);
});
});
describe("score (stdin JSON in, JSON out)", () => {
test("happy path: scored candidates on stdin -> exit 0 + {kept,dropped} JSON", () => {
const candidates = JSON.stringify([
{ id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0
{ id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0
]);
const { status, stdout } = run(["score", "--mode", "kortform", "--threshold", "4.0"], candidates);
assert.equal(status, 0);
const out = JSON.parse(stdout);
assert.deepEqual(
out.kept.map((k: { id: string }) => k.id),
["high"],
);
assert.equal(out.kept[0].composite, 8.0);
assert.equal(out.kept[0].band.priority, "Immediate");
assert.deepEqual(
out.dropped.map((d: { id: string }) => d.id),
["low"],
);
});
test("bad invocation: an unknown --mode -> exit 2", () => {
const candidates = JSON.stringify([
{ id: "x", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } },
]);
const { status } = run(["score", "--mode", "bogus"], candidates);
assert.equal(status, 2);
});
});
describe("capture (stdin raw item|batch -> folds into the store) (RE-R2a / Step 4)", () => {
const tmpStore = () => join(mkdtempSync(join(tmpdir(), "trends-capture-")), "trends.json");
test("happy path: a valid item piped in -> folded into the store, added:1, publishedAt persisted", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Captured",
url: "https://example.com/c",
topics: ["ai"],
publishedAt: "2026-06-20",
},
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 0);
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
1,
"tally must sum to the input size",
);
const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.schemaVersion, 2);
assert.equal(persisted.trends.length, 1);
assert.equal(persisted.trends[0].publishedAt, "2026-06-20");
assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/);
assert.notEqual(
persisted.trends[0].capturedAt,
persisted.trends[0].publishedAt,
"capturedAt (when WE saw it) must be distinct from publishedAt (source date)",
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("a batch with one content-invalid item -> valid added, invalid in errors[], exit 0", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{ source: "tavily", title: "Valid", url: "https://example.com/v", topics: ["x"] },
{ title: "no source or url" },
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 1);
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
2,
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("re-capturing the same trend with a new topic -> merged:1, tally still sums", () => {
const store = tmpStore();
try {
const item = (topics: string[]) =>
JSON.stringify([{ source: "tavily", title: "Dup", url: "https://example.com/d", topics }]);
run(["capture", "--store", store, "--json"], item(["a"]));
const { status, stdout } = run(["capture", "--store", store, "--json"], item(["a", "b"]));
assert.equal(status, 0);
const summary = JSON.parse(stdout);
assert.equal(summary.added, 0);
assert.equal(summary.merged, 1);
assert.equal(summary.duplicates, 0);
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
1,
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("bad invocation: empty stdin -> exit 2", () => {
const { status } = run(["capture"], "");
assert.equal(status, 2);
});
});
});