Files
Epic-The-Musical-Timeline/tests/content.test.ts
T

168 lines
7.5 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Server } from "bun";
import { parseLrc } from "../src/shared/lrc";
import { scanLibrary } from "../src/content/library";
import { splitBooks } from "../src/content/homer";
import { startServer } from "../src/server";
describe("parseLrc", () => {
test("parses timed lines, skips metadata, sorts, handles CRLF and multiple stamps", () => {
const text = "[ar:Someone]\r\n[00:12.50]Second\r\n[00:01.2]First\r\n[01:02.345][00:30]Twice\r\n\r\nno stamp\r\n";
expect(parseLrc(text)).toEqual([
{ time: 1.2, text: "First" },
{ time: 12.5, text: "Second" },
{ time: 30, text: "Twice" },
{ time: 62.345, text: "Twice" },
]);
});
test("keeps empty timed lines as blank text", () => {
expect(parseLrc("[00:05.00]")).toEqual([{ time: 5, text: "" }]);
});
});
const BOOK_FIXTURE = [
"The Project Gutenberg eBook of The Odyssey",
"BOOK I (in the contents, before the start marker)",
"*** START OF THE PROJECT GUTENBERG EBOOK THE ODYSSEY ***",
"Preface text.",
"Contents",
"BOOK I",
"BOOK II.",
"",
"BOOK I",
"",
"",
"THE GODS IN COUNCIL.",
"",
"",
"Tell me, O Muse, of that",
"ingenious hero.",
"",
"So he spoke.",
"BOOK II.",
"",
"ASSEMBLY.",
"",
"Now when the child of morning appeared.",
"*** END OF THE PROJECT GUTENBERG EBOOK THE ODYSSEY ***",
"License text.",
].join("\r\n");
describe("splitBooks", () => {
test("splits between the Gutenberg markers into books with summary and paragraphs", () => {
const books = splitBooks(BOOK_FIXTURE);
expect(books).toHaveLength(2);
expect(books[0]).toEqual({
book: 1,
heading: "BOOK I",
summary: "THE GODS IN COUNCIL.",
paragraphs: ["Tell me, O Muse, of that ingenious hero.", "So he spoke."],
});
expect(books[1]).toMatchObject({ book: 2, summary: "ASSEMBLY.", paragraphs: ["Now when the child of morning appeared."] });
});
});
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "epic-content-"));
const cyc = join(dir, "Songs and Lyrics", "EPIC - The Cyclops Saga (2023)");
const troy = join(dir, "Songs and Lyrics", "EPIC - The Troy Saga (2022)");
const wis = join(dir, "Songs and Lyrics", "EPIC - The Wisdom Saga (2024)");
for (const d of [cyc, troy, wis]) mkdirSync(d, { recursive: true });
const f = (saga: string, n: string, t: string) => `Jorge Rivera-Herrans - EPIC - ${saga} - ${n} - ${t}`;
writeFileSync(join(cyc, f("The Cyclops Saga", "02", "Survive") + ".flac"), "x".repeat(1000));
writeFileSync(join(cyc, f("The Cyclops Saga", "02", "Survive") + ".lrc"), "[00:01.00]a");
writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".flac"), "0123456789");
writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".lrc"), "[00:01.00]a");
writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".txt"), "plain");
writeFileSync(join(cyc, "folder.jpg"), "jpg");
writeFileSync(join(troy, f("The Troy Saga", "01", "The Horse and the Infant") + ".flac"), "x");
writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".flac"), "x");
writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".txt"), "plain lyrics");
mkdirSync(join(dir, "Homers the Odyssey Book"));
writeFileSync(join(dir, "Homers the Odyssey Book", "The Odyssey.txt"), BOOK_FIXTURE);
writeFileSync(join(dir, "Homers the Odyssey Book", "The Iliad.txt"), BOOK_FIXTURE.replace("ASSEMBLY.", "ROMAN NAMES."));
writeFileSync(join(dir, "Homers the Odyssey Book", "The Iliad (Lang, Leaf and Myers).txt"), BOOK_FIXTURE.replace("ASSEMBLY.", "GREEK NAMES."));
writeFileSync(join(dir, "outside-secret.txt"), "no");
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
describe("scanLibrary", () => {
test("lists sagas in story order with songs by track, lrc preferred over txt", () => {
const lib = scanLibrary(dir);
expect(lib.sagas.map((s) => s.name)).toEqual(["The Troy Saga", "The Cyclops Saga", "The Wisdom Saga"]);
const cyc = lib.sagas[1]!;
expect(cyc.year).toBe(2023);
expect(cyc.cover).toBe("/content/files/Songs%20and%20Lyrics/EPIC%20-%20The%20Cyclops%20Saga%20(2023)/folder.jpg");
expect(cyc.songs.map((s) => [s.track, s.title])).toEqual([[1, "Polyphemus"], [2, "Survive"]]);
expect(cyc.songs[0]!.lyrics).toMatchObject({ timed: true });
expect(cyc.songs[0]!.lyrics!.url.endsWith(".lrc")).toBe(true);
expect(lib.sagas[2]!.songs[0]!.lyrics).toMatchObject({ timed: false });
expect(lib.sagas[0]!.cover).toBeNull();
expect(lib.sagas[0]!.songs[0]!.lyrics).toBeNull();
});
test("a missing folder gives an empty library", () => {
expect(scanLibrary(join(dir, "nope")).sagas).toEqual([]);
});
});
describe("/content routes", () => {
let withContent: Server<undefined>;
let without: Server<undefined>;
beforeAll(() => {
withContent = startServer(0, { contentDir: join(dir) });
without = startServer(0, { contentDir: undefined });
});
afterAll(() => { withContent.stop(true); without.stop(true); });
const url = (s: Server<undefined>, p: string) => `http://localhost:${s.port}${p}`;
test("library.json lists the scanned library", async () => {
const res = await fetch(url(withContent, "/content/library.json"));
expect(res.status).toBe(200);
const lib = await res.json();
expect(lib.sagas).toHaveLength(3);
});
test("files are served, and a range request returns 206", async () => {
const lib = scanLibrary(dir);
const audio = lib.sagas[1]!.songs[0]!.audio;
const full = await fetch(url(withContent, audio));
expect(full.status).toBe(200);
expect(full.headers.get("content-type")).toBe("audio/flac");
expect(await full.text()).toBe("0123456789");
const part = await fetch(url(withContent, audio), { headers: { Range: "bytes=2-5" } });
expect(part.status).toBe(206);
expect(part.headers.get("content-range")).toBe("bytes 2-5/10");
expect(await part.text()).toBe("2345");
});
test("paths escaping the content folder are refused", async () => {
for (const p of ["/content/files/..%2F..%2Fetc%2Fpasswd", "/content/files/Songs%20and%20Lyrics/..%2F..%2F..%2Fetc%2Fhosts"]) {
const res = await fetch(url(withContent, p));
expect(res.status).toBe(404);
}
});
test("homer books are served as JSON", async () => {
const res = await fetch(url(withContent, "/content/homer/odyssey/2.json"));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ book: 2, summary: "ASSEMBLY." });
expect((await fetch(url(withContent, "/content/homer/odyssey/99.json"))).status).toBe(404);
});
test("the Greek-names translation is preferred, Butler is the fallback", async () => {
// iliad: both files present → Lang, Leaf and Myers; odyssey: only Butler's file in the fixture
expect(await (await fetch(url(withContent, "/content/homer/iliad/2.json"))).json()).toMatchObject({ summary: "GREEK NAMES." });
expect(await (await fetch(url(withContent, "/content/homer/odyssey/2.json"))).json()).toMatchObject({ summary: "ASSEMBLY." });
});
test("without a content folder every /content route is 404 and the site still works", async () => {
expect((await fetch(url(without, "/content/library.json"))).status).toBe(404);
expect((await fetch(url(without, "/content/homer/odyssey/1.json"))).status).toBe(404);
expect((await fetch(url(without, "/"))).status).toBe(200);
});
});