Files
Epic-The-Musical-Timeline/tests/album.test.ts
T
Lucas WintherandClaude Opus 5.5 288425501d feat(data): album data for all 9 sagas and 40 songs
Odyssey links point at Butcher & Lang (#1728), whose Greek names the
keywords were checked against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:56:32 +01:00

153 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
fanWorks, motifs, prevNextWithMotif, sagaOf, sagas, songById, songs, songsInSaga, songsWithMotif,
} from "../src/data/album";
import { scanLibrary } from "../src/content/library";
import { splitBooks } from "../src/content/homer";
const CONTENT = "/home/user/epic-the-musical-content";
const ODYSSEY = join(CONTENT, "Homers the Odyssey Book", "The Odyssey (Butcher and Lang).txt");
const TITLES = [
"The Horse and the Infant", "Just a Man", "Full Speed Ahead", "Open Arms", "Warrior of the Mind",
"Polyphemus", "Survive", "Remember Them", "My Goodbye",
"Storm", "Luck Runs Out", "Keep Your Friends Close", "Ruthlessness",
"Puppeteer", "Wouldn’t You Like", "Done For", "There Are Other Ways",
"The Underworld", "No Longer You", "Monster",
"Suffering", "Different Beast", "Scylla", "Mutiny", "Thunder Bringer",
"Legendary", "Little Wolf", "We'll Be Fine", "Love in Paradise", "God Games",
"Not Sorry for Loving You", "Dangerous", "Charybdis", "Get in the Water", "Six Hundred Strike",
"The Challenge", "Hold Them Down", "Odysseus", "I Can't Help but Wonder", "Would You Fall in Love with Me Again",
];
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
describe("album sagas", () => {
test("9 sagas ordered troy … ithaca", () => {
expect(sagas.map((s) => s.id)).toEqual([
"troy", "cyclops", "ocean", "circe", "underworld", "thunder", "wisdom", "vengeance", "ithaca",
]);
expect(sagas.map((s) => s.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test("every song is in exactly one known saga, sagaTrack runs 1..n", () => {
const ids = new Set(sagas.map((s) => s.id));
for (const song of songs) expect(ids.has(song.sagaId)).toBe(true);
for (const saga of sagas) {
const inSaga = songsInSaga(saga.id);
expect(inSaga.length).toBeGreaterThan(0);
expect(inSaga.map((s) => s.sagaTrack)).toEqual(inSaga.map((_, i) => i + 1));
for (const s of inSaga) expect(sagaOf(s)).toBe(saga);
}
});
});
describe("album songs", () => {
test("40 songs, tracks 1..40 contiguous", () => {
expect(songs.length).toBe(40);
expect(songs.map((s) => s.track)).toEqual(songs.map((_, i) => i + 1));
});
test("titles match the album in order", () => {
expect(songs.map((s) => s.title)).toEqual(TITLES);
});
test("ids are unique kebab-case titles", () => {
expect(new Set(songs.map((s) => s.id)).size).toBe(40);
expect(songById("wouldnt-you-like")?.title).toBe("Wouldn’t You Like");
expect(songById("ill-be-fine")).toBeUndefined();
for (const s of songs) expect(s.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
});
test("story time never goes backwards in album order", () => {
for (let i = 1; i < songs.length; i++) {
expect(songs[i]!.storyTime.yearsAfterTroy).toBeGreaterThanOrEqual(songs[i - 1]!.storyTime.yearsAfterTroy);
}
for (const s of songs) expect(s.storyTime.label.length).toBeGreaterThan(0);
});
test("landings as pinned", () => {
const landed = Object.fromEntries(songs.filter((s) => s.landing).map((s) => [s.id, s.landing]));
expect(landed).toEqual({
polyphemus: "cave", survive: "cave", "god-games": "arena",
"the-challenge": "hall", "hold-them-down": "hall", odysseus: "hall",
"i-cant-help-but-wonder": "hall", "would-you-fall-in-love-with-me-again": "hall",
});
});
test("every song has singers, 1–3 facts and a video link", () => {
for (const s of songs) {
expect(s.singers.length).toBeGreaterThan(0);
expect(s.facts.length).toBeGreaterThanOrEqual(1);
expect(s.facts.length).toBeLessThanOrEqual(3);
expect(s.videos.length).toBeGreaterThan(0);
for (const v of s.videos) expect(v.url).toStartWith("https://www.youtube.com/");
}
});
test("odyssey refs point at a Gutenberg #1728 (Butcher & Lang) book anchor", () => {
for (const s of songs) {
if (!s.odyssey) continue;
expect(s.odyssey.book).toBeGreaterThanOrEqual(1);
expect(s.odyssey.book).toBeLessThanOrEqual(24);
expect(s.odyssey.keywords.length).toBeGreaterThan(0);
expect(s.odyssey.url).toBe(
`https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap${String(s.odyssey.book).padStart(2, "0")}`,
);
}
});
test("fanWorks is empty in v1", () => {
expect(fanWorks).toEqual([]);
});
});
describe("album motifs", () => {
test("motif ids are unique; every referenced motif exists; every motif is used by ≥2 songs", () => {
const ids = new Set(motifs.map((m) => m.id));
expect(ids.size).toBe(motifs.length);
for (const s of songs) {
for (const id of s.motifIds) expect(ids.has(id)).toBe(true);
for (const p of s.parts) for (const id of p.motifIds) expect(ids.has(id)).toBe(true);
}
for (const m of motifs) expect(songsWithMotif(m.id).length).toBeGreaterThanOrEqual(2);
});
test("motif colours are hex colours", () => {
for (const m of motifs) expect(m.color).toMatch(/^#[0-9a-f]{6}$/i);
});
test("a song's part motifs are among its own motifs", () => {
for (const s of songs) for (const p of s.parts) for (const id of p.motifIds) expect(s.motifIds).toContain(id);
});
test("prevNextWithMotif on a middle occurrence returns both neighbours", () => {
const motif = motifs.find((m) => songsWithMotif(m.id).length >= 3)!;
const withIt = songsWithMotif(motif.id);
const mid = withIt[1]!;
expect(prevNextWithMotif(mid, motif.id)).toEqual({ prev: withIt[0]!, next: withIt[2]! });
expect(prevNextWithMotif(withIt[0]!, motif.id).prev).toBeNull();
expect(prevNextWithMotif(withIt.at(-1)!, motif.id).next).toBeNull();
});
});
describe("album vs the content folder", () => {
test.skipIf(!existsSync(join(CONTENT, "Songs and Lyrics")))("scanLibrary titles match album titles in order", () => {
const lib = scanLibrary(CONTENT).sagas.flatMap((s) => s.songs.map((x) => norm(x.title)));
expect(lib).toEqual(songs.map((s) => norm(s.title)));
});
test.skipIf(!existsSync(ODYSSEY))("odyssey keywords occur in that book of Butcher & Lang", () => {
const books = new Map(splitBooks(readFileSync(ODYSSEY, "utf8")).map((b) => [b.book, [b.summary, ...b.paragraphs].join(" ")]));
const missing: string[] = [];
for (const s of songs) {
if (!s.odyssey) continue;
const text = books.get(s.odyssey.book) ?? "";
for (const k of s.odyssey.keywords) if (!text.includes(k)) missing.push(`${s.id}: book ${s.odyssey.book} "${k}"`);
}
expect(missing).toEqual([]);
});
});