diff --git a/packages/ui/src/components/flow-diagram/flow-diagram.test.tsx b/packages/ui/src/components/flow-diagram/flow-diagram.test.tsx new file mode 100644 index 00000000..b0d724f6 --- /dev/null +++ b/packages/ui/src/components/flow-diagram/flow-diagram.test.tsx @@ -0,0 +1,334 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { FlowDiagram } from "./flow-diagram"; +import type { FlowDiagramEdge, FlowDiagramNode } from "./types"; + +type MockFlowNode = { + data: { + description?: string; + label: string; + }; + id: string; + position?: { + x: number; + y: number; + }; +}; + +type MockFlowEdge = { + id: string; + source: string; + target: string; +}; + +type MockReactFlowProps = { + children?: React.ReactNode; + colorMode?: string; + edges: MockFlowEdge[]; + nodes: MockFlowNode[]; + onNodeClick?: (event: React.MouseEvent, node: MockFlowNode) => void; +}; + +const flowRuntime = vi.hoisted(() => { + type RuntimeNode = { + data: { + description?: string; + label: string; + }; + id: string; + position?: { + x: number; + y: number; + }; + }; + + const currentNodes: RuntimeNode[] = []; + + return { + clipboardWrite: vi.fn(() => Promise.resolve()), + currentNodes, + fetchImage: vi.fn((input: string) => + Promise.resolve( + new Response(new Blob([input], { type: "image/png" }), { + status: 200, + }), + ), + ), + fitView: vi.fn(() => Promise.resolve()), + getNodesBounds: vi.fn(() => ({ + height: 80, + width: 120, + x: 0, + y: 0, + })), + getViewport: vi.fn(() => ({ + x: 0, + y: 0, + zoom: 1, + })), + getViewportForBounds: vi.fn(() => ({ + x: 10, + y: 20, + zoom: 1.25, + })), + toPng: vi.fn(() => Promise.resolve("data:image/png;base64,diagram")), + zoomTo: vi.fn(() => Promise.resolve()), + }; +}); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "light" }), +})); + +vi.mock("html-to-image", () => ({ + toPng: flowRuntime.toPng, +})); + +vi.mock("@xyflow/react", () => ({ + Background: () =>
, + BackgroundVariant: { Dots: "dots" }, + getNodesBounds: flowRuntime.getNodesBounds, + getViewportForBounds: flowRuntime.getViewportForBounds, + ReactFlow: ({ + children, + colorMode, + edges, + nodes, + onNodeClick, + }: MockReactFlowProps) => { + const firstNode = nodes[0]; + const handleNodeClick = (event: React.MouseEvent) => { + if (firstNode) onNodeClick?.(event, firstNode); + }; + + return ( +
+ + {children} +
+ ); + }, + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + useReactFlow: () => ({ + fitView: flowRuntime.fitView, + getNodes: () => flowRuntime.currentNodes, + getViewport: flowRuntime.getViewport, + zoomTo: flowRuntime.zoomTo, + }), +})); + +class TestClipboardItem { + readonly items: Record; + + constructor(items: Record) { + this.items = items; + } +} + +const nodes: FlowDiagramNode[] = [ + { + data: { description: "Entry point", label: "Start" }, + id: "start", + position: { x: 0, y: 0 }, + }, + { + data: { description: "Exit point", label: "End" }, + id: "end", + position: { x: 220, y: 0 }, + }, +]; + +const edges: FlowDiagramEdge[] = [ + { id: "start-end", source: "start", target: "end" }, +]; + +function setRuntimeNodes(nextNodes: FlowDiagramNode[]) { + flowRuntime.currentNodes.splice( + 0, + flowRuntime.currentNodes.length, + ...nextNodes, + ); +} + +function getReactFlowParent(): HTMLElement { + const parent = screen.getByTestId("react-flow").parentElement; + if (!parent) throw new Error("Expected ReactFlow parent element"); + return parent; +} + +describe("FlowDiagram", () => { + beforeEach(() => { + setRuntimeNodes(nodes); + flowRuntime.clipboardWrite.mockReset(); + flowRuntime.clipboardWrite.mockResolvedValue(); + flowRuntime.fetchImage.mockClear(); + flowRuntime.fitView.mockClear(); + flowRuntime.getNodesBounds.mockClear(); + flowRuntime.getViewport.mockClear(); + flowRuntime.getViewportForBounds.mockClear(); + flowRuntime.toPng.mockClear(); + flowRuntime.toPng.mockResolvedValue("data:image/png;base64,diagram"); + flowRuntime.zoomTo.mockClear(); + + vi.stubGlobal("ClipboardItem", TestClipboardItem); + vi.stubGlobal("fetch", flowRuntime.fetchImage); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + window.setTimeout(() => { + callback(0); + }, 0), + ); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + window.clearTimeout(id); + }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { write: flowRuntime.clipboardWrite }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.style.overflow = ""; + }); + + it("renders title, canvas sizing, controls, and graph data", () => { + render( + , + ); + + expect(screen.getByText("Pipeline")).toBeInTheDocument(); + expect(screen.getByTestId("react-flow")).toHaveAttribute( + "data-node-count", + "2", + ); + expect(screen.getByTestId("react-flow")).toHaveAttribute( + "data-edge-count", + "1", + ); + expect(getReactFlowParent()).toHaveStyle({ height: "320px" }); + expect(screen.getByLabelText("Zoom in")).toBeInTheDocument(); + expect(screen.getByLabelText("Zoom out")).toBeInTheDocument(); + expect(screen.getByLabelText("Fit view")).toBeInTheDocument(); + expect(screen.getByLabelText("Copy as image")).toBeInTheDocument(); + expect(screen.getByLabelText("Fullscreen")).toBeInTheDocument(); + }); + + it("hides controls when showControls is false", () => { + render( + , + ); + + expect(screen.getByText("No controls")).toBeInTheDocument(); + expect(screen.queryByLabelText("Zoom in")).not.toBeInTheDocument(); + }); + + it("warns for invalid flow data", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => null); + + render( + , + ); + + expect(warn).toHaveBeenCalledWith( + "[FlowDiagram] 1 edge(s) reference non-existent nodes:", + ["missing: start -> missing"], + ); + expect(warn).toHaveBeenCalledWith( + "[FlowDiagram] 1 node(s) missing position:", + ["start"], + ); + }); + + it("wires zoom, fit view, and node click handlers", () => { + const onNodeClick = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByLabelText("Zoom in")); + fireEvent.click(screen.getByLabelText("Fit view")); + fireEvent.click(screen.getByText("Mock first node")); + + expect(flowRuntime.zoomTo).toHaveBeenCalledWith(1.2, { duration: 200 }); + expect(flowRuntime.fitView).toHaveBeenCalledWith({ + duration: 200, + padding: 0.2, + }); + expect(onNodeClick).toHaveBeenCalledWith(nodes[0]); + }); + + it("opens fullscreen mode and closes it from the portal control", () => { + render(); + + fireEvent.click(screen.getByLabelText("Fullscreen")); + + expect( + screen.getByRole("dialog", { name: "Flow diagram fullscreen view" }), + ).toBeInTheDocument(); + expect(document.body.style.overflow).toBe("hidden"); + + fireEvent.click(screen.getByLabelText("Close fullscreen")); + + expect( + screen.queryByRole("dialog", { name: "Flow diagram fullscreen view" }), + ).not.toBeInTheDocument(); + expect(document.body.style.overflow).toBe(""); + }); + + it("copies the rendered flow image to the clipboard", async () => { + render(); + + fireEvent.click(screen.getByLabelText("Copy as image")); + + await waitFor(() => { + expect(flowRuntime.clipboardWrite).toHaveBeenCalledTimes(1); + }); + expect(flowRuntime.getNodesBounds).toHaveBeenCalledWith(nodes); + expect(flowRuntime.getViewportForBounds).toHaveBeenCalledWith( + { height: 80, width: 120, x: 0, y: 0 }, + 1024, + 768, + 0.5, + 2, + 0.2, + ); + expect(flowRuntime.toPng).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ + backgroundColor: "white", + height: 768, + width: 1024, + }), + ); + expect(screen.getByLabelText("Copied!")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/tutorial-mdx/tutorial-mdx.test.tsx b/packages/ui/src/components/tutorial-mdx/tutorial-mdx.test.tsx new file mode 100644 index 00000000..3829b925 --- /dev/null +++ b/packages/ui/src/components/tutorial-mdx/tutorial-mdx.test.tsx @@ -0,0 +1,84 @@ +import { act, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { mdxComponents, TutorialMDX } from "./tutorial-mdx"; + +vi.mock("../flow-diagram", () => ({ + FlowDiagram: ({ title }: { title?: string }) => ( +
{title ?? "Flow diagram"}
+ ), +})); + +describe("TutorialMDX", () => { + it("renders plain markdown with styled headings, links, lists, and code", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toHaveClass("tutorial-copy"); + expect(screen.getByRole("heading", { name: "Intro" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "guide" })).toHaveAttribute( + "href", + "https://example.com", + ); + expect(screen.getByText("pnpm test")).toBeInTheDocument(); + expect(screen.getByText("Learn")).toBeInTheDocument(); + expect(screen.getByText("Verify")).toBeInTheDocument(); + }); + + it("does not treat JSX-like text inside fenced code as MDX", () => { + const { container } = render( + Example\n```"} />, + ); + const codeBlock = container.querySelector("pre"); + if (!codeBlock) throw new Error("Expected fenced code block"); + + expect(codeBlock).toHaveTextContent("Example"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders tutorial MDX components through the component map", async () => { + await act(async () => { + render( + , + ); + }); + + expect(await screen.findByRole("alert")).toHaveTextContent("Heads up"); + expect(screen.getByText("Remember the flow.")).toBeInTheDocument(); + }); + + it("renders the lazy FlowDiagram mapping from MDX content", async () => { + await act(async () => { + render( + , + ); + }); + + expect(await screen.findByTestId("mock-flow-diagram")).toHaveTextContent( + "Architecture map", + ); + }); + + it("exports the expected tutorial component mappings", () => { + expect(Object.keys(mdxComponents)).toEqual( + expect.arrayContaining([ + "Callout", + "FlowDiagram", + "Quiz", + "Step", + "StepByStep", + ]), + ); + }); +});