168 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|