186 lines
8.6 KiB
TypeScript
186 lines
8.6 KiB
TypeScript
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<AppState> = {}) => {
|
|
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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<SVGElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>("[data-action=more]")!;
|
|
expect(more.textContent).toContain("+2 more");
|
|
more.click();
|
|
expect(root.querySelectorAll(".tl-thread")).toHaveLength(5);
|
|
const fewer = root.querySelector<HTMLButtonElement>("[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);
|
|
});
|
|
});
|