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
161 lines
6.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|