import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import type { HomerBook } from "../src/content/homer"; import { createStore, DEFAULT_STATE, type AppState } from "../src/app/state"; beforeAll(() => GlobalRegistrator.register({ url: "http://localhost:3000/" })); afterAll(() => GlobalRegistrator.unregister()); // Placeholder text only: never real lyrics in tests. const library = { sagas: [{ name: "The Cyclops Saga", year: 2023, cover: "/content/files/cyclops/folder.jpg", songs: [{ track: 1, title: "Polyphemus", audio: "/content/files/cyclops/01.flac", lyrics: { url: "/content/files/cyclops/01.lrc", timed: true } }], }], }; const book: HomerBook = { book: 9, heading: "BOOK IX.", summary: "Summary line.", paragraphs: ["First paragraph.", "Second paragraph.", "Here the Cyclops appears.", "The end."], }; let realFetch: typeof fetch; function serve(mounted: boolean) { globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); if (mounted && url.endsWith("/content/library.json")) return Response.json(library); if (mounted && url.endsWith("/content/files/cyclops/01.lrc")) return new Response("[00:01.00]Line A\n[00:05.00]Line B\n[00:09.00]Line C\n"); if (mounted && url.endsWith("/content/homer/odyssey/9.json")) return Response.json(book); return new Response("Not found", { status: 404 }); }) as typeof fetch; } const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); }; let destroy: (() => void) | null = null; afterEach(() => { destroy?.(); destroy = null; globalThis.fetch = realFetch; }); beforeAll(() => { realFetch = globalThis.fetch; }); async function mount(mounted: boolean, state: Partial = {}) { serve(mounted); const content = await import("../src/app/content"); await content.loadLibrary(); const { mountPanel } = await import("../src/app/panel/panel"); document.body.innerHTML = `
`; const root = document.getElementById("panel")!; const store = createStore({ ...DEFAULT_STATE, songId: "polyphemus", ...state }, { history: false }); const panel = mountPanel(root, store, { timelineHidden: () => false }); destroy = panel.destroy; await flush(); return { root, store, panel }; } test("About has no Watch and no listen button", async () => { const { root } = await mount(true, { tab: "about" }); expect(root.querySelector(".about")).not.toBeNull(); expect(root.querySelector(".watch")).toBeNull(); expect(root.querySelector(".golisten")).toBeNull(); expect(root.querySelector(".panel__title")!.textContent).toBe("Polyphemus"); }); test("Listen & lyrics has the player, the synced lyrics and Watch", async () => { const { root } = await mount(true, { tab: "listen" }); expect(root.querySelector(".player")).not.toBeNull(); expect(root.querySelector(".lyrics")).not.toBeNull(); expect(root.querySelector(".watch .video-card")).not.toBeNull(); expect(root.querySelectorAll(".lyrics button.ly")).toHaveLength(3); expect(root.querySelector("audio")).not.toBeNull(); }); test("clicking a lyric line seeks the audio there", async () => { const { root } = await mount(true, { tab: "listen" }); const audio = root.querySelector("audio")!; (root.querySelectorAll(".lyrics button.ly")[1]!).click(); expect(audio.currentTime).toBe(5); }); test("Paths shows the motif strip only while the timeline is closed", async () => { const { root, store } = await mount(true, { tab: "paths" }); expect(root.querySelector(".paths")).not.toBeNull(); expect(root.querySelector(".motif-strip")).toBeNull(); store.set({ timeline: "closed" }); expect(root.querySelector(".motif-strip")).not.toBeNull(); store.set({ timeline: "open" }); expect(root.querySelector(".motif-strip")).toBeNull(); }); test("Paths has a row per motif and the chart of all nine places", async () => { const { root } = await mount(true, { tab: "paths" }); expect(root.querySelectorAll(".mrow").length).toBeGreaterThan(0); const places = [...root.querySelectorAll(".c-place")].map((t) => t.textContent); expect(places).toHaveLength(9); expect(places[0]).toBe("Troy"); expect(places[8]).toBe("Ithaca"); }); test("Fan works shows the empty state", async () => { const { root } = await mount(true, { tab: "fan" }); expect(root.querySelector(".fan")!.textContent).toContain( "No fan works collected yet. Real ones will be credited and linked to their creators.", ); }); test("without the content folder, Listen says so and has no audio", async () => { const { root } = await mount(false, { tab: "listen" }); expect(root.querySelector(".listen")!.textContent).toContain("Content folder not mounted"); expect(root.querySelector("audio")).toBeNull(); expect(root.querySelector(".watch")).not.toBeNull(); }); test("the header play button switches to Listen & lyrics", async () => { const { root, store } = await mount(true, { tab: "about" }); root.querySelector(".pplay")!.click(); expect(store.get().tab).toBe("listen"); expect(root.querySelector(".listen")).not.toBeNull(); }); test("the reading view opens at the passage with the paragraphs before folded, and Back closes it", async () => { const { root, store } = await mount(true, { tab: "about", read: true }); const reading = root.querySelector(".reading")!; expect(reading.textContent).toContain("Show the 2 paragraphs before"); expect(reading.textContent).not.toContain("Roman"); expect(reading.querySelectorAll(".reading__p")).toHaveLength(2); root.querySelector(".reading__back")!.click(); expect(store.get().read).toBe(false); expect(root.querySelector(".reading")).toBeNull(); }); test("changing the song re-renders the header", async () => { const { root, store } = await mount(true); store.set({ songId: "would-you-fall-in-love-with-me-again" }); expect(root.querySelector(".panel__title")!.textContent).toBe("Would You Fall in Love with Me Again"); }); test("a long title can wrap: no nowrap on .panel__title", () => { const css = readFileSync(new URL("../src/app/panel/panel.css", import.meta.url), "utf8"); const rule = /\.panel__title\s*\{[^}]*\}/.exec(css); expect(rule).not.toBeNull(); expect(rule![0]).not.toContain("nowrap"); });