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>
153 lines
6.5 KiB
TypeScript
153 lines
6.5 KiB
TypeScript
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([]);
|
||
});
|
||
});
|