Files
roboco/panel/src/components/tasks/task-detail/__tests__/video-preview-card.test.tsx
T
a5d8c6bd5b feat(video): CEO can preview a video authoring task's frames before approving (#608)
A source=video authoring task reaches awaiting_ceo_approval with no MP4
yet — rendering only happens after it completes — so the CEO had nothing
to review. Two CEO-gated routes now serve the request_render preview
frames: GET /video/preview-frames/{task_id} lists them per orientation
(parsed from the self-describing .previews/{task8}/{orientation}/ filenames
rather than the render_preview marker, which only holds the last call's
single orientation), and .../{orientation}/{filename} streams a frame's
PNG behind the existing path-confinement guard. The task-detail Overview
gains a Video preview card — a 9:16/1:1 toggle + prev/next/scrubber frame
stepper with composition id, duration, and a dirty badge — shown for a
video task with preview frames or awaiting CEO approval.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 10:51:43 +02:00

163 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import type { VideoPreviewFrames } from "@/lib/api/video";
const { getPreviewFrames, getPreviewFrameBlob } = vi.hoisted(() => ({
getPreviewFrames: vi.fn<() => Promise<VideoPreviewFrames>>(),
getPreviewFrameBlob: vi.fn(
async () => new Blob(["fake-png-bytes"], { type: "image/png" }),
),
}));
vi.mock("@/lib/api", () => ({
videoApi: { getPreviewFrames, getPreviewFrameBlob },
}));
import { VideoPreviewCard } from "../video-preview-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "task-1",
title: "Video: launch teaser",
description: "d",
status: TaskStatus.AWAITING_CEO_APPROVAL,
team: Team.UX_UI,
task_type: TaskType.CODE,
acceptance_criteria: [],
source: "video",
...overrides,
} as unknown as Task;
}
const FRAMES: VideoPreviewFrames = {
task_id: "task-1",
composition_id: "Intro",
duration_seconds: 6.4,
head_sha: "abc1234",
dirty: false,
rendered_at: "2026-07-19T12:00:00Z",
frames: {
vertical: [
{ index: 1, file: "frame-01-of-2-at-1.5s.png", timestamp_seconds: 1.5 },
{ index: 2, file: "frame-02-of-2-at-4.5s.png", timestamp_seconds: 4.5 },
],
square: [
{ index: 1, file: "frame-01-of-1-at-3.0s.png", timestamp_seconds: 3.0 },
],
},
};
describe("VideoPreviewCard", () => {
beforeEach(() => {
getPreviewFrames.mockReset();
getPreviewFrameBlob.mockClear();
let objectUrlCount = 0;
globalThis.URL.createObjectURL = vi.fn(
() => `blob:mock-url-${++objectUrlCount}`,
);
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders composition metadata and blob-fetches the first frame of the default cut", async () => {
getPreviewFrames.mockResolvedValueOnce(FRAMES);
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
expect(await screen.findByText("Intro")).toBeInTheDocument();
expect(screen.getByText("6.4s clip")).toBeInTheDocument();
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"vertical",
"frame-01-of-2-at-1.5s.png",
),
);
expect(screen.getByText(/Frame 1\/2/)).toBeInTheDocument();
expect(screen.getByText(/1\.5s into the clip/)).toBeInTheDocument();
});
it("steps to the next frame within a cut", async () => {
getPreviewFrames.mockResolvedValueOnce(FRAMES);
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
await screen.findByText("Intro");
await waitFor(() => expect(getPreviewFrameBlob).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText("Previous frame")).toBeDisabled();
fireEvent.click(screen.getByLabelText("Next frame"));
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"vertical",
"frame-02-of-2-at-4.5s.png",
),
);
expect(screen.getByText(/Frame 2\/2/)).toBeInTheDocument();
expect(screen.getByText(/4\.5s into the clip/)).toBeInTheDocument();
expect(screen.getByLabelText("Next frame")).toBeDisabled();
});
it("switches cuts via the 9:16/1:1 toggle and re-fetches that orientation's first frame", async () => {
getPreviewFrames.mockResolvedValueOnce(FRAMES);
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
await screen.findByText("Intro");
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"vertical",
"frame-01-of-2-at-1.5s.png",
),
);
fireEvent.click(screen.getByRole("button", { name: "1:1" }));
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"square",
"frame-01-of-1-at-3.0s.png",
),
);
expect(screen.getByText(/Frame 1\/1/)).toBeInTheDocument();
});
it("flags an orientation with no rendered frames as missing and disables its toggle", async () => {
getPreviewFrames.mockResolvedValueOnce({
...FRAMES,
frames: { vertical: FRAMES.frames.vertical },
});
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
await screen.findByText("Intro");
const squareButton = screen.getByRole("button", { name: /1:1/ });
expect(squareButton).toHaveTextContent("(missing)");
expect(squareButton).toBeDisabled();
});
it("shows an uncommitted-changes badge when the render was dirty", async () => {
getPreviewFrames.mockResolvedValueOnce({ ...FRAMES, dirty: true });
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
expect(await screen.findByText("uncommitted changes")).toBeInTheDocument();
});
it("shows a muted empty state when nothing has been rendered yet (404)", async () => {
getPreviewFrames.mockRejectedValueOnce(new Error("not found"));
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
expect(
await screen.findByText(/No render preview yet/),
).toBeInTheDocument();
expect(getPreviewFrameBlob).not.toHaveBeenCalled();
});
});