cfbe052242
- Replace the 14 rem sticky sidebar (and its mobile <select> twin)
with a single sticky icon-popup trigger pinned to the top-right
corner of the report column. Trigger shows `≡` followed by the
currently active section title (CSS-clamped with text-overflow:
ellipsis so long RU titles cannot bloat the button). Click opens
an anchored popover on desktop and a fixed bottom-sheet on
<768.98 px (mirrors lib/active-view/map-toggles.svelte).
- Each menuitem closes the popover and scrolls the matching
`<section id="report-<slug>">` into view. The scroll is deferred
one animation frame so the surface unmount + restoreFocus's
focus restoration on the (sticky) trigger commit first; otherwise
the focus call could cancel the just-started smooth/instant
scroll under desktop Chromium and WebKit.
- Drop the in-report "Back to map" button — the same affordance
lives in the app-shell view menu (tests/e2e/game-shell.spec.ts
covers it).
- Tighten the report grid to a single flex column so the section
body now occupies the full container width.
- i18n: remove game.report.back_to_map and
game.report.toc.mobile_label; add game.report.toc.open and
game.report.toc.close (mirrors game.map.toggles.open/close).
- Tests: Vitest report-toc.test.ts rewritten for the new icon-popup
contract; Playwright report-sections.spec.ts switches the anchor
loop to trigger → menuitem and adds a mobile bottom-sheet
assertion; game-shell-stubs.test.ts no longer asserts the
back-to-map button on the report orchestrator.
- Docs: ui/docs/report-view.md (TOC + i18n + test seams) and
docs/FUNCTIONAL{,_ru}.md §6.4 updated. The stale SvelteKit
Snapshot reference (the route file was removed by the single-URL
app-shell) is dropped at the same time.
Refs: #52 (#43 umbrella).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
// 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<void>((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");
|
|
});
|
|
});
|