Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Adventures are authored as YAML at `src/data/adventures/<id>/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) |

Expand Down
17 changes: 7 additions & 10 deletions src/pages/Challenges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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`
: ""}
</span>

Expand All @@ -131,19 +133,14 @@ const Challenges = (): JSX.Element => {
) : (
<>
<h2 className="mb-6 text-lg font-semibold text-foreground">
All Challenges
All Adventures
<span className="ml-2 font-normal text-sm text-muted-foreground">
&middot; {ALL_LEVELS.length} result{ALL_LEVELS.length !== 1 ? "s" : ""}
&middot; {ADVENTURE_SUMMARIES.length} {ADVENTURE_SUMMARIES.length === 1 ? "adventure" : "adventures"}, {ALL_LEVELS.length} {ALL_LEVELS.length === 1 ? "challenge" : "challenges"}
</span>
</h2>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{ALL_LEVELS.map(({ level, adventureId, adventureTitle }) => (
<FilteredLevelCard
key={`${adventureId}-${level.id}`}
level={level}
adventureId={adventureId}
adventureTitle={adventureTitle}
/>
{ADVENTURE_SUMMARIES.map((adventure) => (
<AdventureCard key={adventure.id} adventure={adventure} />
))}
</div>
</>
Expand Down
145 changes: 145 additions & 0 deletions src/test/challenges.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof render> {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/challenges" element={<Challenges />} />
<Route path="/challenges/:tag" element={<Challenges />} />
</Routes>
</MemoryRouter>
);
}

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");
});
});
2 changes: 1 addition & 1 deletion styleguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ const [activeTopic, setActiveTopic] = useState<string | null>(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.

---

Expand Down
Loading