Files
roboco/panel/src/components/dashboard/__tests__/video-post-queue.test.tsx
T
18998c4a42 Video pipeline fixes: visibility strip, rich briefs, spotlight timing + fps (#369)
* feat(video): pipeline visibility — strip, state-aware queue, render-error capture

Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET
/video/pipeline lists every in-flight video item (authoring statuses,
rendering attempt n/max, terminal failures with the error — now stamped
onto the video_draft marker instead of dying as a log line).
source_task_id exposed on both video schemas. Panel: pipeline strip on
the Social page, state-aware queue empty copy, title/script on queue
rows, missing cuts disabled instead of a blank player, notifications
deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the
markers policy layer (single source of truth).

* feat(video): rich authoring briefs — changelog section, brand voice, kit pointer

Task 2 of the 2026-07-09 video-pipeline review. The release brief is
now a structured block (full CHANGELOG section capped at 4000 chars +
highlights) instead of one LLM-compressed sentence; brand_voice and a
motion/kit design-bar pointer are appended centrally in open_video_task
so release, spotlight, and on-demand paths all inherit them.
suggested_input_props seeded on the video_draft marker; third
acceptance criterion pins the design bar; propose_video docstring
points at the kit.

* fix(video): spotlight video drafts on CEO approval, renderer honors data-fps

Task 4 of the 2026-07-09 video-pipeline review. The companion-video
hook moves from propose_feature_spotlight (HoM authoring time) to
XPostService approve's posted-success branch for x_feature drafts,
mirroring the release-publish seam — a rejected spotlight no longer
burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref
marker. Best-effort: a video-engine failure never breaks the post.
render.js reads data-fps from the composition HTML (clamped 24-60,
fallback 30) instead of hardcoding 30; parseFps covered by node --test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-09 08:31:08 +02:00

336 lines
11 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 type { VideoPipelineItem, VideoPost } from "@/lib/api/video";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
} = vi.hoisted(() => ({
listPosts: vi.fn(
async () =>
[
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: {
vertical: "/fake/vertical.mp4",
square: "/fake/square.mp4",
},
},
] as VideoPost[],
),
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
requestVideo: vi.fn(async () => ({
status: "opened",
task_id: "v-2",
detail: "Video-authoring task opened.",
})),
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
}));
vi.mock("@/lib/api", () => ({
videoApi: {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
},
}));
import { VideoPostQueue } from "../video-post-queue";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("VideoPostQueue", () => {
beforeEach(() => {
listPosts.mockClear();
listPipeline.mockClear();
approve.mockClear();
reject.mockClear();
requestVideo.mockClear();
getMediaBlob.mockClear();
resolveApproveRef.current = null;
// jsdom has no Blob URL implementation. Distinct URLs per call so a
// revoke can be asserted against the specific (stale) one it replaced.
let objectUrlCount = 0;
globalThis.URL.createObjectURL = vi.fn(
() => `blob:mock-url-${++objectUrlCount}`,
);
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders a draft with its occasion badge and both platform captions", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText("release")).toBeInTheDocument();
expect(
screen.getByDisplayValue("RoboCo v0.19.0 is here!"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
it("shows the fetched title and script instead of dropping them", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(
await screen.findByText("Video: release v0.19.0"),
).toBeInTheDocument();
expect(
screen.getByText("RoboCo v0.19.0 just shipped!"),
).toBeInTheDocument();
});
it("flags a missing cut as disabled instead of silently blanking the player", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: { vertical: "/fake/vertical.mp4" }, // square never rendered
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
const squareButton = screen.getByRole("button", { name: /1:1/ });
expect(squareButton).toHaveTextContent("(missing)");
expect(squareButton).toBeDisabled();
// The present cut is unaffected — no "(missing)" suffix, not disabled.
const verticalButton = screen.getByRole("button", { name: /9:16/ });
expect(verticalButton).not.toHaveTextContent("(missing)");
expect(verticalButton).not.toBeDisabled();
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"),
);
});
// H15: the 30s refetchInterval produces a new `post` prop, but useState
// initializes once — so a server-side re-draft between the CEO opening the
// card and approving would be silently overwritten by the stale initial
// caption. The displayed value must track the server until the CEO edits.
it("tracks the server caption until the CEO edits, then holds the edit (mirrors x-post-queue)", async () => {
const basePost = {
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
};
listPosts.mockResolvedValueOnce([
{ ...basePost, x_caption: "old", tiktok_caption: "old-tik" },
] as VideoPost[]);
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={client}>
<VideoPostQueue />
</QueryClientProvider>,
);
const xTextarea = await screen.findByDisplayValue("old");
expect(xTextarea).toBeInTheDocument();
// Simulate a 30s refetch producing a re-drafted server caption.
listPosts.mockResolvedValueOnce([
{ ...basePost, x_caption: "new server text", tiktok_caption: "new-tik" },
] as VideoPost[]);
await client.invalidateQueries({ queryKey: ["video", "posts"] });
await waitFor(() =>
expect(screen.getByDisplayValue("new server text")).toBeInTheDocument(),
);
// CEO types an edit — the derived value should now follow the user.
fireEvent.change(screen.getByDisplayValue("new server text"), {
target: { value: "my edit" },
});
expect(screen.getByDisplayValue("my edit")).toBeInTheDocument();
// Another refetch with a newer server caption — the user's edit holds.
listPosts.mockResolvedValueOnce([
{
...basePost,
x_caption: "even newer server text",
tiktok_caption: "newer-tik",
},
] as VideoPost[]);
await client.invalidateQueries({ queryKey: ["video", "posts"] });
await waitFor(() =>
expect(screen.getByDisplayValue("my edit")).toBeInTheDocument(),
);
expect(
screen.queryByDisplayValue("even newer server text"),
).not.toBeInTheDocument();
});
it("fetches the preview clip as a blob via axios and drives <video> off an object URL", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"),
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe(
"blob:mock-url-1",
),
);
fireEvent.click(screen.getByRole("button", { name: "1:1" }));
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "square"),
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe(
"blob:mock-url-2",
),
);
// The stale cut's object URL is revoked once the new one takes over —
// this is the leak-prevention path FIX 1 exists for.
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith(
"blob:mock-url-1",
);
});
it("disables Approve when the edited X caption exceeds 280 characters", async () => {
render(withQueryClient(<VideoPostQueue />));
const textarea = await screen.findByDisplayValue("RoboCo v0.19.0 is here!");
fireEvent.change(textarea, { target: { value: "x".repeat(281) } });
expect(screen.getByText("281/280")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Approve/ })).toBeDisabled();
});
it("only sends captions for platforms left toggled on", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
// Un-toggle TikTok — its stored caption should NOT be sent on approve.
fireEvent.click(screen.getByLabelText("Edit TikTok caption"));
fireEvent.click(screen.getByRole("button", { name: /Approve/ }));
await waitFor(() =>
expect(approve).toHaveBeenCalledWith("v-1", {
x_caption: "RoboCo v0.19.0 is here!",
}),
);
resolveApproveRef.current?.({ status: "posted", posted: {}, detail: "ok" });
});
it("rejects a draft with a reason", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
const reasonBox = await screen.findByLabelText("Reason");
fireEvent.change(reasonBox, { target: { value: "wrong occasion" } });
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(reject).toHaveBeenCalledWith("v-1", "wrong occasion"),
);
});
it("requests an on-demand video with the chosen occasion, brief, and platforms", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
fireEvent.change(screen.getByLabelText("Occasion"), {
target: { value: "Founder's Day" },
});
fireEvent.change(screen.getByLabelText("Brief"), {
target: { value: "Celebrate the founding." },
});
// Both platforms are checked by default — leave as-is and submit.
fireEvent.click(screen.getByRole("button", { name: "Request" }));
await waitFor(() =>
expect(requestVideo).toHaveBeenCalledWith({
occasion: "Founder's Day",
brief: "Celebrate the founding.",
platforms: ["x", "tiktok"],
}),
);
});
it("shows the keys/engine empty copy when nothing is in the pipeline either", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([]);
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Request a video/ }),
).toBeInTheDocument();
});
it("shows an in-flight count instead of the keys/engine copy when the pipeline is non-empty", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([
{
task_id: "vp-1",
title: "Video: launch teaser",
occasion: "launch",
status: "in_progress",
pr_number: null,
composition_id: null,
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
},
] as VideoPipelineItem[]);
render(withQueryClient(<VideoPostQueue />));
expect(
await screen.findByText(/1 video in flight — nothing rendered yet/),
).toBeInTheDocument();
expect(screen.queryByText(/No drafts yet/)).not.toBeInTheDocument();
});
});