diff --git a/src/App.test.tsx b/src/App.test.tsx index 090d5a8..9c9efd2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -79,7 +79,7 @@ describe("Module filter and empty-state layout (#188)", () => { it("filters modules matching search query by name or description", () => { render(); - const filterInput = screen.getByLabelText("Filter modules"); + const filterInput = screen.getByLabelText("Search modules by name or description"); fireEvent.change(filterInput, { target: { value: "ledger" } }); @@ -90,7 +90,7 @@ describe("Module filter and empty-state layout (#188)", () => { it("renders an accessible empty-state layout when no modules match the filter", () => { render(); - const filterInput = screen.getByLabelText("Filter modules"); + const filterInput = screen.getByLabelText("Search modules by name or description"); fireEvent.change(filterInput, { target: { value: "nonexistent-module" } }); @@ -101,7 +101,7 @@ describe("Module filter and empty-state layout (#188)", () => { screen.getByText(/No modules match/i), ).toBeInTheDocument(); - const resetButton = screen.getByRole("button", { name: "Reset filter" }); + const resetButton = screen.getByRole("button", { name: "Reset filters" }); expect(resetButton).toBeInTheDocument(); fireEvent.click(resetButton); @@ -112,13 +112,13 @@ describe("Module filter and empty-state layout (#188)", () => { it("clears the filter using the Clear button", () => { render(); - const filterInput = screen.getByLabelText("Filter modules"); + const filterInput = screen.getByLabelText("Search modules by name or description"); fireEvent.change(filterInput, { target: { value: "events" } }); expect(screen.getByText("events")).toBeInTheDocument(); expect(screen.queryByText("ttl")).not.toBeInTheDocument(); - const clearButton = screen.getByRole("button", { name: "Clear module filter" }); + const clearButton = screen.getByRole("button", { name: "Clear module search" }); fireEvent.click(clearButton); expect(screen.getByText("ttl")).toBeInTheDocument(); @@ -143,6 +143,158 @@ describe("Module filter and empty-state layout (#188)", () => { }); }); +describe("Category filters (#124)", () => { + it("renders category filter chips including an All option", () => { + render(); + const group = screen.getByRole("group", { name: /filter modules by category/i }); + expect(group).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Environment" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Economics" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Security" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Storage" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Assertions" })).toBeInTheDocument(); + }); + + it("filters modules when a category chip is selected", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Environment" })); + + expect(screen.getByText("core")).toBeInTheDocument(); + expect(screen.getByText("ledger")).toBeInTheDocument(); + expect(screen.queryByText("money")).not.toBeInTheDocument(); + expect(screen.queryByText("auth")).not.toBeInTheDocument(); + }); + + it("toggles category off when the active chip is clicked again", () => { + render(); + const envChip = screen.getByRole("button", { name: "Environment" }); + fireEvent.click(envChip); + expect(screen.queryByText("money")).not.toBeInTheDocument(); + + fireEvent.click(envChip); + expect(screen.getByText("money")).toBeInTheDocument(); + expect(screen.getByText("core")).toBeInTheDocument(); + }); + + it("combines category filter with the text search", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Economics" })); + const filterInput = screen.getByLabelText("Search modules by name or description"); + fireEvent.change(filterInput, { target: { value: "token" } }); + + expect(screen.getByText("tokens")).toBeInTheDocument(); + expect(screen.queryByText("money")).not.toBeInTheDocument(); + expect(screen.queryByText("core")).not.toBeInTheDocument(); + }); +}); + +describe("Module detail pages with breadcrumbs (#126)", () => { + it("opens the module detail view when a module card is activated", () => { + render(); + const card = screen.getByRole("button", { name: /view details for core module/i }); + fireEvent.click(card); + + expect(screen.getByRole("heading", { name: "core" })).toBeInTheDocument(); + // Detail description paragraph (distinct from the hero lede). + expect( + screen.getByText("Deterministic environments and addresses"), + ).toBeInTheDocument(); + }); + + it("renders breadcrumb navigation on the detail page", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /view details for ledger module/i })); + + const breadcrumb = screen.getByRole("navigation", { name: /breadcrumb/i }); + expect(breadcrumb).toBeInTheDocument(); + expect(within(breadcrumb).getByRole("link", { name: "Home" })).toBeInTheDocument(); + expect(within(breadcrumb).getByRole("link", { name: "Modules" })).toBeInTheDocument(); + expect(within(breadcrumb).getByText("ledger")).toBeInTheDocument(); + }); + + it("navigates back to the module list via the breadcrumb link", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /view details for auth module/i })); + expect(screen.getByRole("heading", { name: "auth" })).toBeInTheDocument(); + + const breadcrumb = screen.getByRole("navigation", { name: /breadcrumb/i }); + fireEvent.click(within(breadcrumb).getByRole("link", { name: "Modules" })); + expect(screen.getByText("core")).toBeInTheDocument(); + expect(screen.getByText("ttl")).toBeInTheDocument(); + }); + + it("navigates back via the back button on the detail page", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /view details for ttl module/i })); + + fireEvent.click(screen.getByRole("button", { name: /back to all modules/i })); + expect(screen.getByText("events")).toBeInTheDocument(); + }); + + it("supports keyboard activation of module cards", () => { + render(); + const card = screen.getByRole("button", { name: /view details for money module/i }); + fireEvent.keyDown(card, { key: "Enter" }); + + expect(screen.getByRole("heading", { name: "money" })).toBeInTheDocument(); + }); +}); + +describe("Back-to-top control (#125)", () => { + it("renders a hidden back-to-top button initially", () => { + render(); + const btn = screen.getByLabelText(/back to top/i); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute("aria-hidden", "true"); + expect(btn).toHaveAttribute("tabindex", "-1"); + }); + + it("shows the back-to-top button after scrolling down", () => { + render(); + Object.defineProperty(window, "scrollY", { value: 600, writable: true, configurable: true }); + fireEvent.scroll(window); + + const btn = screen.getByLabelText(/back to top/i); + expect(btn).toHaveAttribute("aria-hidden", "false"); + expect(btn).toHaveAttribute("tabindex", "0"); + expect(btn.className).toContain("visible"); + }); + + it("scrolls to top when the button is activated", () => { + const scrollSpy = vi.fn(); + window.scrollTo = scrollSpy; + + render(); + Object.defineProperty(window, "scrollY", { value: 800, writable: true, configurable: true }); + fireEvent.scroll(window); + + fireEvent.click(screen.getByLabelText(/back to top/i)); + expect(scrollSpy).toHaveBeenCalledWith({ top: 0, behavior: "smooth" }); + }); +}); + +describe("Module search keyboard shortcut (#127)", () => { + it("focuses the module search field when / is pressed", () => { + render(); + const filterInput = screen.getByLabelText("Search modules by name or description"); + + fireEvent.keyDown(document.body, { key: "/" }); + + expect(filterInput).toHaveFocus(); + }); + + it("does not trigger the shortcut when typing / inside the search input", () => { + render(); + const filterInput = screen.getByLabelText("Search modules by name or description") as HTMLInputElement; + + fireEvent.keyDown(filterInput, { key: "/" }); + + // The slash should not cause a side-effect (focus jump); the input simply keeps its value. + expect(filterInput).toBeInTheDocument(); + }); +}); + describe("Reduced-width layout for navigation labels (#189)", () => { it("renders accessible navigation links with responsive container attributes", () => { render(); diff --git a/src/App.tsx b/src/App.tsx index 7078c70..de74841 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ -import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildCommand, formatCommandLine, modules, + moduleCategories, type CommandName, + type ModuleCategory, type OptionalArgKey, localFileGuides, } from "./testkit"; @@ -260,7 +262,14 @@ export default function App() { useEffect(() => { document.documentElement.style.scrollPaddingTop = "78px"; const handleKeyDown = (event: globalThis.KeyboardEvent) => { - if (!event.ctrlKey && !event.metaKey) return; + if (!event.ctrlKey && !event.metaKey) { + // "/" focuses the module search field (#127) + if (event.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes((event.target as HTMLElement)?.tagName ?? "")) { + event.preventDefault(); + document.getElementById("module-search")?.focus(); + } + return; + } switch (event.key) { case "1": @@ -327,12 +336,29 @@ export default function App() { const filteredModules = useMemo(() => { const q = moduleFilter.trim().toLowerCase(); - if (!q) return modules; - return modules.filter( - ([name, description]) => - name.toLowerCase().includes(q) || description.toLowerCase().includes(q) - ); - }, [moduleFilter]); + return modules.filter((m) => { + if (categoryFilter && m.category !== categoryFilter) return false; + if (!q) return true; + return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q); + }); + }, [moduleFilter, categoryFilter]); + + const selectedModuleData = useMemo( + () => (selectedModule ? modules.find((m) => m.name === selectedModule) ?? null : null), + [selectedModule], + ); + + // Back-to-top visibility after long scrolling (#125) + useEffect(() => { + const onScroll = () => setShowBackToTop(window.scrollY > 480); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + const scrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + document.getElementById("top")?.focus?.(); + }, []); const moveOptionalArg = (key: OptionalArgKey, direction: "up" | "down") => { setCoverageOrder((current) => { @@ -594,11 +620,10 @@ export default function App() { {moduleFilter && ( )} @@ -640,6 +665,107 @@ export default function App() { Reset filter + ) : ( + <> +
+

One prelude. Seven focused modules.

+

Everything a serious contract test needs.

+
+ + setModuleFilter(event.target.value)} + aria-label="Search modules by name or description" + /> + {moduleFilter && ( + + )} +
+
+ + {moduleCategories.map((cat) => ( + + ))} +
+
+ + {filteredModules.length > 0 ? ( +
+ {filteredModules.map((mod, index) => ( +
setSelectedModule(mod.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedModule(mod.name); + } + }} + > + 0{index + 1} +

{mod.name}

+

{mod.description}

+ {mod.category} +
+ ))} +
+ ) : ( +
+ +

No modules match your filters

+

+ {moduleFilter + ? `No testing modules found matching “${moduleFilter}”${categoryFilter ? ` in ${categoryFilter}` : ""}.` + : `No testing modules in the ${categoryFilter} category.`} + {" "}Try adjusting your filters. +

+ +
+ )} + )} @@ -1344,6 +1470,18 @@ export default function App() { Apache-2.0 + + {/* Back-to-top control (#125) */} + ); } diff --git a/src/testkit.ts b/src/testkit.ts index f61afe0..4169438 100644 --- a/src/testkit.ts +++ b/src/testkit.ts @@ -88,16 +88,30 @@ export const configPresets: ConfigPreset[] = [ }, ]; -export const modules = [ - ["core", "Deterministic environments and addresses"], - ["ledger", "Time, sequence, and clock control"], - ["money", "Adversarial values and conservation checks"], - ["events", "Typed capture and event assertions"], - ["tokens", "Stellar Asset Contract test doubles"], - ["auth", "Privilege and authorization matrices"], - ["ttl", "Storage lifetime and expiry simulation"], +export const moduleCategories = [ + "Environment", + "Economics", + "Assertions", + "Security", + "Storage", ] as const; +export type ModuleCategory = (typeof moduleCategories)[number]; + +export const modules: ReadonlyArray<{ + name: string; + description: string; + category: ModuleCategory; +}> = [ + { name: "core", description: "Deterministic environments and addresses", category: "Environment" }, + { name: "ledger", description: "Time, sequence, and clock control", category: "Environment" }, + { name: "money", description: "Adversarial values and conservation checks", category: "Economics" }, + { name: "events", description: "Typed capture and event assertions", category: "Assertions" }, + { name: "tokens", description: "Stellar Asset Contract test doubles", category: "Economics" }, + { name: "auth", description: "Privilege and authorization matrices", category: "Security" }, + { name: "ttl", description: "Storage lifetime and expiry simulation", category: "Storage" }, +]; + const quote = (value: string) => (value.includes(" ") ? `"${value}"` : value); const getOptionalArgOrder = (options: CommandOptions): OptionalArgKey[] => {