import { test } from "node:test"; import assert from "node:assert/strict"; import { chunkCuesForContext, cuesToSnippets, mergeSnippets, windowCues, } from "./transcriptWindow"; import type { Cue } from "./vtt"; function cue(start: number, text = `t${start}`): Cue { return { start, end: start + 2, text }; } test("windowCues keeps cues within [center-before, center+after]", () => { const cues = [cue(0), cue(30), cue(60), cue(90), cue(120)]; const win = windowCues(cues, 60, { before: 45, after: 45 }); assert.deepEqual( win.map((c) => c.start), [30, 60, 90], ); }); test("windowCues handles a center at 0s", () => { const cues = [cue(0), cue(10), cue(50), cue(100)]; const win = windowCues(cues, 0, { before: 45, after: 45 }); assert.deepEqual( win.map((c) => c.start), [0, 10], ); }); test("windowCues caps to the maxCues closest, restoring chronological order", () => { const cues = Array.from({ length: 20 }, (_, i) => cue(i * 5)); // 0,5,…,95 const win = windowCues(cues, 50, { before: 100, after: 100, maxCues: 3 }); // Closest to 50 are 50,45,55 → returned sorted. assert.deepEqual( win.map((c) => c.start), [45, 50, 55], ); }); test("cuesToSnippets formats clock, collapses whitespace, caps to 240 chars", () => { const long = "a ".repeat(200); // 400 chars pre-slice const snips = cuesToSnippets([cue(65, "hello world"), cue(0, " "), cue(3600, long)]); // Empty cue dropped. assert.equal(snips.length, 2); assert.deepEqual(snips[0], { clock: "1:05", seconds: 65, text: "hello world" }); assert.equal(snips[1].clock, "1:00:00"); assert.ok(snips[1].text.length <= 240); }); test("mergeSnippets dedupes by seconds, keeps existing, sorts, caps", () => { const existing = [ { seconds: 100, text: "hit-a" }, { seconds: 20, text: "hit-b" }, ]; const incoming = [ { seconds: 20, text: "dupe-should-not-replace" }, { seconds: 10, text: "ctx-1" }, { seconds: 30, text: "ctx-2" }, ]; const merged = mergeSnippets(existing, incoming, 10); assert.deepEqual( merged.map((s) => s.seconds), [10, 20, 30, 100], ); // Existing snippet at 20 wins over the incoming duplicate. assert.equal(merged.find((s) => s.seconds === 20)?.text, "hit-b"); }); test("mergeSnippets never drops existing hits and stops adding at cap", () => { const existing = [ { seconds: 1, text: "a" }, { seconds: 2, text: "b" }, ]; const incoming = [ { seconds: 3, text: "c" }, { seconds: 4, text: "d" }, { seconds: 5, text: "e" }, ]; const merged = mergeSnippets(existing, incoming, 3); // Both existing kept; only one incoming added to reach cap 3. assert.equal(merged.length, 3); assert.deepEqual( merged.map((s) => s.seconds), [1, 2, 3], ); }); // --------------------------------------------------------------------------- // chunkCuesForContext — the digest chunker. windowCues cannot do this job (it is // center-based and measured in seconds), so these cases pin the contract the // digest generator depends on: full coverage, in order, with a shared overlap. // --------------------------------------------------------------------------- test("chunkCuesForContext returns one chunk when the transcript fits", () => { const cues = [cue(0), cue(5), cue(10)]; const chunks = chunkCuesForContext(cues, { maxCues: 10, overlapCues: 2 }); assert.equal(chunks.length, 1); assert.deepEqual(chunks[0], cues); }); test("chunkCuesForContext covers every cue at least once", () => { const cues = Array.from({ length: 25 }, (_, i) => cue(i * 10)); const chunks = chunkCuesForContext(cues, { maxCues: 10, overlapCues: 3 }); const seen = new Set(chunks.flat().map((c) => c.start)); assert.equal(seen.size, cues.length, "every cue appears in some chunk"); }); test("chunkCuesForContext overlaps consecutive chunks by overlapCues", () => { const cues = Array.from({ length: 25 }, (_, i) => cue(i * 10)); const chunks = chunkCuesForContext(cues, { maxCues: 10, overlapCues: 3 }); for (let i = 1; i < chunks.length; i++) { const prevTail = chunks[i - 1].slice(-3).map((c) => c.start); const head = chunks[i].slice(0, 3).map((c) => c.start); assert.deepEqual(head, prevTail, `chunk ${i} shares its head with the previous tail`); } }); test("chunkCuesForContext emits chunks in chronological order", () => { const cues = Array.from({ length: 40 }, (_, i) => cue(i * 10)); const chunks = chunkCuesForContext(cues, { maxCues: 12, overlapCues: 4 }); for (let i = 1; i < chunks.length; i++) { assert.ok( chunks[i][0].start > chunks[i - 1][0].start, "each chunk starts later than the previous one", ); } }); test("chunkCuesForContext never emits a trailing chunk that is pure overlap", () => { // 13 cues at maxCues 10 / overlap 3 steps by 7: [0..9], [7..12]. A third chunk // starting at 14 would be empty, and one starting at 12 would be re-work. const cues = Array.from({ length: 13 }, (_, i) => cue(i * 10)); const chunks = chunkCuesForContext(cues, { maxCues: 10, overlapCues: 3 }); assert.equal(chunks.length, 2); assert.equal(chunks[1][chunks[1].length - 1].start, 120); }); test("chunkCuesForContext clamps an overlap that would stall the loop", () => { // overlapCues >= maxCues would make step 0 and loop forever; it is clamped so // progress is guaranteed. const cues = Array.from({ length: 30 }, (_, i) => cue(i * 10)); const chunks = chunkCuesForContext(cues, { maxCues: 5, overlapCues: 99 }); assert.ok(chunks.length > 1 && chunks.length < 40); const seen = new Set(chunks.flat().map((c) => c.start)); assert.equal(seen.size, cues.length); }); test("chunkCuesForContext handles an empty transcript", () => { assert.deepEqual(chunkCuesForContext([], { maxCues: 10 }), []); });