// Tests for local-media.mjs: a clip whose media and cues are files beside the // manifest (`src`, `cues`) -- the two cue shapes, the path rules, validation, // and the reader the build and resolve-windows hold. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; import path from "node:path"; import os from "node:os"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { clipLabel, createLocalMedia, hasLocalMedia, normaliseCueDoc, resolveManifestPath, validateLocalMedia, } from "./local-media.mjs"; import { unresolvable } from "./resolve-windows.mjs"; import { buildVideo } from "./build-video.mjs"; // The same three cues, in each shape. const CORPUS = { title: "Episode 12", channel: "Demo Feed", uploadDate: "20260102", duration: 12.5, platform: "podcast", cues: [ { start: 4, end: 7.25, text: "and then the second." }, { start: 0.5, end: 3.9, text: "First thing." }, { start: 7.5, end: 12, text: "The third one?" }, ], }; const PARAKEET = { duration_seconds: 12.5, chunks: 3, text: "First thing. and then the second. The third one?", chunk_data: [ { start_time: 0.5, end_time: 3.9, text: "First thing." }, { start_time: 4, end_time: 7.25, text: "and then the second." }, { start_time: 7.5, end_time: 12, text: "The third one?" }, ], }; async function dir() { const root = await mkdtemp(path.join(os.tmpdir(), "rtv-local-media-")); await mkdir(path.join(root, "media"), { recursive: true }); return root; } test("both cue shapes normalise to the same cues, in time order", () => { const a = normaliseCueDoc(CORPUS); const b = normaliseCueDoc(PARAKEET); assert.deepEqual(a.cues, b.cues); assert.deepEqual(a.cues.map((c) => c.start), [0.5, 4, 7.5]); assert.equal(a.duration, 12.5); assert.equal(b.duration, 12.5); // The corpus shape carries the record's metadata; the parakeet one has none. assert.equal(a.title, "Episode 12"); assert.equal(a.platform, "podcast"); assert.equal(b.title, undefined); }); test("a cue with no finite start or end is dropped; neither shape is an error", () => { const got = normaliseCueDoc({ cues: [{ start: 1, end: 2, text: "a" }, { start: "x", end: 3, text: "b" }, { start: 4 }] }); assert.deepEqual(got.cues, [{ start: 1, end: 2, text: "a" }]); assert.throws(() => normaliseCueDoc({ segments: [] }, "m/ep.json"), /m\/ep\.json: neither a transcript\.cues\.json/); assert.throws(() => normaliseCueDoc(null), /not a cue document/); }); test("resolveManifestPath: inside the manifest's directory, or absolute only with leave", () => { const base = path.resolve("/srv/proj"); assert.deepEqual(resolveManifestPath(base, "media/ep.mp3"), { file: path.join(base, "media", "ep.mp3") }); assert.deepEqual(resolveManifestPath(base, "media/../ep.mp3"), { file: path.join(base, "ep.mp3") }); assert.match(resolveManifestPath(base, "../other/ep.mp3").error, /climbs out of the manifest's directory/); assert.match(resolveManifestPath(base, "media/../../ep.mp3").error, /climbs out/); assert.match(resolveManifestPath(base, "..").error, /climbs out/); // A name that merely starts with two dots is inside. assert.deepEqual(resolveManifestPath(base, "..ep.mp3"), { file: path.join(base, "..ep.mp3") }); assert.match(resolveManifestPath(base, "/mnt/feed/ep.mp3").error, /absolute; set render\.allowAbsoluteSrc/); assert.deepEqual(resolveManifestPath(base, "/mnt/feed/ep.mp3", { allowAbsolute: true }), { file: "/mnt/feed/ep.mp3" }); assert.match(resolveManifestPath(base, "").error, /non-empty/); }); test("validateLocalMedia: every problem, each naming its entry", async () => { const root = await dir(); try { await writeFile(path.join(root, "media", "ep.mp3"), "x"); await writeFile(path.join(root, "media", "ep.json"), JSON.stringify(PARAKEET)); await writeFile(path.join(root, "media", "bad.json"), JSON.stringify({ words: [] })); const timeline = [ { id: "ok", type: "clip", src: "media/ep.mp3", cues: "media/ep.json" }, { id: "nofile", type: "clip", src: "media/missing.mp3" }, { id: "up", type: "clip", src: "../ep.mp3" }, { id: "abs", type: "clip", src: path.join(root, "media", "ep.mp3") }, { id: "both", type: "clip", src: "media/ep.mp3", video: "abc123" }, { id: "nocues", type: "clip", src: "media/ep.mp3", cues: "media/none.json" }, { id: "badcues", type: "clip", src: "media/ep.mp3", cues: "media/bad.json" }, { id: "orphan", type: "clip", video: "abc123", cues: "media/ep.json" }, // Not this field: an image's `src` is a picture, and a card is no clip. { id: "img", type: "image", src: "media/missing.png" }, { id: "card", type: "card", src: "whatever" }, ]; const errors = await validateLocalMedia(timeline, root); assert.equal(errors.length, 7, errors.join("\n")); assert.match(errors[0], /^timeline\[1\] nofile: no media at .*missing\.mp3 \(src: media\/missing\.mp3\)$/); assert.match(errors[1], /^timeline\[2\] up: src \.\.\/ep\.mp3 climbs out/); assert.match(errors[2], /^timeline\[3\] abs: src .* is absolute/); assert.match(errors[3], /^timeline\[4\] both: a clip takes `src` or `video`, not both$/); assert.match(errors[4], /^timeline\[5\] nocues: no cue file at .*none\.json/); assert.match(errors[5], /^timeline\[6\] badcues: media\/bad\.json: neither/); assert.match(errors[6], /^timeline\[7\] orphan: `cues` belongs to a clip with `src`$/); // With leave, the absolute one passes. const abs = await validateLocalMedia([timeline[3]], root, { allowAbsolute: true }); assert.deepEqual(abs, []); } finally { await rm(root, { recursive: true, force: true }); } }); test("createLocalMedia: the file, the cues and the metadata a record would have given", async () => { const root = await dir(); try { await writeFile(path.join(root, "media", "a.json"), JSON.stringify(CORPUS)); await writeFile(path.join(root, "media", "b.json"), JSON.stringify(PARAKEET)); const local = createLocalMedia({ baseDir: root }); const a = { id: "a", type: "clip", src: "media/ep.mp3", cues: "media/a.json" }; const b = { id: "b", type: "clip", src: "media/ep.mp3", cues: "media/b.json" }; const bare = { id: "c", type: "clip", src: "media/episode-12.mp3" }; assert.equal(local.file(a), path.join(root, "media", "ep.mp3")); assert.deepEqual(await local.cues(a), await local.cues(b)); assert.deepEqual(await local.cues(bare), []); assert.deepEqual(await local.meta(a), { title: "Episode 12", uploadDate: "20260102", webpageUrl: null, duration: 12.5, channel: "Demo Feed", platform: "podcast", }); // No record: the file's own name is the title, and there is no page. const m = await local.meta(bare); assert.equal(m.title, "episode-12"); assert.equal(m.webpageUrl, null); assert.equal(hasLocalMedia(bare), true); assert.equal(hasLocalMedia({ id: "v", type: "clip", video: "abc123" }), false); assert.equal(clipLabel(bare), "media/episode-12.mp3"); assert.equal(clipLabel({ video: "abc123", src: "x" }), "abc123"); } finally { await rm(root, { recursive: true, force: true }); } }); test("resolve-windows leaves a src clip alone without cues or a window", () => { assert.equal(unresolvable({ type: "clip", video: "abc123", start: 1, end: 2 }), null); assert.equal(unresolvable({ type: "clip", src: "m.mp3", start: 1, end: 2 }), "no cues file"); assert.equal(unresolvable({ type: "clip", src: "m.mp3", cues: "m.json" }), "no start/end (the whole file)"); assert.equal(unresolvable({ type: "clip", src: "m.mp3", cues: "m.json", start: 1, end: 2 }), null); }); test("buildVideo refuses a missing src before anything runs, naming the entry", async () => { const root = await dir(); try { const manifestPath = path.join(root, "video.manifest.json"); await writeFile(manifestPath, JSON.stringify({ slug: "demo", title: "Demo", render: { width: 320, height: 180, fps: 30 }, timeline: [ { id: "t0", type: "card", style: "chapter", heading: "Hi" }, { id: "p1", type: "clip", src: "media/gone.mp3" }, ], })); await assert.rejects( buildVideo({ manifestPath, opts: { noNetwork: true } }), /^Error: manifest: timeline\[1\] p1: no media at .*gone\.mp3 \(src: media\/gone\.mp3\)$/, ); } finally { await rm(root, { recursive: true, force: true }); } });