import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { sagas, songs, songsInSaga } from "../src/data/album"; import { createStore, DEFAULT_STATE, type AppState } from "../src/app/state"; import { layoutTimeline, threadMotifs } from "../src/app/timeline/layout"; import { contrast, inkOnParchment, parchment, rgbOf } from "../src/app/timeline/ink"; import { motifs } from "../src/data/album"; const measure = (t: string) => t.length * 7.2; /** The widest line of a (possibly two-line) label. */ const labelW = (label: string) => Math.max(...label.split("\n").map(measure)); describe("layoutTimeline", () => { const l = layoutTimeline("the-horse-and-the-infant", 980, measure); test("has 40 stops in album order with strictly increasing x", () => { expect(l.stops).toHaveLength(40); expect(l.stops.map((s) => s.songId)).toEqual(songs.map((s) => s.id)); for (let i = 1; i < l.stops.length; i++) expect(l.stops[i]!.x).toBeGreaterThan(l.stops[i - 1]!.x); expect(l.stops[0]!.x).toBeGreaterThan(0); expect(l.stops.at(-1)!.x).toBeLessThan(l.width); }); test("labels only the current saga's stops, and marks the current song", () => { for (const s of l.stops) { const inTroy = songs.find((x) => x.id === s.songId)!.sagaId === "troy"; expect(s.label !== null).toBe(inTroy); } expect(l.stops.filter((s) => s.current).map((s) => s.songId)).toEqual(["the-horse-and-the-infant"]); expect(l.sagas.filter((g) => g.expanded).map((g) => g.sagaId)).toEqual(["troy"]); }); test("sagas are 9 ordered, non-overlapping spans that contain their stops", () => { expect(l.sagas.map((g) => g.sagaId)).toEqual(sagas.map((g) => g.id)); for (let i = 1; i < l.sagas.length; i++) expect(l.sagas[i]!.x0).toBeGreaterThanOrEqual(l.sagas[i - 1]!.x1); for (const g of l.sagas) { for (const song of songsInSaga(g.sagaId)) { const x = l.stops.find((s) => s.songId === song.id)!.x; expect(x).toBeGreaterThan(g.x0); expect(x).toBeLessThan(g.x1); } } }); test("uses at least the given width, and spreads out when there is room", () => { expect(l.width).toBeGreaterThanOrEqual(980); const wide = layoutTimeline("the-horse-and-the-infant", 3000, measure); expect(wide.width).toBe(3000); expect(wide.stops.at(-1)!.x).toBeGreaterThan(2900); }); test("compact sagas space their stops 18px apart", () => { const cyc = songsInSaga("cyclops").map((s) => l.stops.find((x) => x.songId === s.id)!.x); for (let i = 1; i < cyc.length; i++) expect(cyc[i]! - cyc[i - 1]!).toBeCloseTo(18, 5); }); // Review focus 5: long titles never overlap in the expanded saga. for (const saga of sagas) { test(`no two labelled stops overlap with ${saga.name} expanded`, () => { const first = songsInSaga(saga.id)[0]!; const lay = layoutTimeline(first.id, 980, measure); const labelled = lay.stops.filter((s) => s.label !== null); expect(labelled.length).toBe(songsInSaga(saga.id).length); for (const s of labelled) expect(s.label!.split("\n").length).toBeLessThanOrEqual(2); for (let i = 1; i < labelled.length; i++) { const a = labelled[i - 1]!, b = labelled[i]!; expect(b.x - a.x).toBeGreaterThanOrEqual(64); expect(b.x - a.x).toBeGreaterThanOrEqual((labelW(a.label!) + labelW(b.label!)) / 2 + 16 - 1e-6); } // labels stay inside their saga's span const g = lay.sagas.find((x) => x.expanded)!; expect(labelled[0]!.x - labelW(labelled[0]!.label!) / 2).toBeGreaterThanOrEqual(g.x0 - 1e-6); expect(labelled.at(-1)!.x + labelW(labelled.at(-1)!.label!) / 2).toBeLessThanOrEqual(g.x1 + 1e-6); }); } test("the longest title is split onto two lines", () => { const lay = layoutTimeline("would-you-fall-in-love-with-me-again", 980, measure); const s = lay.stops.find((x) => x.songId === "would-you-fall-in-love-with-me-again")!; expect(s.label!.split("\n")).toHaveLength(2); expect(s.label!.replace("\n", " ")).toBe("Would You Fall in Love with Me Again"); }); }); describe("threadMotifs", () => { test("lists musical motifs before character themes", () => { const ms = threadMotifs("full-speed-ahead", null); const kinds = ms.map((m) => m.kind); expect(kinds.indexOf("character")).toBeGreaterThan(kinds.lastIndexOf("motif")); expect(ms.map((m) => m.id).sort()).toEqual(["eurylochus", "full-speed", "polites", "six-hundred"]); }); test("includes a picked motif the song does not use", () => { expect(threadMotifs("storm", "warrior").map((m) => m.id)).toContain("warrior"); }); }); test("every motif's ink reads at 4.5:1 on the timeline's parchment", () => { for (const m of motifs) expect(contrast(rgbOf(inkOnParchment(m.color)), parchment)).toBeGreaterThanOrEqual(4.5); }); describe("mountTimeline", () => { beforeAll(() => GlobalRegistrator.register()); afterAll(() => GlobalRegistrator.unregister()); const setup = async (patch: Partial = {}) => { const { mountTimeline } = await import("../src/app/timeline/timeline"); const root = document.createElement("nav"); document.body.append(root); const store = createStore({ ...DEFAULT_STATE, ...patch }, { history: false }); const tl = mountTimeline(root, store); return { root, store, tl }; }; test("renders 40 stops, a controls slot and the current song's threads", async () => { const { root } = await setup({ songId: "polyphemus" }); expect(root.querySelectorAll("[data-song]")).toHaveLength(40); expect(root.querySelector(".timeline__controls")).not.toBeNull(); expect(root.querySelectorAll(".tl-thread")).toHaveLength(1); expect(root.querySelector('[data-song="polyphemus"]')!.getAttribute("aria-current")).toBe("step"); }); test("the hide control collapses the timeline, and shows it again", async () => { const { root, store } = await setup(); const btn = root.querySelector("[data-action=toggle-timeline]")!; expect(btn.getAttribute("aria-expanded")).toBe("true"); btn.click(); expect(store.get().timeline).toBe("closed"); expect(root.dataset.state).toBe("closed"); expect(root.querySelectorAll(".tl-thread")).toHaveLength(0); root.querySelector("[data-action=toggle-timeline]")!.click(); expect(store.get().timeline).toBe("open"); }); test("the threads-on-the-sea switch writes state.ribbons", async () => { const { root, store } = await setup(); const sw = root.querySelector("[role=switch]")!; expect(sw.getAttribute("aria-checked")).toBe("false"); sw.click(); expect(store.get().ribbons).toBe(true); expect(root.querySelector("[role=switch]")!.getAttribute("aria-checked")).toBe("true"); }); test("clicking a stop selects that song", async () => { const { root, store } = await setup(); root.querySelector('[data-song="storm"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(store.get().songId).toBe("storm"); expect(root.querySelector('[data-song="storm"]')!.getAttribute("aria-current")).toBe("step"); }); test("clicking a motif name toggles state.motif", async () => { const { root, store } = await setup({ songId: "polyphemus" }); const pick = () => root.querySelector('[data-pick="polyphemus"]')!; pick().click(); expect(store.get().motif).toBe("polyphemus"); expect(pick().getAttribute("aria-pressed")).toBe("true"); pick().click(); expect(store.get().motif).toBeNull(); }); test("shows at most 4 thread rows, with a +N more control", async () => { const { root } = await setup({ songId: "god-games" }); // 5 motifs expect(root.querySelectorAll(".tl-thread")).toHaveLength(3); const more = root.querySelector("[data-action=more]")!; expect(more.textContent).toContain("+2 more"); more.click(); expect(root.querySelectorAll(".tl-thread")).toHaveLength(5); const fewer = root.querySelector("[data-action=more]")!; expect(fewer.textContent).toContain("Show fewer"); fewer.click(); expect(root.querySelectorAll(".tl-thread")).toHaveLength(3); }); test("a picked motif stays visible even past the row limit", async () => { const { root } = await setup({ songId: "god-games", motif: "polyphemus" }); expect(root.querySelector('.tl-thread[data-m="polyphemus"]')).not.toBeNull(); }); test("refresh re-renders without changing state", async () => { const { root, store, tl } = await setup(); const before = store.get(); tl.refresh(); expect(store.get()).toBe(before); expect(root.querySelectorAll("[data-song]")).toHaveLength(40); }); });