import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import { batchRecencyComparator } from "./batchRecency"; import { clearRecencyCache } from "./recencyIndex"; // Run with: node_modules/.bin/tsx --test common/controller/batchRecency.test.ts async function writeMeta( channelsDir: string, slug: string, id: string, uploadDate: string, ): Promise { const dir = path.join(channelsDir, slug, "data", id); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, "metadata.info.json"), JSON.stringify({ id, upload_date: uploadDate }), ); } // One channel, three videos, dates deliberately out of id order so a sort that // silently fell back to "listed" would be visible. async function fixture(): Promise<{ paths: Paths; cleanup: () => Promise }> { const dir = await mkdtemp(path.join(tmpdir(), "batch-recency-")); clearRecencyCache(); const channelsDir = path.join(dir, "channels"); await writeMeta(channelsDir, "ch", "a-old", "20240101"); await writeMeta(channelsDir, "ch", "b-new", "20260812"); await writeMeta(channelsDir, "ch", "c-mid", "20250601"); return { paths: { lmdbPath: path.join(dir, "none.mdb"), channelsDir } as Paths, cleanup: () => rm(dir, { recursive: true, force: true }), }; } const IDS = ["a-old", "b-new", "c-mid"]; test('order "listed" reproduces today\'s order exactly', async () => { // The compatibility claim for this stage in one assertion. "listed" must not // return a comparator that happens to be order-preserving — it must return // NOTHING, so the call sites skip the sort entirely and a list is byte-for- // byte the list they already had. const { paths, cleanup } = await fixture(); try { const cmp = await batchRecencyComparator(paths, "ch", IDS, "listed"); assert.equal(cmp, null); } finally { await cleanup(); } }); test("newest/oldest sort a channel's candidates by upload date", async () => { const { paths, cleanup } = await fixture(); try { const newest = await batchRecencyComparator(paths, "ch", IDS, "newest"); assert.deepEqual([...IDS].sort(newest!), ["b-new", "c-mid", "a-old"]); clearRecencyCache(); const oldest = await batchRecencyComparator(paths, "ch", IDS, "oldest"); assert.deepEqual([...IDS].sort(oldest!), ["a-old", "c-mid", "b-new"]); } finally { await cleanup(); } }); test("recency COMPOSES with a prior sort rather than replacing it", async () => { // This is how the digest lane keeps both of its rules: sort by duration, then // stable-sort by date. makeRecencyComparator returns 0 for two videos sharing // a YYYYMMDD key and Array#sort is stable, so the duration order survives // inside a day. "newest day first, shortest video within a day." const dir = await mkdtemp(path.join(tmpdir(), "batch-recency-compose-")); try { clearRecencyCache(); const channelsDir = path.join(dir, "channels"); // Two videos share the newer day; the older day has one. await writeMeta(channelsDir, "ch", "new-long", "20260812"); await writeMeta(channelsDir, "ch", "new-short", "20260812"); await writeMeta(channelsDir, "ch", "old-short", "20240101"); const paths = { lmdbPath: path.join(dir, "none.mdb"), channelsDir, } as Paths; const durations = new Map([ ["new-long", 9000], ["new-short", 60], ["old-short", 30], ]); const rows = [...durations.keys()]; rows.sort((a, b) => durations.get(a)! - durations.get(b)!); assert.deepEqual(rows, ["old-short", "new-short", "new-long"]); const cmp = await batchRecencyComparator(paths, "ch", rows, "newest"); rows.sort(cmp!); // Newest day first; within that day the shortest-first order is intact. assert.deepEqual(rows, ["new-short", "new-long", "old-short"]); } finally { await rm(dir, { recursive: true, force: true }); } }); test("an unreadable corpus degrades to the listed order, never throws", async () => { // A recency lookup failing is not a reason to refuse to digest a channel. const cmp = await batchRecencyComparator( { lmdbPath: "/nonexistent/none.mdb", channelsDir: "/nonexistent/channels", } as Paths, "ch", IDS, "newest", ); // Every id falls to layer 4's "" key, so every pair compares equal and the // sort is a no-op — the listed order, reached honestly rather than by // dropping undatable videos out of the queue. assert.deepEqual([...IDS].sort(cmp!), IDS); }); test("an empty candidate list short-circuits", async () => { const cmp = await batchRecencyComparator( { lmdbPath: "x", channelsDir: "y" } as Paths, "ch", [], "newest", ); assert.equal(cmp, null); });