From def71f08605b4ae7fd31dc9f28c5a23fcf90e022 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 13 Jan 2026 09:47:05 +1300 Subject: [PATCH] test(17-02): add OutputNode component tests - Add 31 tests covering all OutputNode functionality - Test empty state rendering with placeholder - Test image content display with correct src - Test all video detection paths: - data.video present - contentType === "video" - data.image starts with "data:video/" - data.image contains ".mp4" or ".webm" - Test video controls (controls, loop, muted, autoPlay, playsInline) - Test lightbox open/close functionality - Test download for data URL and HTTP URL content - Test custom title display and editing Co-Authored-By: Claude Opus 4.5 --- src/components/__tests__/OutputNode.test.tsx | 556 +++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 src/components/__tests__/OutputNode.test.tsx diff --git a/src/components/__tests__/OutputNode.test.tsx b/src/components/__tests__/OutputNode.test.tsx new file mode 100644 index 00000000..c79a18e5 --- /dev/null +++ b/src/components/__tests__/OutputNode.test.tsx @@ -0,0 +1,556 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { OutputNode } from "@/components/nodes/OutputNode"; +import { ReactFlowProvider } from "@xyflow/react"; + +// Mock the workflow store +const mockUpdateNodeData = vi.fn(); +const mockUseWorkflowStore = vi.fn(); + +vi.mock("@/store/workflowStore", () => ({ + useWorkflowStore: (selector: (state: unknown) => unknown) => mockUseWorkflowStore(selector), +})); + +// Mock useReactFlow +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + getNodes: vi.fn(() => []), + setNodes: vi.fn(), + }), + }; +}); + +// Mock URL.createObjectURL and URL.revokeObjectURL +const mockCreateObjectURL = vi.fn(() => "blob:test-url"); +const mockRevokeObjectURL = vi.fn(); + +// Wrapper component for React Flow context +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("OutputNode", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementation + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + currentNodeId: null, + groups: {}, + nodes: [], + }; + return selector(state); + }); + + // Mock URL methods + global.URL.createObjectURL = mockCreateObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL; + }); + + const createNodeProps = (data: Partial<{ + image: string | null; + video?: string | null; + contentType?: "image" | "video"; + customTitle?: string; + comment?: string; + }> = {}) => ({ + id: "output-node-1", + type: "output" as const, + data: { + image: null, + ...data, + }, + selected: false, + }); + + describe("Empty State Rendering", () => { + it("should render empty state placeholder when no content", () => { + render( + + + + ); + + expect(screen.getByText("Waiting for image or video")).toBeInTheDocument(); + }); + + it("should render the title 'Output'", () => { + render( + + + + ); + + expect(screen.getByText("Output")).toBeInTheDocument(); + }); + + it("should not render download button when no content", () => { + render( + + + + ); + + expect(screen.queryByText("Download")).not.toBeInTheDocument(); + }); + + it("should render input handle for image", () => { + const { container } = render( + + + + ); + + const handle = container.querySelector('[data-handletype="image"]'); + expect(handle).toBeInTheDocument(); + }); + }); + + describe("Image Content Display", () => { + it("should render image element when data.image is set", () => { + render( + + + + ); + + const img = screen.getByAltText("Output"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "data:image/png;base64,abc123"); + }); + + it("should not show waiting message when image is present", () => { + render( + + + + ); + + expect(screen.queryByText("Waiting for image or video")).not.toBeInTheDocument(); + }); + + it("should render download button when image is present", () => { + render( + + + + ); + + expect(screen.getByText("Download")).toBeInTheDocument(); + }); + }); + + describe("Video Detection Logic", () => { + it("should detect video when data.video is present", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + expect(video).toHaveAttribute("src", "data:video/mp4;base64,xyz789"); + }); + + it("should detect video when data.contentType is 'video'", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + }); + + it("should detect video when data.image starts with 'data:video/'", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + expect(video).toHaveAttribute("src", "data:video/mp4;base64,abc123"); + }); + + it("should detect video when data.image contains '.mp4'", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + }); + + it("should detect video when data.image contains '.webm'", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + }); + + it("should render image when no video indicators are present", () => { + render( + + + + ); + + const img = screen.getByAltText("Output"); + expect(img).toBeInTheDocument(); + expect(document.querySelector("video")).not.toBeInTheDocument(); + }); + + it("should prioritize data.video over data.image", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toBeInTheDocument(); + expect(video).toHaveAttribute("src", "data:video/mp4;base64,video123"); + }); + }); + + describe("Video Controls Rendering", () => { + it("should render video with controls attribute", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toHaveAttribute("controls"); + }); + + it("should render video with loop attribute", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toHaveAttribute("loop"); + }); + + it("should render video with muted attribute", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video?.muted).toBe(true); + }); + + it("should render video with autoPlay attribute", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video?.autoplay).toBe(true); + }); + + it("should render video with playsInline attribute", () => { + render( + + + + ); + + const video = document.querySelector("video"); + expect(video).toHaveAttribute("playsinline"); + }); + }); + + describe("Lightbox Functionality", () => { + it("should open lightbox when image is clicked", () => { + render( + + + + ); + + const img = screen.getByAltText("Output"); + const clickableArea = img.closest(".cursor-pointer"); + expect(clickableArea).toBeInTheDocument(); + + fireEvent.click(clickableArea!); + + // Lightbox should be visible with full size image + expect(screen.getByAltText("Output full size")).toBeInTheDocument(); + }); + + it("should close lightbox when clicking outside", () => { + render( + + + + ); + + // Open lightbox + const img = screen.getByAltText("Output"); + const clickableArea = img.closest(".cursor-pointer"); + fireEvent.click(clickableArea!); + + // Lightbox should be open + expect(screen.getByAltText("Output full size")).toBeInTheDocument(); + + // Click the backdrop (fixed overlay) + const backdrop = document.querySelector(".fixed.inset-0"); + expect(backdrop).toBeInTheDocument(); + fireEvent.click(backdrop!); + + // Lightbox should be closed + expect(screen.queryByAltText("Output full size")).not.toBeInTheDocument(); + }); + + it("should close lightbox when clicking close button", () => { + render( + + + + ); + + // Open lightbox + const img = screen.getByAltText("Output"); + const clickableArea = img.closest(".cursor-pointer"); + fireEvent.click(clickableArea!); + + // Find and click close button (it's the button with X icon inside the lightbox) + const closeButton = document.querySelector(".fixed.inset-0 button"); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton!); + + // Lightbox should be closed + expect(screen.queryByAltText("Output full size")).not.toBeInTheDocument(); + }); + + it("should show video in lightbox when video content is clicked", () => { + render( + + + + ); + + // Find the clickable area (parent of video) + const video = document.querySelector("video"); + const clickableArea = video?.closest(".cursor-pointer"); + expect(clickableArea).toBeInTheDocument(); + + fireEvent.click(clickableArea!); + + // Lightbox should have a video element + const lightboxVideos = document.querySelectorAll("video"); + // There should now be 2 videos - one in node, one in lightbox + expect(lightboxVideos.length).toBe(2); + }); + + it("should not close lightbox when clicking on video controls", () => { + render( + + + + ); + + // Open lightbox + const video = document.querySelector("video"); + const clickableArea = video?.closest(".cursor-pointer"); + fireEvent.click(clickableArea!); + + // Click directly on video (should stopPropagation) + const lightboxVideo = document.querySelectorAll("video")[1]; + fireEvent.click(lightboxVideo); + + // Lightbox should still be open (2 videos present) + const allVideos = document.querySelectorAll("video"); + expect(allVideos.length).toBe(2); + }); + }); + + describe("Download Functionality", () => { + let anchorClicks: { href: string; download: string }[] = []; + let originalCreateElement: typeof document.createElement; + + beforeEach(() => { + anchorClicks = []; + originalCreateElement = document.createElement.bind(document); + + // Spy on document.createElement to capture anchor properties before click + vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => { + const element = originalCreateElement(tagName, options); + if (tagName === "a") { + const originalClick = element.click.bind(element); + element.click = () => { + anchorClicks.push({ + href: (element as HTMLAnchorElement).href, + download: (element as HTMLAnchorElement).download, + }); + // Don't call original click to avoid navigation issues in tests + }; + } + return element; + }); + }); + + afterEach(() => { + vi.mocked(document.createElement).mockRestore(); + }); + + it("should trigger download for data URL content", async () => { + render( + + + + ); + + const downloadButton = screen.getByText("Download"); + fireEvent.click(downloadButton); + + await waitFor(() => { + expect(anchorClicks.length).toBe(1); + expect(anchorClicks[0].href).toContain("data:image/png;base64,abc123"); + }); + }); + + it("should fetch and download for URL-based content", async () => { + const mockBlob = new Blob(["test"], { type: "image/png" }); + const mockFetch = vi.fn().mockResolvedValue({ + blob: () => Promise.resolve(mockBlob), + }); + global.fetch = mockFetch; + + render( + + + + ); + + const downloadButton = screen.getByText("Download"); + fireEvent.click(downloadButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("https://example.com/image.png"); + }); + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob); + }); + + await waitFor(() => { + expect(anchorClicks.length).toBe(1); + expect(anchorClicks[0].href).toBe("blob:test-url"); + }); + }); + + it("should use .mp4 extension for video downloads", async () => { + render( + + + + ); + + const downloadButton = screen.getByText("Download"); + fireEvent.click(downloadButton); + + await waitFor(() => { + expect(anchorClicks.length).toBe(1); + expect(anchorClicks[0].download).toMatch(/\.mp4$/); + }); + }); + + it("should use .png extension for image downloads", async () => { + render( + + + + ); + + const downloadButton = screen.getByText("Download"); + fireEvent.click(downloadButton); + + await waitFor(() => { + expect(anchorClicks.length).toBe(1); + expect(anchorClicks[0].download).toMatch(/\.png$/); + }); + }); + + it("should revoke blob URL after download for HTTP content", async () => { + const mockBlob = new Blob(["test"], { type: "image/png" }); + global.fetch = vi.fn().mockResolvedValue({ + blob: () => Promise.resolve(mockBlob), + }); + + render( + + + + ); + + const downloadButton = screen.getByText("Download"); + fireEvent.click(downloadButton); + + await waitFor(() => { + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:test-url"); + }); + }); + }); + + describe("Custom Title and Comment", () => { + it("should display custom title when provided", () => { + render( + + + + ); + + expect(screen.getByText("My Output - Output")).toBeInTheDocument(); + }); + + it("should call updateNodeData when custom title is changed", () => { + render( + + + + ); + + // Click on title to edit + const title = screen.getByText("Output"); + fireEvent.click(title); + + // Type new title + const input = screen.getByPlaceholderText("Custom title..."); + fireEvent.change(input, { target: { value: "New Title" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mockUpdateNodeData).toHaveBeenCalledWith("output-node-1", { customTitle: "New Title" }); + }); + }); +});