diff --git a/admin-ui/__specs__/__snapshots__/grouped-navigation.spec.tsx.snap b/admin-ui/__specs__/__snapshots__/grouped-navigation.spec.tsx.snap new file mode 100644 index 00000000..07694ca4 --- /dev/null +++ b/admin-ui/__specs__/__snapshots__/grouped-navigation.spec.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Media") at /catalog 1`] = ` +{ + "Media": true, + "Station": false, + "Status": false, + "Tools": false, +} +`; + +exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Station") at /ads 1`] = ` +{ + "Media": false, + "Station": true, + "Status": false, + "Tools": false, +} +`; + +exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Status") at /health 1`] = ` +{ + "Media": false, + "Station": false, + "Status": true, + "Tools": false, +} +`; + +exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Tools") at /gardener 1`] = ` +{ + "Media": false, + "Station": false, + "Status": false, + "Tools": true, +} +`; diff --git a/admin-ui/__specs__/about-page.spec.tsx b/admin-ui/__specs__/about-page.spec.tsx index c4237aa2..b7540c60 100644 --- a/admin-ui/__specs__/about-page.spec.tsx +++ b/admin-ui/__specs__/about-page.spec.tsx @@ -3,22 +3,185 @@ // // BDD specification — Jest. RED at plan time: every specification is it.todo — turn it into an `it` only in the // task that makes it green (T561). Each Given comment names the arrange the scenario needs. +// +// next/jest's SWC transform (unlike babel-jest) does not hoist jest.mock() calls above import +// statements (mirrors app-shell.spec.tsx's own header comment), so Sidebar — which calls the +// mocked next/navigation hook — is loaded via a dynamic `await import()` inside the test. + +jest.mock("next/navigation", () => ({ + usePathname: jest.fn(), +})); + +jest.mock("@/app/login/actions", () => ({ + logout: jest.fn(), +})); + +import { describe, it, jest, expect } from "@jest/globals"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/jest-globals"; +import type { usePathname } from "next/navigation"; +import { AboutView } from "../app/(authed)/about/AboutView"; +import type { AboutResponseDto } from "../lib/about-api"; + +const mockedUsePathname = jest + .requireMock<{ usePathname: typeof usePathname }>("next/navigation") + .usePathname as jest.MockedFunction; -import { describe, it } from "@jest/globals"; +// Given: a single fixture GET /api/about response — two attribution kinds, two packs, so AC4's +// "shows every attribution name" fact can't pass vacuously on a one-item list. +const ABOUT_FIXTURE: AboutResponseDto = { + version: "5.11.0+abc123", + stationName: "GWAV 108.8", + tagline: "Your late-night companion", + libraryCount: 4821, + uptimeSeconds: 90_000, // 1 day, 1 hour + attributions: { + groups: [ + { + kind: "jingle-pack", + packs: [ + { + slug: "gw-first-beds", + name: "GW First Beds", + attributions: [ + { + title: "Night Drive", + creator: "Jane Doe", + sourceUrl: "https://example.com/night-drive", + license: "CC-BY", + }, + ], + }, + ], + }, + { + kind: "font-pack", + packs: [ + { + slug: "about-font-pack", + name: "About Grotesk", + attributions: [ + { + title: "Grotesk Typeface", + creator: null, + sourceUrl: "https://fonts.example.com/about-grotesk", + license: "OFL-1.1", + }, + ], + }, + ], + }, + ], + }, +}; describe("Feature: About page", () => { describe("Scenario: the page with a response", () => { // Given: /about rendered with version, stationName, libraryCount, uptimeSeconds, attributions - it.todo("AC4 — shows the version"); - it.todo("AC4 — shows the station name"); - it.todo("AC4 — shows the library count"); - it.todo("AC4 — shows the uptime"); - it.todo("AC4 — shows every attribution name"); + it("AC4 — shows the version", () => { + render(); + expect(screen.getByText(/5\.11\.0\+abc123/)).toBeInTheDocument(); + }); + + it("AC4 — shows the station name", () => { + render(); + expect(screen.getByText("GWAV 108.8")).toBeInTheDocument(); + }); + + it("AC4 — shows the library count", () => { + render(); + expect(screen.getByText(/4821/)).toBeInTheDocument(); + }); + + it("AC4 — shows the uptime", () => { + render(); + expect(screen.getByText(/1 day, 1 hour/)).toBeInTheDocument(); + }); + + // One assertion: the rendered pack-name elements' own text, collected in document order, + // against the expected list — catches a missing OR an extra pack, not just "Night Drive is + // somewhere" (that string is also an attribution-line title, so a getByText check on it + // wasn't even proving the PACK name rendered at all). + it("AC4 — shows every attribution name", () => { + render(); + expect(screen.getAllByTestId("attribution-pack-name").map((el) => el.textContent)).toEqual([ + "GW First Beds", + "About Grotesk", + ]); + }); + }); + + describe("Scenario: the tagline", () => { + // Given: Station:Tagline is set (Dean's ruling 2026-09-23) + it("renders the tagline when set", () => { + render(); + expect(screen.getByText("Your late-night companion")).toBeInTheDocument(); + }); + + // Given: Station:Tagline is unset ("" on the wire) + it("is absent when the tagline is blank", () => { + render(); + expect(screen.queryByText("Your late-night companion")).not.toBeInTheDocument(); + }); + + // Given: Station:Tagline is whitespace-only (a settings-form edge case, not merely "") + it("is absent when the tagline is whitespace-only", () => { + render(); + expect(screen.queryByTestId("station-tagline")).not.toBeInTheDocument(); + }); + }); + + describe("Scenario: attribution source links", () => { + // Given: an attribution line whose sourceUrl is http(s) — a safe link target + it("links the credit when sourceUrl is http(s)", () => { + render(); + expect(screen.getByRole("link", { name: "Night Drive" })).toHaveAttribute( + "href", + "https://example.com/night-drive", + ); + }); + + // Given: an attribution line whose sourceUrl is NOT http(s) — untrusted pack-manifest data, + // never pointed at from an href (security-web) + it("renders no link when sourceUrl is not http(s)", () => { + const unsafeSourceFixture: AboutResponseDto = { + ...ABOUT_FIXTURE, + attributions: { + groups: [ + { + kind: "jingle-pack", + packs: [ + { + slug: "gw-first-beds", + name: "GW First Beds", + attributions: [ + { title: "Night Drive", creator: "Jane Doe", sourceUrl: "javascript:alert(1)", license: "CC-BY" }, + ], + }, + ], + }, + ], + }, + }; + + render(); + + expect(screen.queryByRole("link", { name: "Night Drive" })).not.toBeInTheDocument(); + }); }); describe("Scenario: the sidebar footer", () => { // Given: Sidebar rendered - it.todo("AC5 — About sits in the footer beside Sign out"); + it("AC5 — About sits in the footer beside Sign out", async () => { + mockedUsePathname.mockReturnValue("/dashboard"); + const { Sidebar } = await import("../app/(authed)/_components/Sidebar"); + + render(); + + const about = screen.getByRole("link", { name: "About" }); + const signOut = screen.getByRole("button", { name: /sign out/i }); + expect(about.closest("footer")).toContainElement(signOut); + }); }); }); diff --git a/admin-ui/__specs__/app-shell.spec.tsx b/admin-ui/__specs__/app-shell.spec.tsx index 006d1a17..d05dc4a8 100644 --- a/admin-ui/__specs__/app-shell.spec.tsx +++ b/admin-ui/__specs__/app-shell.spec.tsx @@ -136,6 +136,10 @@ function collectFiles(dir: string, exts: string[], out: string[] = []): string[] beforeEach(() => { jest.clearAllMocks(); document.documentElement.removeAttribute("data-theme"); + // jsdom's `window` (and its `localStorage`) is shared by every `it` in this FILE — a nav-group + // toggle written by one test (SPEC F203.2's remembered-toggle storage) must not leak into the + // next one's Sidebar/MobileNav mount. + window.localStorage.clear(); }); afterEach(() => { @@ -148,13 +152,18 @@ afterEach(() => { describe("Feature: App shell", () => { describe("Scenario: shell wraps every authed route", () => { - it("renders the sidebar with Dashboard, Live, Catalog, Station Imaging, Settings (and NOT Libraries)", async () => { + it("renders the sidebar with Dashboard, Catalog, Station sounds, Settings (and NOT Libraries)", async () => { mockedUsePathname.mockReturnValue("/dashboard"); const { Sidebar } = await import("../app/(authed)/_components/Sidebar"); render(); + // Catalog (Media) and Station sounds (Station) live inside collapsible groups (SPEC F203.2) + // that don't open by route rule at /dashboard — open them by hand so this stays a shell- + // composition check, not a group-collapse check. + fireEvent.click(screen.getByRole("button", { name: "Station" })); + fireEvent.click(screen.getByRole("button", { name: "Media" })); - for (const label of ["Dashboard", "Live", "Catalog", "Station Imaging", "Settings"]) { + for (const label of ["Dashboard", "Catalog", "Station sounds", "Settings"]) { expect(screen.getByRole("link", { name: label })).toBeInTheDocument(); } // Libraries stays routable but unlisted until Q7. @@ -185,7 +194,10 @@ describe("Feature: App shell", () => { // --------------------------------------------------------------------------- describe("Scenario: Persona Catalog nav entry is feature-gated (PLAN T102, SPEC F90.1)", () => { it("hides the Persona Catalog link by default (no live signal, fail-closed)", async () => { - mockedUsePathname.mockReturnValue("/dashboard"); + // /catalog is Media's own route, so the group opens by rule (SPEC F203.2) with no manual + // toggle — otherwise Media stays collapsed and this assertion would pass whether or not the + // gate exists (the link would be absent either way). + mockedUsePathname.mockReturnValue("/catalog"); const { Sidebar } = await import("../app/(authed)/_components/Sidebar"); render(); @@ -194,7 +206,9 @@ describe("Feature: App shell", () => { }); it("lists the Persona Catalog link once the authed layout resolves catalogEnabled=true", async () => { - mockedUsePathname.mockReturnValue("/dashboard"); + // Community Catalog lives in the Media group (SPEC F203.1) — /catalog is Media's own route, + // so the group opens by rule (SPEC F203.2) without a manual toggle. + mockedUsePathname.mockReturnValue("/catalog"); const { Sidebar } = await import("../app/(authed)/_components/Sidebar"); render(); @@ -206,7 +220,7 @@ describe("Feature: App shell", () => { }); it("mirrors the same gating in the sub-1024px drawer (MobileNav)", async () => { - mockedUsePathname.mockReturnValue("/dashboard"); + mockedUsePathname.mockReturnValue("/catalog"); const { MobileNav } = await import("../app/(authed)/_components/MobileNav"); render(); diff --git a/admin-ui/__specs__/bed-picker-help-flyover.spec.tsx b/admin-ui/__specs__/bed-picker-help-flyover.spec.tsx index c1b2ece7..a36cca00 100644 --- a/admin-ui/__specs__/bed-picker-help-flyover.spec.tsx +++ b/admin-ui/__specs__/bed-picker-help-flyover.spec.tsx @@ -1,5 +1,5 @@ // @jest-environment jsdom -// gh-#431 — Station Imaging "Bed (optional)" gets a ? help flyover, matching the settings page's +// gh-#431 — Station sounds "Bed (optional)" gets a ? help flyover, matching the settings page's // SettingHelpFlyover pattern: the shared `HelpFlyover` (gh-#209 extraction), the exact idiom the // booth log's Mode column header already uses (LlmCallsFeed.tsx). // diff --git a/admin-ui/__specs__/booth-log-page.spec.tsx b/admin-ui/__specs__/booth-log-page.spec.tsx index 99320e39..02b2c3ce 100644 --- a/admin-ui/__specs__/booth-log-page.spec.tsx +++ b/admin-ui/__specs__/booth-log-page.spec.tsx @@ -366,7 +366,9 @@ describe("Feature: Booth log", () => { describe("Feature: Booth log sidebar entry", () => { it("lists Booth log in the persistent sidebar nav", async () => { - mockedUsePathname.mockReturnValue("/dashboard"); + // Booth log lives in the Status group (SPEC F203.1) — /booth-log is Status's own route, so the + // group opens by rule (SPEC F203.2) without a manual toggle. + mockedUsePathname.mockReturnValue("/booth-log"); const { Sidebar } = await import("../app/(authed)/_components/Sidebar"); render(); diff --git a/admin-ui/__specs__/dashboard-page.spec.tsx b/admin-ui/__specs__/dashboard-page.spec.tsx index b11c280c..e6f53103 100644 --- a/admin-ui/__specs__/dashboard-page.spec.tsx +++ b/admin-ui/__specs__/dashboard-page.spec.tsx @@ -232,7 +232,7 @@ describe("Feature: Dashboard", () => { render(); await flush(); - expect(screen.getByText(/Station Imaging rotation — drain state/)).toBeInTheDocument(); + expect(screen.getByText(/Station sounds rotation — drain state/)).toBeInTheDocument(); }); it("shows a warming-up state for 503", async () => { diff --git a/admin-ui/__specs__/font-pack-wardrobe.spec.tsx b/admin-ui/__specs__/font-pack-wardrobe.spec.tsx deleted file mode 100644 index 7f107aa6..00000000 --- a/admin-ui/__specs__/font-pack-wardrobe.spec.tsx +++ /dev/null @@ -1,145 +0,0 @@ -// @jest-environment jsdom -// STORY-284 — The wardrobe is inspectable (SPEC F104.7 · PLAN T203); AC2 is the 🖐️ T204 gate. -// Nav label/route renamed "Library" → "Wardrobe" at PLAN T204 (Dean's ruling) — this file was -// font-pack-library.spec.tsx before that rename; no behavior changed, only names. -// -// Runner: Jest. WardrobeClient renders GET /api/fonts's own listing — family (title), faces -// (style + byte size via the shared font-format.ts helper), the licence/version/subset line, and -// the "Installed · · " db/25 provenance chip (AC1). `timeZone="UTC"` is pinned -// explicitly (the StatusTiles/BoothLogFeed/PersonasClient/SettingsForm house idiom, T105/T187) so -// the date half of the provenance chip is deterministic regardless of the host's TZ. -// -// gh-#428: every non-empty render now also renders `UninstallPackButton` per card, which calls -// `useConfirm()`/`useRouter()` unconditionally — so every such render here needs `ConfirmDialogProvider` -// and a mocked `next/navigation`, mirroring `wardrobe-uninstall-pack.spec.tsx`'s own harness. This -// file's own Facts never click Uninstall (that's the dedicated spec file's concern) — the wrapper is -// here only so these read-only listing Facts keep rendering at all. - -jest.mock("next/navigation", () => ({ - ...jest.requireActual("next/navigation"), - useRouter: jest.fn(), -})); - -import { describe, it, expect, jest, beforeEach } from "@jest/globals"; -import { render, screen, within } from "@testing-library/react"; -import "@testing-library/jest-dom/jest-globals"; -import type { useRouter } from "next/navigation"; -import { ConfirmDialogProvider } from "@/components/ui/confirm-dialog"; -import type { FontLibraryPackDto } from "../app/(authed)/wardrobe/types"; -import type { WardrobeClient as WardrobeClientType } from "../app/(authed)/wardrobe/WardrobeClient"; - -const mockedUseRouter = jest - .requireMock<{ useRouter: typeof useRouter }>("next/navigation") - .useRouter as jest.MockedFunction; - -let WardrobeClient: typeof WardrobeClientType; - -beforeEach(async () => { - mockedUseRouter.mockReturnValue({ refresh: jest.fn() } as unknown as ReturnType); - // Dynamic import after the mock is in place — the directory's established convention - // (catalog-purge-unavailable.spec.tsx's renderAction does the same); WardrobeClient renders - // UninstallPackButton, which calls useRouter() unconditionally. - ({ WardrobeClient } = await import("../app/(authed)/wardrobe/WardrobeClient")); -}); - -const SPACE_GROTESK_PACK: FontLibraryPackDto = { - slug: "space-grotesk", - family: "Space Grotesk", - faces: [{ file: "space-grotesk-variable-latin.woff2", style: "normal", byteSize: 7844 }], - license: "OFL-1.1", - sourceUrl: "https://github.com/example/space-grotesk", - version: "1.0", - subset: "latin", - importedFrom: "space-grotesk", - importedAt: "2026-08-05T12:00:00Z", -}; - -describe("Feature: the wardrobe is inspectable", () => { - describe("Scenario: the wardrobe lists installed packs", () => { - it("shows family, faces, byte sizes, and licence per pack (T203, AC1)", () => { - render( - - - - ); - - const list = screen.getByRole("list", { name: "Installed font packs" }); - const card = within(list).getByText("Space Grotesk").closest("li"); - if (card === null) throw new Error("No
  • ancestor for the pack card"); - - expect(within(card).getByText("Space Grotesk")).toBeInTheDocument(); // family, as the title - expect(within(card).getByText("normal — 8 KiB")).toBeInTheDocument(); // style + formatFontByteTotal - expect(within(card).getByText("OFL-1.1 · v1.0 · latin")).toBeInTheDocument(); // licence line - }); - - it("shows 'Installed · · ' provenance per pack (T203, AC1)", () => { - render( - - - - ); - - expect(screen.getByText("Installed · space-grotesk · Aug 5, 2026")).toBeInTheDocument(); - }); - - // gh-#428 — the wardrobe stops being pure-read-only: each card gets its own uninstall - // affordance. The button's own confirm/DELETE/toast/refresh cycle is UninstallPackButton's own - // concern (wardrobe-uninstall-pack.spec.tsx); this Fact only proves WardrobeClient wires one in - // per card, named for that card's own pack. - it("renders an Uninstall button per pack, named for that pack", () => { - render( - - - - ); - - expect(screen.getByRole("button", { name: "Uninstall Space Grotesk" })).toBeInTheDocument(); - }); - }); - - describe("Scenario: the wardrobe is empty", () => { - // T203 review finding F3: the empty-state CTA must not point at /persona-catalog when the - // catalog is disabled — that route itself 404s off-catalog, the exact dead end the Wardrobe nav - // item's own deliberate ungating (SPEC F104.8) exists to let an operator avoid. - it("names the reason and offers the Community Catalog CTA when the catalog is enabled", () => { - render(); - - expect(screen.getByText("No packs installed")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Browse the Community Catalog" })).toHaveAttribute( - "href", - "/persona-catalog" - ); - }); - - it("points at Settings instead of the catalog when the catalog is disabled", () => { - render(); - - expect(screen.getByText("No packs installed")).toBeInTheDocument(); - expect( - screen.getByText( - "The Community Catalog is disabled — enable Community:CatalogIndexUrl in Settings to browse packs." - ) - ).toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Browse the Community Catalog" })).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open Settings" })).toHaveAttribute("href", "/settings"); - }); - }); - - describe("Scenario: a stored definition failed to re-parse", () => { - it("degrades the licence line to 'Licence unknown' instead of rendering blank", () => { - const packWithoutManifest: FontLibraryPackDto = { - ...SPACE_GROTESK_PACK, - license: null, - version: null, - subset: null, - }; - render( - - - - ); - - expect(screen.getByText("Licence unknown")).toBeInTheDocument(); - }); - }); -}); diff --git a/admin-ui/__specs__/gardener-nav.spec.tsx b/admin-ui/__specs__/gardener-nav.spec.tsx index 1314490f..d176711b 100644 --- a/admin-ui/__specs__/gardener-nav.spec.tsx +++ b/admin-ui/__specs__/gardener-nav.spec.tsx @@ -1,25 +1,23 @@ // @jest-environment jsdom -// SPEC F153.10, PLAN T378 — the Gardener nav entry. +// SPEC F153.10, PLAN T378 — the Gardener nav entry, now inside the grouped Tools group +// (SPEC F203.1, PLAN T558). // -// Runner: Jest. NAV_ITEMS/visibleNavItems are plain data (no rendering needed) — same posture the -// Sidebar itself reads them with. +// Runner: Jest. NAV_GROUPS is plain data (no rendering needed) — same posture the Sidebar itself +// reads it with. import { describe, it, expect } from "@jest/globals"; -import { visibleNavItems } from "../app/(authed)/_components/nav-items"; +import { NAV_GROUPS } from "../app/(authed)/_components/nav-items"; describe("Feature: the Gardener nav entry (SPEC F153.10)", () => { - describe("Scenario: the sidebar/mobile nav item list", () => { - it("places Gardener right after Catalog", () => { - const items = visibleNavItems(false); - const catalogIndex = items.findIndex((item) => item.label === "Catalog"); - const gardenerIndex = items.findIndex((item) => item.label === "Gardener"); + describe("Scenario: the Tools group", () => { + const tools = NAV_GROUPS.find((group) => group.label === "Tools"); - expect(gardenerIndex).toBe(catalogIndex + 1); + it("places Gardener first in the Tools group", () => { + expect(tools?.items[0]?.label).toBe("Gardener"); }); it("links Gardener to /gardener", () => { - const items = visibleNavItems(false); - const gardener = items.find((item) => item.label === "Gardener"); + const gardener = tools?.items.find((item) => item.label === "Gardener"); expect(gardener?.href).toBe("/gardener"); }); diff --git a/admin-ui/__specs__/grouped-navigation.spec.tsx b/admin-ui/__specs__/grouped-navigation.spec.tsx index 820b298b..4659e3ec 100644 --- a/admin-ui/__specs__/grouped-navigation.spec.tsx +++ b/admin-ui/__specs__/grouped-navigation.spec.tsx @@ -3,64 +3,412 @@ // // BDD specification — Jest. RED at plan time: every specification is it.todo — turn it into an `it` only in the // task that makes it green (T558–T560). Each Given comment names the arrange the scenario needs. +// +// next/jest's SWC transform (unlike babel-jest) does not hoist jest.mock() calls above import +// statements (mirrors app-shell.spec.tsx's own header comment), so Sidebar/MobileNav — the +// components that actually call the mocked next/navigation hook — are loaded via a dynamic +// `await import()` inside each test rather than a static top-level import. + +jest.mock("next/navigation", () => ({ + usePathname: jest.fn(), + redirect: jest.fn(), +})); + +jest.mock("@/app/login/actions", () => ({ + logout: jest.fn(), +})); + +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import "@testing-library/jest-dom/jest-globals"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import type { redirect, usePathname } from "next/navigation"; +import { + NAV_BOTTOM, + NAV_FOOTER, + NAV_GROUPS, + NAV_TOP, + visibleNavGroups, + type NavGroup, +} from "../app/(authed)/_components/nav-items"; +import { resetNavGroupOpenStateForTests } from "../app/(authed)/_components/useNavGroupOpenState"; + +const ROOT = path.resolve(__dirname, ".."); + +// jsdom's `window` (and its `localStorage`) is shared by every `it` in this FILE, not recreated +// per test — a toggle written in one scenario (AC6) would otherwise leak into the next one. Every +// test starts from a clean store, matching SPEC F203.2's "no stored toggles" baseline unless a +// scenario deliberately arranges one. +beforeEach(() => { + window.localStorage.clear(); +}); + +const mockedNextNavigation = jest.requireMock<{ + usePathname: typeof usePathname; + redirect: typeof redirect; +}>("next/navigation"); +const mockedUsePathname = mockedNextNavigation.usePathname as jest.MockedFunction; +const mockedRedirect = mockedNextNavigation.redirect as jest.MockedFunction; + +/** Recursively lists files under `dir` with one of `exts`, skipping build/dep dirs (house pattern, + * see app-shell.spec.tsx's own `collectFiles`). */ +function collectFiles(dir: string, exts: string[], out: string[] = []): string[] { + const SKIP = new Set(["node_modules", ".next"]); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIP.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectFiles(full, exts, out); + } else if (exts.some((ext) => entry.name.endsWith(ext))) { + out.push(full); + } + } + return out; +} + +/** The visible text of a group heading button (its label span, not the decorative chevron — + * which carries `aria-hidden` but is still part of `textContent`). */ +function groupHeadingLabel(button: HTMLElement): string { + return button.querySelector("span:not([aria-hidden])")?.textContent?.trim() ?? ""; +} + +/** The labels of every group whose heading currently reports `aria-expanded="true"`, in document + * order (SPEC F203.2). */ +function openGroupLabels(root: ParentNode): string[] { + return Array.from(root.querySelectorAll('nav button[aria-expanded="true"]')).map( + groupHeadingLabel + ); +} + +/** Every group's label mapped to its open/closed state (SPEC F203.6's snapshot target) — small and + * readable, proving "exactly one open group" without snapshotting the whole DOM tree. */ +function groupOpenStates(root: ParentNode): Record { + const headings = Array.from(root.querySelectorAll("nav button[aria-expanded]")); + return Object.fromEntries( + headings.map((button) => [groupHeadingLabel(button), button.getAttribute("aria-expanded") === "true"]) + ); +} + +/** Every static/dynamic import specifier (`from "..."` / `import("...")`) literally present in + * `fileText` — used by AC13's source scan below. */ +function importSpecifiersFrom(fileText: string): string[] { + const specifiers: string[] = []; + for (const re of [/\bfrom\s+["']([^"']+)["']/g, /\bimport\(\s*["']([^"']+)["']\s*\)/g]) { + for (const match of fileText.matchAll(re)) { + const specifier = match[1]; + if (specifier !== undefined) specifiers.push(specifier); + } + } + return specifiers; +} + +/** Resolves a relative (`./`, `../`) or `@/`-aliased specifier found in `fromFile` to an absolute + * path with no extension — `null` for a bare package specifier (e.g. `"react"`), which can never + * resolve inside `app/`. Mirrors `moduleNameMapper`'s own `^@/(.*)$ -> /$1` (jest.config.js). */ +function resolveImportSpecifier(fromFile: string, specifier: string): string | null { + if (specifier.startsWith(".")) return path.resolve(path.dirname(fromFile), specifier); + if (specifier.startsWith("@/")) return path.join(ROOT, specifier.slice(2)); + return null; +} -import { describe, it } from "@jest/globals"; +/** The ordered shape of a rendered `