diff --git a/packages/ui/src/components/mdx-content/mdx-content.test.tsx b/packages/ui/src/components/mdx-content/mdx-content.test.tsx new file mode 100644 index 00000000..1f8f20b6 --- /dev/null +++ b/packages/ui/src/components/mdx-content/mdx-content.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import type React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MDXContent } from "./mdx-content"; + +function Note({ children }: { children: React.ReactNode }) { + return ; +} + +describe("MDXContent", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders markdown headings, links, and list items", async () => { + const node = await MDXContent({ + content: + "## Getting started\n\nRead the [docs](https://example.com).\n\n- Install\n- Ship", + }); + + render(node); + + expect( + screen.getByRole("heading", { name: "Getting started" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "docs" })).toHaveAttribute( + "href", + "https://example.com", + ); + expect(screen.getByText("Install")).toBeInTheDocument(); + expect(screen.getByText("Ship")).toBeInTheDocument(); + }); + + it("strips injected component imports before rendering MDX", async () => { + const node = await MDXContent({ + components: { Note }, + content: + 'import { Note } from "./note";\n\nImported component content', + }); + + render(node); + + expect(screen.getByText("Imported component content")).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent( + 'import { Note } from "./note"', + ); + }); + + it("falls back to markdown when MDX evaluation fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => null); + const node = await MDXContent({ + content: "## Fallback content\n\n> = {}, +) { + const props = { + models, + onOpenChange: vi.fn(), + onSelectModel: vi.fn(), + open: true, + selectedModelId: "google/gemini-1.5-pro", + ...overrides, + }; + + render(); + + return props; +} + +describe("ModelSelector", () => { + it("renders the open dialog with selected model promoted and disabled", () => { + renderModelSelector(); + + expect(screen.getByText("Select Model")).toBeInTheDocument(); + expect(screen.getByText("Selected")).toBeInTheDocument(); + expect(getModelItem("Gemini 1.5 Pro")).toHaveAttribute( + "aria-disabled", + "true", + ); + expect(screen.getByText("In: $2.50/1M")).toBeInTheDocument(); + expect(screen.getByText("Out: $10.00/1M")).toBeInTheDocument(); + }); + + it("filters models by search text across provider metadata", () => { + renderModelSelector(); + + fireEvent.change( + screen.getByPlaceholderText("Search models or providers..."), + { + target: { value: "anthropic" }, + }, + ); + + expect(screen.getByText("Claude 3.5 Sonnet")).toBeInTheDocument(); + expect(screen.queryByText("GPT-4o")).not.toBeInTheDocument(); + expect(screen.queryByText("Gemini 1.5 Pro")).not.toBeInTheDocument(); + }); + + it("selects a non-current model and closes the dialog", () => { + const onOpenChange = vi.fn(); + const onSelectModel = vi.fn(); + renderModelSelector({ onOpenChange, onSelectModel }); + + fireEvent.click(getModelItem("Claude 3.5 Sonnet")); + + expect(onSelectModel).toHaveBeenCalledWith("anthropic/claude-3-5-sonnet"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/ui/src/components/slideshow/slideshow.test.tsx b/packages/ui/src/components/slideshow/slideshow.test.tsx new file mode 100644 index 00000000..2db5d6e2 --- /dev/null +++ b/packages/ui/src/components/slideshow/slideshow.test.tsx @@ -0,0 +1,104 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Slideshow, type SlideshowProps } from "./slideshow"; + +const sections = [ + { id: "intro", title: "Intro" }, + { id: "setup", title: "Setup" }, + { id: "finish", title: "Finish" }, +]; + +function renderSlideshow(overrides: Partial = {}) { + const props = { + completedSections: new Set(), + currentIndex: 0, + onComplete: vi.fn(), + onExit: vi.fn(), + onNavigate: vi.fn(), + onToggleSection: vi.fn(), + renderContent: (section: { title: string }) => ( +
{section.title} body
+ ), + sections, + title: "Tutorial", + ...overrides, + }; + + const view = render(); + + return { props, view }; +} + +function advanceNavigationTimer() { + act(() => { + vi.advanceTimersByTime(150); + }); +} + +describe("Slideshow", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.style.overflow = ""; + }); + + it("renders in a portal and restores body scroll lock on cleanup", () => { + const { view } = renderSlideshow(); + + expect(screen.getByText("Tutorial")).toBeInTheDocument(); + expect(screen.getByText("Intro body")).toBeInTheDocument(); + expect(document.body.style.overflow).toBe("hidden"); + + view.unmount(); + + expect(document.body.style.overflow).toBe(""); + }); + + it("opens the completion dialog before navigating an incomplete section", () => { + const { props } = renderSlideshow(); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + + expect( + screen.getByRole("dialog", { name: "Mark section as complete?" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^done/i })); + advanceNavigationTimer(); + + expect(props.onToggleSection).toHaveBeenCalledWith("intro"); + expect(props.onNavigate).toHaveBeenCalledWith(1); + }); + + it("navigates from the table of contents after the transition delay", () => { + const { props } = renderSlideshow(); + + fireEvent.click( + screen.getByRole("button", { name: "Open table of contents" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Setup" })); + advanceNavigationTimer(); + + expect(props.onNavigate).toHaveBeenCalledWith(1); + expect( + screen.getByRole("button", { name: "Open table of contents" }), + ).toBeInTheDocument(); + }); + + it("handles keyboard exit and next navigation shortcuts", () => { + const { props } = renderSlideshow({ + completedSections: new Set(["intro"]), + }); + + fireEvent.keyDown(document, { key: "ArrowRight" }); + advanceNavigationTimer(); + expect(props.onNavigate).toHaveBeenCalledWith(1); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(props.onExit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/social-fab/social-fab.test.tsx b/packages/ui/src/components/social-fab/social-fab.test.tsx new file mode 100644 index 00000000..2b415185 --- /dev/null +++ b/packages/ui/src/components/social-fab/social-fab.test.tsx @@ -0,0 +1,129 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SocialFAB, type SocialFabProps } from "./social-fab"; + +const originalInnerWidth = window.innerWidth; + +const labels = { + close: "Close social actions", + share: "Share", +}; + +const actions = [ + { id: "share", label: "Share", onClick: vi.fn() }, + { id: "copy", label: "Copy link", onClick: vi.fn() }, +]; + +function setViewportWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); +} + +function renderSocialFab(overrides: Partial = {}) { + return render( + , + ); +} + +function getMainButton(name: string): HTMLElement { + const buttons = screen.getAllByRole("button", { name }); + const button = buttons.at(-1); + if (!button) throw new Error(`Expected main button named ${name}`); + return button; +} + +function getFirstButton(name: string): HTMLElement { + const button = screen.getAllByRole("button", { name }).at(0); + if (!button) throw new Error(`Expected button named ${name}`); + return button; +} + +describe("SocialFAB", () => { + afterEach(() => { + vi.restoreAllMocks(); + setViewportWidth(originalInnerWidth); + }); + + it("renders nothing when hidden", () => { + const { container } = renderSocialFab({ hidden: true }); + + expect(container.firstChild).toBeNull(); + }); + + it("opens and closes from desktop hover callbacks", () => { + const onClose = vi.fn(); + const onOpen = vi.fn(); + const { container } = renderSocialFab({ onClose, onOpen }); + const wrapper = container.querySelector(".fixed"); + if (!wrapper) throw new Error("Expected fixed FAB wrapper"); + + fireEvent.mouseEnter(wrapper); + expect( + screen.getByRole("button", { name: "Close social actions" }), + ).toHaveAttribute("aria-expanded", "true"); + expect(onOpen).toHaveBeenCalledWith("hover", "desktop"); + + fireEvent.mouseLeave(wrapper); + expect(getMainButton("Share")).toHaveAttribute("aria-expanded", "false"); + expect(onClose).toHaveBeenCalledWith("hover_leave"); + }); + + it("uses a mobile backdrop to close expanded actions", () => { + setViewportWidth(390); + const onClose = vi.fn(); + const onOpen = vi.fn(); + renderSocialFab({ onClose, onOpen }); + + fireEvent.click(getMainButton("Share")); + + expect( + screen.getByRole("button", { name: "Close social actions" }), + ).toHaveAttribute("aria-expanded", "true"); + expect(onOpen).toHaveBeenCalledWith("tap", "mobile"); + + const backdrop = document.body.querySelector("[aria-hidden='true']"); + if (!backdrop) throw new Error("Expected mobile backdrop"); + fireEvent.click(backdrop); + + expect(getMainButton("Share")).toHaveAttribute("aria-expanded", "false"); + expect(onClose).toHaveBeenCalledWith("backdrop"); + }); + + it("opens share platforms on desktop hover and launches selected share URL", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + const onAction = vi.fn(); + renderSocialFab({ + onAction, + sharePlatforms: [ + { + buildUrl: (pageUrl, pageTitle) => + `https://share.example/?url=${pageUrl}&title=${pageTitle}`, + key: "example", + label: "Example Network", + }, + ], + }); + + fireEvent.click(getMainButton("Share")); + fireEvent.mouseEnter(getFirstButton("Share")); + fireEvent.click(screen.getByRole("button", { name: "Example Network" })); + + expect(onAction).toHaveBeenCalledWith("share"); + expect(open).toHaveBeenCalledWith( + expect.stringContaining("https://share.example/"), + "_blank", + "noopener,noreferrer", + ); + }); +});