From 6c25bf78c7d743db0111113986858bf3435f8947 Mon Sep 17 00:00:00 2001 From: bntvllnt Date: Tue, 12 May 2026 20:54:24 +0200 Subject: [PATCH] test(components): backfill tutorial surface coverage --- .../category-filter/category-filter.test.tsx | 66 ++++++++++ .../content-intro/content-intro.test.tsx | 76 +++++++++++ .../table-of-contents-panel.test.tsx | 93 ++++++++++++++ .../tutorial-filters.test.tsx | 119 ++++++++++++++++++ .../tutorial-intro-content.test.tsx | 64 ++++++++++ 5 files changed, 418 insertions(+) create mode 100644 packages/ui/src/components/category-filter/category-filter.test.tsx create mode 100644 packages/ui/src/components/content-intro/content-intro.test.tsx create mode 100644 packages/ui/src/components/table-of-contents-panel/table-of-contents-panel.test.tsx create mode 100644 packages/ui/src/components/tutorial-filters/tutorial-filters.test.tsx create mode 100644 packages/ui/src/components/tutorial-intro-content/tutorial-intro-content.test.tsx diff --git a/packages/ui/src/components/category-filter/category-filter.test.tsx b/packages/ui/src/components/category-filter/category-filter.test.tsx new file mode 100644 index 00000000..a05c3768 --- /dev/null +++ b/packages/ui/src/components/category-filter/category-filter.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +let mockPathname = "/en/design-systems"; + +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathname, +})); + +import { CategoryFilter } from "./category-filter"; + +describe("CategoryFilter", () => { + it("renders nothing when there are no categories", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("deduplicates and sorts category labels", () => { + render( + , + ); + + expect(screen.getAllByText(/Alpha|Design systems|Zeta/)).toHaveLength(3); + expect( + screen.getAllByText(/Alpha|Design systems|Zeta/).map((node) => { + return node.textContent; + }), + ).toEqual(["Alpha", "Design systems", "Zeta"]); + }); + + it("slugifies category links with the active language", () => { + render( + , + ); + + expect(screen.getByText("Résumé Tips").closest("a")).toHaveAttribute( + "href", + "/fr/resume-tips", + ); + expect(screen.getByText("Data & AI").closest("a")).toHaveAttribute( + "href", + "/fr/data-ai", + ); + }); + + it("renders the selected category as a non-link badge", () => { + mockPathname = "/en/design-systems"; + + render( + , + ); + + expect(screen.getByText("Design systems").closest("a")).toBeNull(); + expect(screen.getByText("Components").closest("a")).toHaveAttribute( + "href", + "/en/components", + ); + }); +}); diff --git a/packages/ui/src/components/content-intro/content-intro.test.tsx b/packages/ui/src/components/content-intro/content-intro.test.tsx new file mode 100644 index 00000000..74be5649 --- /dev/null +++ b/packages/ui/src/components/content-intro/content-intro.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ContentIntro, type ContentIntroSection } from "./content-intro"; + +const sections: ContentIntroSection[] = [ + { id: "setup", title: "Set up the workspace" }, + { id: "ship", title: "Ship the first flow" }, +]; + +const baseProps = { + completedSections: new Set(), + estimatedTime: "12 min", + onGoToSection: vi.fn(), + onStart: vi.fn(), + renderIntroContent: () =>

Read the framing first.

, + sections, + title: "Build a tutorial", +}; + +describe("ContentIntro", () => { + it("renders the intro content and table of contents", () => { + render(); + + expect(screen.getByText("Build a tutorial")).toBeInTheDocument(); + expect(screen.getByText("Read the framing first.")).toBeInTheDocument(); + expect(screen.getByText("Set up the workspace")).toBeInTheDocument(); + expect(screen.getByText("Ship the first flow")).toBeInTheDocument(); + }); + + it("calls onGoToSection with the clicked section index", () => { + const handleGoToSection = vi.fn(); + render(); + + fireEvent.click(screen.getByText("Ship the first flow")); + + expect(handleGoToSection).toHaveBeenCalledWith(1); + }); + + it("shows progress and continue copy when sections are completed", () => { + render( + , + ); + + expect(screen.getByText("1/2 completed")).toBeInTheDocument(); + expect(screen.getByText("Keep going")).toBeInTheDocument(); + expect(screen.getByText("Set up the workspace")).toHaveClass( + "line-through", + ); + }); + + it("calls onStart from the primary button and Enter shortcut", () => { + const handleStart = vi.fn(); + render(); + + fireEvent.click(screen.getByText("Start Tutorial")); + fireEvent.keyDown(document, { key: "Enter" }); + + expect(handleStart).toHaveBeenCalledTimes(2); + }); + + it("renders additional content when supplied", () => { + render( + Author notes} + />, + ); + + expect(screen.getByText("Author notes")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/table-of-contents-panel/table-of-contents-panel.test.tsx b/packages/ui/src/components/table-of-contents-panel/table-of-contents-panel.test.tsx new file mode 100644 index 00000000..8ef6aeeb --- /dev/null +++ b/packages/ui/src/components/table-of-contents-panel/table-of-contents-panel.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + TableOfContentsPanel, + type TOCSection, +} from "./table-of-contents-panel"; + +const sections: TOCSection[] = [ + { id: "intro", title: "Introduction" }, + { id: "deep-dive", title: "Deep dive" }, +]; + +const baseProps = { + completedSections: new Set(["intro"]), + completionCount: 1, + currentSectionIndex: 0, + isOpen: true, + onClose: vi.fn(), + onSelectSection: vi.fn(), + sections, + totalSections: 2, +}; + +describe("TableOfContentsPanel", () => { + it("renders nothing when closed", () => { + render(); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders the dialog with progress and sections when open", () => { + render(); + + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + expect(screen.getByText("Table of Contents")).toBeInTheDocument(); + expect(screen.getByText("1 / 2 (50%)")).toBeInTheDocument(); + expect(screen.getByText("Introduction")).toBeInTheDocument(); + expect(screen.getByText("Deep dive")).toBeInTheDocument(); + }); + + it("selects a section and closes the panel", () => { + const handleClose = vi.fn(); + const handleSelectSection = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("Deep dive")); + + expect(handleSelectSection).toHaveBeenCalledWith(1); + expect(handleClose).toHaveBeenCalledTimes(1); + }); + + it("closes on Escape and backdrop click", () => { + const handleClose = vi.fn(); + const { container } = render( + , + ); + const backdrop = container.querySelector( + "[aria-hidden='true']", + ); + if (!backdrop) throw new Error("Expected backdrop to render"); + + fireEvent.keyDown(window, { key: "Escape" }); + fireEvent.click(backdrop); + + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it("renders the reset action only when progress exists", () => { + const handleReset = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByText("Reset Progress")); + expect(handleReset).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.queryByText("Reset Progress")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/tutorial-filters/tutorial-filters.test.tsx b/packages/ui/src/components/tutorial-filters/tutorial-filters.test.tsx new file mode 100644 index 00000000..8b219185 --- /dev/null +++ b/packages/ui/src/components/tutorial-filters/tutorial-filters.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + TutorialFilters, + type TutorialFiltersLabels, +} from "./tutorial-filters"; + +const labels: TutorialFiltersLabels = { + activeFilters: "Active filters:", + clear: "Clear", + clearAll: "Clear all", + difficulty: { + advanced: "Advanced", + all: "All", + beginner: "Beginner", + intermediate: "Intermediate", + }, + difficultyLabel: "Difficulty", + searchFilter: "Search", + searchLabel: "Search tutorials", + searchPlaceholder: "Search by topic", + tagsLabel: "Tags", +}; + +const baseProps = { + currentDifficulty: "", + currentTags: [] as string[], + labels, + onFilterChange: vi.fn(), + searchQuery: "", + tags: ["React", "Design"], +}; + +describe("TutorialFilters", () => { + it("renders search, difficulty options, and tags", () => { + render(); + + expect(screen.getByLabelText("Search tutorials")).toHaveAttribute( + "placeholder", + "Search by topic", + ); + expect(screen.getByText("Beginner")).toBeInTheDocument(); + expect(screen.getByText("Advanced")).toBeInTheDocument(); + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("Design")).toBeInTheDocument(); + }); + + it("emits search and difficulty updates", () => { + const handleFilterChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search tutorials"), { + target: { value: "state" }, + }); + fireEvent.click(screen.getByText("Intermediate")); + + expect(handleFilterChange).toHaveBeenCalledWith({ search: "state" }); + expect(handleFilterChange).toHaveBeenCalledWith({ + difficulty: "intermediate", + }); + }); + + it("toggles tags from the current selection", () => { + const handleFilterChange = vi.fn(); + render( + , + ); + const [reactTag] = screen.getAllByText("React"); + if (!reactTag) throw new Error("Expected React tag to render"); + + fireEvent.click(reactTag); + fireEvent.click(screen.getByText("Design")); + + expect(handleFilterChange).toHaveBeenCalledWith({ tags: [] }); + expect(handleFilterChange).toHaveBeenCalledWith({ + tags: ["React", "Design"], + }); + }); + + it("renders active filters and clears them", () => { + const handleFilterChange = vi.fn(); + render( + , + ); + + expect(screen.getByText("Active filters:")).toBeInTheDocument(); + expect(screen.getByText('Search "routing"')).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Clear")); + fireEvent.click(screen.getByText("Clear all")); + + expect(handleFilterChange).toHaveBeenCalledWith({ tags: [] }); + expect(handleFilterChange).toHaveBeenCalledWith({ + difficulty: "all", + search: "", + tags: [], + }); + }); + + it("disables search and difficulty controls while pending", () => { + render(); + + expect(screen.getByLabelText("Search tutorials")).toBeDisabled(); + expect(screen.getByText("Beginner").closest("button")).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/components/tutorial-intro-content/tutorial-intro-content.test.tsx b/packages/ui/src/components/tutorial-intro-content/tutorial-intro-content.test.tsx new file mode 100644 index 00000000..697f5baf --- /dev/null +++ b/packages/ui/src/components/tutorial-intro-content/tutorial-intro-content.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { TutorialIntroContent } from "./tutorial-intro-content"; + +describe("TutorialIntroContent", () => { + it("renders the supplied title and markdown content", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Start here" })).toBeVisible(); + expect(screen.getByText(/Intro paragraph with/)).toBeInTheDocument(); + expect(screen.getByText("strong text")).toHaveClass("font-semibold"); + }); + + it("renders links and inline code with the expected semantics", () => { + render( + , + ); + + expect(screen.getByText("pnpm").tagName).toBe("CODE"); + expect(screen.getByText("pnpm").closest("a")).toHaveAttribute( + "href", + "https://pnpm.io", + ); + }); + + it("strips MDX component tags before markdown rendering", () => { + render( + Hidden client-only content", + "", + ].join("\n\n")} + title="Hybrid content" + />, + ); + + expect(screen.getByText("Visible copy.")).toBeInTheDocument(); + expect( + screen.queryByText("Hidden client-only content"), + ).not.toBeInTheDocument(); + }); + + it("applies a custom class name to the section", () => { + const { container } = render( + , + ); + + expect(container.querySelector("section")).toHaveClass("custom-intro"); + }); +});