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>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 04:56:32 +01:00
1 parent 3235df8bea
commit 288425501d
8 files changed
+611 -11

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
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([]);
});
});