feat(app): content client and side panel
About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip when the timeline is hidden, motif rows, nine-place chart), Fan works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
38c4614726
commit
2fbf3a6980
13 files changed
+1734
-1
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { loadBook, loadLibrary, loadLyrics, passageStart, realFor } from "../src/app/content";
|
||||
import { songById } from "../src/data/album";
|
||||
import type { HomerBook } from "../src/content/homer";
|
||||
|
||||
// Placeholder text only: never real lyrics in tests.
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => { globalThis.fetch = realFetch; });
|
||||
|
||||
function mockFetch(routes: Record<string, () => Response>) {
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
seen.push(url);
|
||||
const hit = routes[url];
|
||||
return hit ? hit() : new Response("Not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
return seen;
|
||||
}
|
||||
|
||||
const library = {
|
||||
sagas: [
|
||||
{
|
||||
name: "The Circe Saga", year: 2024, cover: "/content/files/circe/folder.jpg",
|
||||
songs: [
|
||||
{ track: 2, title: "Wouldn't You Like", audio: "/content/files/circe/02.flac", lyrics: { url: "/content/files/circe/02.lrc", timed: true } },
|
||||
{ track: 3, title: "Done For", audio: "/content/files/circe/03.flac", lyrics: { url: "/content/files/circe/03.txt", timed: false } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("loadLibrary is false when /content 404s", async () => {
|
||||
mockFetch({});
|
||||
expect(await loadLibrary()).toBe(false);
|
||||
expect(realFor(songById("polyphemus")!)).toBeNull();
|
||||
});
|
||||
|
||||
test("loadLibrary is false when the request times out", async () => {
|
||||
globalThis.fetch = ((_: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise((_res, rej) => init?.signal?.addEventListener("abort", () => rej(new Error("aborted"))))) as typeof fetch;
|
||||
expect(await loadLibrary(20)).toBe(false);
|
||||
});
|
||||
|
||||
test("realFor matches by normalised title, including a curly apostrophe", async () => {
|
||||
mockFetch({ "/content/library.json": () => Response.json(library) });
|
||||
expect(await loadLibrary()).toBe(true);
|
||||
const song = songById("wouldnt-you-like")!;
|
||||
expect(song.title).toBe("Wouldn’t You Like");
|
||||
expect(realFor(song)).toEqual({
|
||||
audio: "/content/files/circe/02.flac",
|
||||
cover: "/content/files/circe/folder.jpg",
|
||||
lyrics: { url: "/content/files/circe/02.lrc", timed: true },
|
||||
});
|
||||
expect(realFor(songById("polyphemus")!)).toBeNull();
|
||||
});
|
||||
|
||||
test("loadLyrics parses .lrc as timed lines and .txt as plain lines", async () => {
|
||||
mockFetch({
|
||||
"/content/files/x/a.lrc": () => new Response("[ar:Someone]\n[00:02.00]Line B\n[00:01.00]Line A\n"),
|
||||
"/content/files/x/a.txt": () => new Response("Line A\r\n\r\nLine B\n"),
|
||||
});
|
||||
expect(await loadLyrics({ url: "/content/files/x/a.lrc", timed: true })).toEqual({
|
||||
timed: true, lines: [{ time: 1, text: "Line A" }, { time: 2, text: "Line B" }],
|
||||
});
|
||||
expect(await loadLyrics({ url: "/content/files/x/a.txt", timed: false })).toEqual({ timed: false, lines: ["Line A", "", "Line B"] });
|
||||
expect(await loadLyrics({ url: "/content/files/x/missing.lrc", timed: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("loadBook fetches one book, null when missing", async () => {
|
||||
const book: HomerBook = { book: 11, heading: "BOOK XI", summary: "S", paragraphs: ["p"] };
|
||||
const seen = mockFetch({ "/content/homer/odyssey/11.json": () => Response.json(book) });
|
||||
expect(await loadBook(11)).toEqual(book);
|
||||
expect(await loadBook(30)).toBeNull();
|
||||
expect(seen).toContain("/content/homer/odyssey/11.json");
|
||||
});
|
||||
|
||||
test("passageStart finds the first paragraph with a keyword, else 0", () => {
|
||||
const book: HomerBook = {
|
||||
book: 9, heading: "BOOK IX", summary: "",
|
||||
paragraphs: ["Nothing here.", "Still nothing, cyclopean is not a word match.", "Then the Cyclops spoke.", "The cave again."],
|
||||
};
|
||||
expect(passageStart(book, ["cave", "Cyclops"])).toEqual({ index: 2, word: "Cyclops" });
|
||||
expect(passageStart(book, ["Noman"])).toEqual({ index: 0, word: null });
|
||||
expect(passageStart(book, [])).toEqual({ index: 0, word: null });
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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<AppState> = {}) {
|
||||
serve(mounted);
|
||||
const content = await import("../src/app/content");
|
||||
await content.loadLibrary();
|
||||
const { mountPanel } = await import("../src/app/panel/panel");
|
||||
document.body.innerHTML = `<main class="shell" data-panel="open"><aside id="panel" class="panel"></aside></main>`;
|
||||
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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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");
|
||||
});
|
||||
Reference in new issue
Block a user