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[] => {