From 3a9ea9e330c02a44883353bedd2558a327a4e1f1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 21:56:24 +0200 Subject: [PATCH] test: the side panel's focus step is held in its commit; the MenuCheck contract counts chosen roles --- .../src/components/ui/SidePanel.drawer.dom.test.tsx | 10 +++++++--- .../src/components/ui/menuCheck.contract.test.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ui/SidePanel.drawer.dom.test.tsx b/frontend/src/components/ui/SidePanel.drawer.dom.test.tsx index ac7dcf05..673efe03 100644 --- a/frontend/src/components/ui/SidePanel.drawer.dom.test.tsx +++ b/frontend/src/components/ui/SidePanel.drawer.dom.test.tsx @@ -6,7 +6,7 @@ // hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel // asks from three switches, and can flip them live (a tablet turning). import { describe, it, expect, afterEach, vi } from "vitest"; -import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -106,7 +106,7 @@ describe("SidePanel · the tab under an open card", () => { expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0); }); - it("hands a key's focus on the tab to the card it peeks open, not to the hidden tab", async () => { + it("hands a key's focus on the tab to the card it peeks open, not to the hidden tab", () => { stubMedia({ coarse: false, narrow: false, wide: true }); globalThis.ResizeObserver ??= class { observe() {} @@ -115,10 +115,14 @@ describe("SidePanel · the tab under an open card", () => { } as unknown as typeof ResizeObserver; mount(true); const tab = screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }); + // A page the browser is not painting runs no animation frame: the step must not wait for one. + const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); act(() => tab.focus()); // jsdom calls every focus :focus-visible — a key's focus, here - await waitFor(() => expect(document.activeElement?.closest("aside")).not.toBeNull()); + // in the commit that opens the card, not a frame later + expect(document.activeElement?.closest("aside")).not.toBeNull(); expect(document.activeElement?.getAttribute("aria-label")).toBe("sidebar.pinNamed:Filters"); expect(document.activeElement).not.toBe(tab); + raf.mockRestore(); }); }); diff --git a/frontend/src/components/ui/menuCheck.contract.test.ts b/frontend/src/components/ui/menuCheck.contract.test.ts index c87e8cce..d2da1309 100644 --- a/frontend/src/components/ui/menuCheck.contract.test.ts +++ b/frontend/src/components/ui/menuCheck.contract.test.ts @@ -7,6 +7,17 @@ const sources = () => .filter((f) => f.endsWith(".tsx") && !f.includes(".test.")) .map((f) => ({ f, src: readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8") })); +/** Checkable menu rows a file renders: a literal `role="menuitemradio"`, and a role CHOSEN by an + * expression that can come out checkable — FilterSelect's and ActionMenu's rows, every filter select + * and checkable action menu in the app, which a literal-only count skipped (round 3). */ +function checkableRoles(src: string): number { + const literal = (src.match(/role="menuitem(?:radio|checkbox)"/g) ?? []).length; + const chosen = [...src.matchAll(/role=\{([^{}]*)\}/g)].filter(([, e]) => + /"menuitem(?:radio|checkbox)"/.test(e!), + ).length; + return literal + chosen; +} + // E28 review L2: `ui/MenuCheck` is the ONE drawn check box of a checkable row (S3e3b replaced seven // hand-drawn copies), yet FilterSelect and Combobox still painted their own — a 14px box beside the // menus' 16px one. The filled-box paint is the tell: it may live in MenuCheck only. @@ -25,7 +36,7 @@ describe("the drawn check box", () => { it("marks every checkable menu row, with no tick of its own beside it", () => { const offenders = sources() .filter(({ src }) => { - const checkable = (src.match(/role="menuitem(?:radio|checkbox)"/g) ?? []).length; + const checkable = checkableRoles(src); const drawn = (src.match(/ drawn || (checkable > 0 && /