// Vitest coverage for the F8-09 turn-report icon-popup section menu. // Smokes the sticky trigger, its active-title label, the open/close // state machine, popover items + active highlight, scroll-on-pick, // and the Escape + outside-click dismissal paths. The // IntersectionObserver-driven `activeSlug` is computed by the // orchestrator (`report.svelte`) and passed in via prop; the TOC // itself owns no observers. import "@testing-library/jest-dom/vitest"; import { fireEvent, render } from "@testing-library/svelte"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; import type { TranslationKey } from "../src/lib/i18n/index.svelte"; import ReportToc, { type TocEntry, } from "../src/lib/active-view/report/report-toc.svelte"; const ENTRIES: readonly TocEntry[] = [ { slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" }, { slug: "votes", titleKey: "game.report.section.votes.title" }, { slug: "bombings", titleKey: "game.report.section.bombings.title" }, ]; function stubReducedMotion(reduce: boolean): void { Object.defineProperty(window, "matchMedia", { writable: true, value: (query: string) => ({ matches: reduce && query.includes("reduce"), media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }); } beforeEach(() => { i18n.resetForTests("en"); }); describe("report TOC (icon-popup)", () => { test("renders the trigger with the active section title; popover closed by default", () => { const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); const trigger = ui.getByTestId("report-toc-trigger"); expect(trigger).toHaveAttribute("aria-haspopup", "menu"); expect(trigger).toHaveAttribute("aria-expanded", "false"); expect(trigger).toHaveTextContent("galaxy summary"); expect(ui.queryByTestId("report-toc-surface")).toBeNull(); }); test("clicking the trigger opens the popover with one menuitem per entry", async () => { const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); await fireEvent.click(ui.getByTestId("report-toc-trigger")); expect(ui.getByTestId("report-toc-trigger")).toHaveAttribute( "aria-expanded", "true", ); const surface = ui.getByTestId("report-toc-surface"); expect(surface).toHaveAttribute("role", "menu"); for (const entry of ENTRIES) { const item = ui.getByTestId(`report-toc-item-${entry.slug}`); expect(item).toHaveAttribute("role", "menuitem"); } }); test("marks the active item with aria-current=location and .active", async () => { const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "bombings" }, }); await fireEvent.click(ui.getByTestId("report-toc-trigger")); const active = ui.getByTestId("report-toc-item-bombings"); expect(active).toHaveAttribute("aria-current", "location"); expect(active).toHaveClass("active"); const inactive = ui.getByTestId("report-toc-item-votes"); expect(inactive).not.toHaveAttribute("aria-current"); expect(inactive).not.toHaveClass("active"); }); test("trigger label tracks activeSlug across re-renders", async () => { const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); expect(ui.getByTestId("report-toc-trigger")).toHaveTextContent( "galaxy summary", ); await ui.rerender({ entries: ENTRIES, activeSlug: "votes" }); expect(ui.getByTestId("report-toc-trigger")).toHaveTextContent("votes"); }); test("clicking a menuitem scrolls the target into view and closes the popover", async () => { // jsdom does not implement `scrollIntoView`; stub it on the // section the spec is about to pick. `matchMedia` is forced to // `reduce` so the assertion stays stable. const scrollSpy = vi.fn(); const target = document.createElement("section"); target.id = "report-bombings"; target.scrollIntoView = scrollSpy; document.body.appendChild(target); stubReducedMotion(true); const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); await fireEvent.click(ui.getByTestId("report-toc-trigger")); await fireEvent.click(ui.getByTestId("report-toc-item-bombings")); // The component defers `scrollIntoView` one frame so the // surface unmount + focus restoration run first. Flush the // frame before asserting. await new Promise((resolve) => requestAnimationFrame(() => resolve()), ); expect(scrollSpy).toHaveBeenCalledWith({ behavior: "auto", block: "start", }); expect(ui.queryByTestId("report-toc-surface")).toBeNull(); expect(ui.getByTestId("report-toc-trigger")).toHaveAttribute( "aria-expanded", "false", ); target.remove(); }); test("Escape closes the popover", async () => { const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); await fireEvent.click(ui.getByTestId("report-toc-trigger")); expect(ui.getByTestId("report-toc-surface")).toBeInTheDocument(); await fireEvent.keyDown(document, { key: "Escape" }); expect(ui.queryByTestId("report-toc-surface")).toBeNull(); }); test("outside click closes the popover", async () => { const outside = document.createElement("button"); document.body.appendChild(outside); const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); await fireEvent.click(ui.getByTestId("report-toc-trigger")); expect(ui.getByTestId("report-toc-surface")).toBeInTheDocument(); await fireEvent.click(outside); expect(ui.queryByTestId("report-toc-surface")).toBeNull(); outside.remove(); }); // Soft type-level check: a stray `as unknown as ...` cast on // `TocEntry` would silently widen the prop shape — assert the // runtime values look as expected. test("TocEntry exposes a slug and a TranslationKey", () => { const slug: string = ENTRIES[0]!.slug; const key: TranslationKey = ENTRIES[0]!.titleKey; expect(typeof slug).toBe("string"); expect(typeof key).toBe("string"); }); });