Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
def71f0860
  1. 556
      src/components/__tests__/OutputNode.test.tsx

556
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 <ReactFlowProvider>{children}</ReactFlowProvider>;
}
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(
<TestWrapper>
<OutputNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Waiting for image or video")).toBeInTheDocument();
});
it("should render the title 'Output'", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Output")).toBeInTheDocument();
});
it("should not render download button when no content", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.queryByText("Download")).not.toBeInTheDocument();
});
it("should render input handle for image", () => {
const { container } = render(
<TestWrapper>
<OutputNode {...createNodeProps()} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
expect(screen.queryByText("Waiting for image or video")).not.toBeInTheDocument();
});
it("should render download button when image is present", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
expect(screen.getByText("Download")).toBeInTheDocument();
});
});
describe("Video Detection Logic", () => {
it("should detect video when data.video is present", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz789" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({
image: "https://example.com/video.mp4",
contentType: "video"
})} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video).toBeInTheDocument();
});
it("should detect video when data.image starts with 'data:video/'", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:video/mp4;base64,abc123" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "https://example.com/video.mp4" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video).toBeInTheDocument();
});
it("should detect video when data.image contains '.webm'", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "https://example.com/video.webm" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video).toBeInTheDocument();
});
it("should render image when no video indicators are present", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "https://example.com/image.png" })} />
</TestWrapper>
);
const img = screen.getByAltText("Output");
expect(img).toBeInTheDocument();
expect(document.querySelector("video")).not.toBeInTheDocument();
});
it("should prioritize data.video over data.image", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({
video: "data:video/mp4;base64,video123",
image: "data:image/png;base64,image456"
})} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video).toHaveAttribute("controls");
});
it("should render video with loop attribute", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video).toHaveAttribute("loop");
});
it("should render video with muted attribute", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video?.muted).toBe(true);
});
it("should render video with autoPlay attribute", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video?.autoplay).toBe(true);
});
it("should render video with playsInline attribute", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
const video = document.querySelector("video");
expect(video).toHaveAttribute("playsinline");
});
});
describe("Lightbox Functionality", () => {
it("should open lightbox when image is clicked", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
// 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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
// 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(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
// 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(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
// 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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "https://example.com/image.png" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ video: "data:video/mp4;base64,xyz" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "data:image/png;base64,abc" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ image: "https://example.com/image.png" })} />
</TestWrapper>
);
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(
<TestWrapper>
<OutputNode {...createNodeProps({ customTitle: "My Output", image: null })} />
</TestWrapper>
);
expect(screen.getByText("My Output - Output")).toBeInTheDocument();
});
it("should call updateNodeData when custom title is changed", () => {
render(
<TestWrapper>
<OutputNode {...createNodeProps({ image: null })} />
</TestWrapper>
);
// 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" });
});
});
});
Loading…
Cancel
Save