diff --git a/README.md b/README.md index 7b93fa33..4e18e9a0 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Adventures are authored as YAML at `src/data/adventures//adventure.yaml` and | `/community-guide` | redirects to `/handbook` | Legacy alias | | `/docs` | redirects to `/handbook` | Legacy alias | | `/docs/community-guide` | redirects to `/handbook` | Legacy alias | -| `/challenges` | `Challenges.tsx` | All challenges by technology | +| `/challenges` | `Challenges.tsx` | All adventures; filter by technology tag | | `/challenges/:tag` | `Challenges.tsx` | Challenges filtered by technology tag (SEO-friendly slug) | | `*` | `CatchAll.tsx` | Client-side 404 fallback (re-exports `NotFound.tsx`; required because React Router v7 needs unique files per route) | diff --git a/src/pages/Challenges.tsx b/src/pages/Challenges.tsx index 3ef1b0d6..7a1a64ae 100644 --- a/src/pages/Challenges.tsx +++ b/src/pages/Challenges.tsx @@ -6,7 +6,9 @@ import { Footer } from "@/components/Footer"; import { PageHero } from "@/components/PageHero"; import { BottomCTA } from "@/components/BottomCTA"; import { FilteredLevelCard } from "@/components/FilteredLevelCard"; +import { AdventureCard } from "@/components/AdventureCard"; import { ADVENTURES, ALL_TAGS, getLevelsByTag, slugToTag, tagToSlug } from "@/data/adventures"; +import { ADVENTURE_SUMMARIES } from "@/data/adventures/summaries"; import { SITE_URL, BRAND_NAME } from "@/data/constants"; import { buildPageMeta } from "@/lib/meta"; @@ -105,7 +107,7 @@ const Challenges = (): JSX.Element => { {hasInteracted ? activeTag ? `Showing ${filteredLevels.length} ${filteredLevels.length === 1 ? "challenge" : "challenges"} tagged with ${activeTag}` - : `Filter cleared, showing all ${ALL_LEVELS.length} challenges` + : `Filter cleared, showing all ${ADVENTURE_SUMMARIES.length} adventures` : ""} @@ -131,19 +133,14 @@ const Challenges = (): JSX.Element => { ) : ( <>

- All Challenges + All Adventures - · {ALL_LEVELS.length} result{ALL_LEVELS.length !== 1 ? "s" : ""} + · {ADVENTURE_SUMMARIES.length} {ADVENTURE_SUMMARIES.length === 1 ? "adventure" : "adventures"}, {ALL_LEVELS.length} {ALL_LEVELS.length === 1 ? "challenge" : "challenges"}

- {ALL_LEVELS.map(({ level, adventureId, adventureTitle }) => ( - + {ADVENTURE_SUMMARIES.map((adventure) => ( + ))}
diff --git a/src/test/challenges.test.tsx b/src/test/challenges.test.tsx new file mode 100644 index 00000000..12c7a113 --- /dev/null +++ b/src/test/challenges.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router"; +import Challenges from "@/pages/Challenges"; +import { ADVENTURES } from "@/data/adventures"; +import { ADVENTURE_SUMMARIES } from "@/data/adventures/summaries"; + +const allTags = Array.from(new Set(ADVENTURES.flatMap((a) => a.tags))).sort(); +const firstTag = allTags[0]; +const totalChallenges = ADVENTURES.flatMap((a) => a.levels).length; + +function renderChallenges(initialPath = "/challenges"): ReturnType { + return render( + + + } /> + } /> + + + ); +} + +describe("Challenges - default (All) state", () => { + it("renders an adventure card for every adventure", () => { + renderChallenges(); + ADVENTURE_SUMMARIES.forEach((adventure) => { + expect( + screen.getAllByRole("link").some((l) => l.getAttribute("href") === `/adventures/${adventure.id}`) + ).toBe(true); + }); + }); + + it("does not render individual level cards in the All view", () => { + renderChallenges(); + const levelLinks = screen.queryAllByRole("link").filter( + (l) => l.getAttribute("href")?.includes("/levels/") + ); + expect(levelLinks.length).toBe(0); + }); + + it("heading reads 'All Adventures'", () => { + renderChallenges(); + expect(screen.getByRole("heading", { name: /All Adventures/i })).toBeTruthy(); + }); + + it("heading includes the adventure count", () => { + renderChallenges(); + const heading = screen.getByRole("heading", { name: /All Adventures/i }); + expect(heading.textContent).toContain(String(ADVENTURE_SUMMARIES.length)); + expect(heading.textContent).toContain("adventure"); + }); + + it("heading includes the total challenge count", () => { + renderChallenges(); + const heading = screen.getByRole("heading", { name: /All Adventures/i }); + expect(heading.textContent).toContain(String(totalChallenges)); + expect(heading.textContent).toContain("challenge"); + }); + + it("renders a filter button for every unique tag", () => { + renderChallenges(); + allTags.forEach((tag) => { + expect(screen.getByRole("button", { name: tag })).toBeTruthy(); + }); + }); + + it("wraps filter buttons in a group with aria-label", () => { + const { container } = renderChallenges(); + const group = container.querySelector('[role="group"][aria-label="Filter challenges by technology"]'); + expect(group).toBeTruthy(); + }); + + it("live region is empty before any interaction", () => { + const { container } = renderChallenges(); + const region = container.querySelector("[aria-live]"); + expect(region).toBeTruthy(); + expect(region!.textContent).toBe(""); + }); +}); + +describe("Challenges - tag filter", () => { + it("replaces adventure cards with level cards on tag selection", () => { + renderChallenges(); + fireEvent.click(screen.getByRole("button", { name: firstTag })); + ADVENTURE_SUMMARIES.forEach((adventure) => { + expect( + screen.queryAllByRole("link").some((l) => l.getAttribute("href") === `/adventures/${adventure.id}`) + ).toBe(false); + }); + }); + + it("shows level cards linking to /adventures/:id/levels/:levelId when a tag is selected", () => { + renderChallenges(); + fireEvent.click(screen.getByRole("button", { name: firstTag })); + const levelLinks = screen.getAllByRole("link").filter( + (l) => l.getAttribute("href")?.includes("/levels/") + ); + expect(levelLinks.length).toBeGreaterThan(0); + }); + + it("marks the active tag button as aria-pressed='true'", () => { + renderChallenges(); + const btn = screen.getByRole("button", { name: firstTag }); + fireEvent.click(btn); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("announces the challenge count when a tag is active", () => { + const { container } = renderChallenges(); + fireEvent.click(screen.getByRole("button", { name: firstTag })); + const region = container.querySelector("[aria-live]"); + expect(region!.textContent).toContain(firstTag); + }); +}); + +describe("Challenges - deselecting a tag", () => { + it("restores adventure cards when the active tag is clicked again", () => { + renderChallenges(); + const btn = screen.getByRole("button", { name: firstTag }); + fireEvent.click(btn); + fireEvent.click(btn); + ADVENTURE_SUMMARIES.forEach((adventure) => { + expect( + screen.getAllByRole("link").some((l) => l.getAttribute("href") === `/adventures/${adventure.id}`) + ).toBe(true); + }); + }); + + it("resets aria-pressed to 'false' after deselecting", () => { + renderChallenges(); + const btn = screen.getByRole("button", { name: firstTag }); + fireEvent.click(btn); + fireEvent.click(btn); + expect(btn.getAttribute("aria-pressed")).toBe("false"); + }); + + it("announces adventure count when filter is cleared", () => { + const { container } = renderChallenges(); + const btn = screen.getByRole("button", { name: firstTag }); + fireEvent.click(btn); + fireEvent.click(btn); + const region = container.querySelector("[aria-live]"); + expect(region!.textContent).toContain("adventures"); + }); +}); diff --git a/styleguide.md b/styleguide.md index bacdfac3..5b54c0f6 100644 --- a/styleguide.md +++ b/styleguide.md @@ -1162,7 +1162,7 @@ const [activeTopic, setActiveTopic] = useState(null); - Tag chips render with `.pill-active` when selected and `.pill-inactive` otherwise. Each sets `aria-pressed={activeTopic === tag}`. - Clicking an already-active chip deselects it and returns to the default view. - `ChallengesGrid`: no URL change on selection. Default (All) = adventure card grid. Tag = flat level card grid. -- `Challenges`: URL updates to `/challenges/:tag` when a tag is selected. Default (All) = flat grid of every challenge level. Tag = filtered flat grid. +- `Challenges`: URL updates to `/challenges/:tag` when a tag is selected. Default (All) = adventure card grid (same as `ChallengesGrid`). Tag = filtered flat level card grid. ---