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
This commit is contained in:
parent
b4e500fad4
commit
7a158030b6
10 changed files with 465 additions and 31 deletions
|
|
@ -2,6 +2,9 @@ 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.
|
||||
|
|
@ -67,4 +70,92 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue