From f0ab563f962d2a7ba671e041a40fb7cc779909bc Mon Sep 17 00:00:00 2001 From: bntvllnt Date: Wed, 13 May 2026 07:45:26 +0200 Subject: [PATCH] test(components): backfill learning content coverage --- .../lang-provider/lang-provider.test.tsx | 47 ++++++ .../overview-board/overview-board.test.tsx | 72 ++++++++ packages/ui/src/components/quiz/quiz.test.tsx | 96 +++++++++++ .../step-by-step/step-by-step.test.tsx | 48 ++++++ .../tutorial-complete.test.tsx | 157 ++++++++++++++++++ 5 files changed, 420 insertions(+) create mode 100644 packages/ui/src/components/lang-provider/lang-provider.test.tsx create mode 100644 packages/ui/src/components/overview-board/overview-board.test.tsx create mode 100644 packages/ui/src/components/quiz/quiz.test.tsx create mode 100644 packages/ui/src/components/step-by-step/step-by-step.test.tsx create mode 100644 packages/ui/src/components/tutorial-complete/tutorial-complete.test.tsx diff --git a/packages/ui/src/components/lang-provider/lang-provider.test.tsx b/packages/ui/src/components/lang-provider/lang-provider.test.tsx new file mode 100644 index 00000000..18c6d152 --- /dev/null +++ b/packages/ui/src/components/lang-provider/lang-provider.test.tsx @@ -0,0 +1,47 @@ +import { render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { LangProvider } from "./lang-provider"; + +let mockPathname = "/en/docs"; + +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathname, +})); + +describe("LangProvider", () => { + afterEach(() => { + mockPathname = "/en/docs"; + document.documentElement.removeAttribute("lang"); + }); + + it("sets the document language from a supported pathname prefix", async () => { + mockPathname = "/fr/components/button"; + + render(); + + await waitFor(() => { + expect(document.documentElement).toHaveAttribute("lang", "fr"); + }); + }); + + it("uses the default language when the pathname has no locale prefix", async () => { + mockPathname = "/components/button"; + + render(); + + await waitFor(() => { + expect(document.documentElement).toHaveAttribute("lang", "fr"); + }); + }); + + it("ignores unsupported locale prefixes", async () => { + mockPathname = "/de/components/button"; + + render(); + + await waitFor(() => { + expect(document.documentElement).toHaveAttribute("lang", "en"); + }); + }); +}); diff --git a/packages/ui/src/components/overview-board/overview-board.test.tsx b/packages/ui/src/components/overview-board/overview-board.test.tsx new file mode 100644 index 00000000..62e9295c --- /dev/null +++ b/packages/ui/src/components/overview-board/overview-board.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { OverviewBoard, type OverviewBoardItem } from "./overview-board"; + +const errorItem: OverviewBoardItem = { + ctaLabel: "Review errors", + description: "Two checks need attention.", + handleCtaClick: vi.fn(), + heading: "Error budget", + id: "errors", + metric: "2", + tone: "danger", +}; + +const actionItem: OverviewBoardItem = { + description: "One action is waiting.", + heading: "Action queue", + id: "actions", + metric: "1", + tone: "warning", +}; + +const items: OverviewBoardItem[] = [errorItem, actionItem]; + +describe("OverviewBoard", () => { + it("renders board copy and item metrics", () => { + render( + , + ); + + expect(screen.getByText("Operations")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Run overview" }), + ).toBeInTheDocument(); + expect(screen.getByText("Critical work at a glance.")).toBeInTheDocument(); + expect(screen.getByText("Error budget")).toBeInTheDocument(); + expect(screen.getByText("Two checks need attention.")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + it("renders default icons based on heading text", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-list-todo")).toBeInTheDocument(); + }); + + it("renders CTA buttons only when configured and calls the item handler", () => { + const handleCtaClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /review errors/i })); + + expect(handleCtaClick).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("button", { name: /action queue/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/quiz/quiz.test.tsx b/packages/ui/src/components/quiz/quiz.test.tsx new file mode 100644 index 00000000..a1d95d81 --- /dev/null +++ b/packages/ui/src/components/quiz/quiz.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { Quiz, type QuizOption } from "./quiz"; + +function keyedOption(option: QuizOption, key: string): QuizOption { + return Object.assign(option, { + toString: () => key, + }); +} + +const options: QuizOption[] = [ + keyedOption( + { + correct: true, + explanation: "Paris is the capital of France.", + label: "Paris", + }, + "paris", + ), + keyedOption( + { + explanation: "London is the capital of the United Kingdom.", + label: "London", + }, + "london", + ), +]; + +function renderQuiz(onAnswer = vi.fn()) { + render( + Paris is correct.

} + hint="Think of the Eiffel Tower." + onAnswer={onAnswer} + options={options} + question="What is the capital of France?" + />, + ); + + return { onAnswer }; +} + +describe("Quiz", () => { + it("renders the question and disables submission until an option is selected", () => { + renderQuiz(); + + expect( + screen.getByText("What is the capital of France?"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Check Answer" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Paris" })); + + expect( + screen.getByRole("button", { name: "Check Answer" }), + ).not.toBeDisabled(); + }); + + it("reveals the hint before submission", () => { + renderQuiz(); + + fireEvent.click(screen.getByRole("button", { name: "Show hint" })); + + expect(screen.getByText("Think of the Eiffel Tower.")).toBeInTheDocument(); + }); + + it("submits a correct answer and resets state", () => { + const { onAnswer } = renderQuiz(); + + fireEvent.click(screen.getByRole("button", { name: "Paris" })); + fireEvent.click(screen.getByRole("button", { name: "Check Answer" })); + + expect(onAnswer).toHaveBeenCalledWith(true); + expect(screen.getByText("Correct!")).toBeInTheDocument(); + expect(screen.getByText("Paris is correct.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Try Again" })); + + expect(screen.getByRole("button", { name: "Check Answer" })).toBeDisabled(); + expect(screen.queryByText("Correct!")).not.toBeInTheDocument(); + }); + + it("submits an incorrect answer", () => { + const { onAnswer } = renderQuiz(); + + fireEvent.click(screen.getByRole("button", { name: "London" })); + fireEvent.click(screen.getByRole("button", { name: "Check Answer" })); + + expect(onAnswer).toHaveBeenCalledWith(false); + expect(screen.getByText("Not quite right.")).toBeInTheDocument(); + expect( + screen.getByText("London is the capital of the United Kingdom."), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/step-by-step/step-by-step.test.tsx b/packages/ui/src/components/step-by-step/step-by-step.test.tsx new file mode 100644 index 00000000..a5ef31f2 --- /dev/null +++ b/packages/ui/src/components/step-by-step/step-by-step.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Step, StepByStep } from "./step-by-step"; + +function renderSteps(interactive = false) { + render( + + +

Install dependencies.

+
+ +

Build the package.

+
+
, + ); +} + +describe("StepByStep", () => { + it("renders non-interactive numbered steps", () => { + renderSteps(); + + expect( + screen.getByRole("heading", { name: "Setup guide" }), + ).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("Install")).toBeInTheDocument(); + expect(screen.getByText("Build the package.")).toBeInTheDocument(); + }); + + it("tracks completed steps in interactive mode", () => { + renderSteps(true); + + expect(screen.getByText("0/2 completed")).toBeInTheDocument(); + + const firstStepButton = screen.getByRole("button", { name: "1" }); + fireEvent.click(firstStepButton); + + expect(screen.getByText("1/2 completed")).toBeInTheDocument(); + expect(screen.getByText("Install")).toHaveClass("line-through"); + + fireEvent.click(firstStepButton); + + expect(screen.getByText("0/2 completed")).toBeInTheDocument(); + expect(screen.getByText("Install")).not.toHaveClass("line-through"); + }); +}); diff --git a/packages/ui/src/components/tutorial-complete/tutorial-complete.test.tsx b/packages/ui/src/components/tutorial-complete/tutorial-complete.test.tsx new file mode 100644 index 00000000..0d7cea70 --- /dev/null +++ b/packages/ui/src/components/tutorial-complete/tutorial-complete.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../profile-section", () => ({ + ProfileSection: ({ + dict, + }: { + dict: { profile: { name: string; tagline: string } }; + }) => ( +
+

{dict.profile.name}

+

{dict.profile.tagline}

+
+ ), +})); + +vi.mock("../share-section", () => ({ + ShareSection: ({ + shareTitle, + title, + url, + }: { + shareTitle: string; + title: string; + url: string; + }) => ( +
+ {shareTitle}: {title} ({url}) +
+ ), +})); + +import { + TutorialComplete, + type TutorialCompleteProps, +} from "./tutorial-complete"; + +const labels = { + backToTutorials: "Back to Tutorials", + profileName: "Jane Doe", + profileTagline: "React Developer", + relatedContent: "Continue Learning", + reviewSections: "Review Sections", + shareOn: "Share on", + shareTitle: "Share this tutorial", + startOver: "Start Over", + tutorialComplete: "Tutorial Complete!", + tutorialFinished: "Tutorial Finished", + youveCompletedAll: "You've completed all sections of", + youveFinishedWith: "You've finished with", +}; + +const sections = [ + { id: "intro", title: "Introduction" }, + { id: "advanced", title: "Advanced Concepts" }, +]; + +function TestLink({ + children, + className, + href, +}: { + children: ReactNode; + className?: string; + href: string; +}) { + return ( + + {children} + + ); +} + +function renderTutorialComplete(props: Partial = {}) { + const onGoToSection = vi.fn(); + const onRestart = vi.fn(); + render( + , + ); + + return { onGoToSection, onRestart }; +} + +describe("TutorialComplete", () => { + it("renders the full completion state and restarts", () => { + const { onRestart } = renderTutorialComplete(); + + expect( + screen.getByRole("heading", { name: "Tutorial Complete!" }), + ).toBeInTheDocument(); + expect( + screen.getByText('You\'ve completed all sections of "React Basics"'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Start Over" })); + + expect(onRestart).toHaveBeenCalledTimes(1); + }); + + it("renders partial completion copy", () => { + renderTutorialComplete({ + completedSections: new Set(["intro"]), + completionPercent: 50, + }); + + expect( + screen.getByRole("heading", { name: "Tutorial Finished" }), + ).toBeInTheDocument(); + expect( + screen.getByText('You\'ve finished with "React Basics" (50%)'), + ).toBeInTheDocument(); + }); + + it("navigates to review sections by index", () => { + const { onGoToSection } = renderTutorialComplete(); + + fireEvent.click(screen.getByRole("button", { name: /advanced concepts/i })); + + expect(onGoToSection).toHaveBeenCalledWith(1); + }); + + it("renders related links, share content, profile, and back link", () => { + renderTutorialComplete({ + profile: { + imageSource: "/profile.png", + socialLinks: [{ href: "https://example.com", label: "Website" }], + }, + }); + + expect( + screen.getByRole("link", { name: /tutorial next tutorial/i }), + ).toHaveAttribute("href", "/next"); + expect(screen.getByLabelText("share")).toHaveTextContent( + "Share this tutorial: React Basics (https://example.com/tutorial)", + ); + expect(screen.getByLabelText("profile")).toHaveTextContent("Jane Doe"); + expect( + screen.getByRole("link", { name: "← Back to Tutorials" }), + ).toHaveAttribute("href", "/tutorials"); + }); +});