// @vitest-environment jsdom // // A split export that fails part-way. Every clip is its own job, // committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor // reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It // now says how many made it (also when `req` has already shown the refusal in its dialog), deselects // them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series. import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); const { notify } = vi.hoisted(() => ({ notify: vi.fn() })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify, })); import VideoEditor from "./VideoEditor"; import { api, HttpError, type DownloadJob } from "../lib/api"; import { markReported } from "../lib/api/core"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); notify.mockReset(); }); const job = { id: 7, display_name: "Talk", asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" }, } as unknown as DownloadJob; describe("VideoEditor — a split export that fails part-way", () => { it("says how many clips made it, and a retry queues only the rest", async () => { vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); let refuse: (e: unknown) => void = () => {}; const enqueue = vi .spyOn(api, "enqueueEdit") .mockResolvedValueOnce({} as never) .mockImplementationOnce( () => new Promise((_, reject) => { refuse = reject; }) as never, ) .mockResolvedValue({} as never); render( {}} /> , ); // The playhead is where the video element says it is. const video = document.querySelector("video")!; const playhead = (s: number) => { Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true }); fireEvent.timeUpdate(video); }; playhead(50); fireEvent.click(screen.getByText("editor.splitHere")); fireEvent.click(screen.getByText("editor.createN")); await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2)); // While the clips are queued the cut list holds still: a split now would be a range never queued. playhead(75); fireEvent.click(screen.getByText("editor.splitHere")); // The job cap, as `req` delivers it: already shown in the error dialog, and marked reported. const refused = new HttpError(422, "errors.generic", { code: "quota" }); markReported(refused); await act(async () => refuse(refused)); await waitFor(() => expect(notify).toHaveBeenCalledWith( expect.objectContaining({ level: "error", title: "editor.partlyCreated" }), ), ); expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)"); // The queued first clip is deselected: one clip left to create, and only it is sent again — as // the second of the same two. fireEvent.click(screen.getByText("editor.create")); await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3)); expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 }); expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)"); }); }); // A time typed into the selected segment's field belongs to that segment: selecting another one // drops it, and never commits it to the new selection's cut. describe("VideoEditor — a typed time and another segment", () => { it("does not move the newly selected segment's cut with a time typed for the old one", () => { vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); render( {}} /> , ); const video = document.querySelector("video")!; for (const at of [30, 60]) { Object.defineProperty(video, "currentTime", { value: at, configurable: true, writable: true, }); fireEvent.timeUpdate(video); fireEvent.click(screen.getByText("editor.splitHere")); } const segs = () => screen.getAllByTitle("editor.keptSeg"); fireEvent.pointerDown(segs()[1]!); const inField = screen.getByRole("textbox", { name: "editor.in" }); act(() => inField.focus()); fireEvent.change(inField, { target: { value: "0:40" } }); // Another segment is selected before the field loses the focus (a press on it). fireEvent.pointerDown(segs()[2]!); fireEvent.blur(inField); const cuts = screen .getAllByRole("slider", { name: "editor.cutN" }) .map((c) => c.getAttribute("aria-valuenow")); expect(cuts).toEqual(["30", "60"]); }); }); // An unreadable time is said on a line under the segment's row: outside the field's label, so the // field's name stays "Out" and the line is read once, as its description. describe("VideoEditor — an unreadable time", () => { it("is said under the row, outside the field's label", () => { vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); render( {}} /> , ); const video = document.querySelector("video")!; Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true }); fireEvent.timeUpdate(video); fireEvent.click(screen.getByText("editor.splitHere")); fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!); const out = screen.getByRole("textbox", { name: "editor.out" }); fireEvent.change(out, { target: { value: "abc" } }); fireEvent.keyDown(out, { key: "Enter" }); const line = screen.getByRole("alert"); expect(line.textContent).toBe("common.timecodeInvalid"); expect(line.closest("label")).toBeNull(); expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out); expect(out.getAttribute("aria-describedby")).toBe(line.id); }); }); // The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a // tenth short of the next cut, so no segment ever closes up. describe("VideoEditor — a cut on the keyboard", () => { it("steps a focused cut with the arrows and stops it before its neighbours", () => { vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); render( {}} /> , ); const video = document.querySelector("video")!; Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true }); fireEvent.timeUpdate(video); fireEvent.click(screen.getByText("editor.splitHere")); const cut = screen.getByRole("slider", { name: "editor.cutN" }); expect(cut.getAttribute("aria-valuenow")).toBe("50"); fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true }); expect(cut.getAttribute("aria-valuenow")).toBe("51"); fireEvent.keyDown(cut, { key: "End" }); expect(cut.getAttribute("aria-valuenow")).toBe("99.9"); }); }); // A cut's remove button and the keep toggle of the segment after it: the button is a button a click // or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it // sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's // hit area that reaches into its segment, so a press on it is the toggle's. describe("VideoEditor — a cut's remove button", () => { const split = () => { vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); render( {}} /> , ); const video = document.querySelector("video")!; Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true }); fireEvent.timeUpdate(video); fireEvent.click(screen.getByText("editor.splitHere")); }; it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => { split(); const remove = screen.getByRole("button", { name: "editor.removeCut" }); expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false); act(() => remove.focus()); fireEvent.click(remove); expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull(); }); it("hands the focus on to a cut beside it, or to Split here, never to ", async () => { split(); const video = document.querySelector("video")!; Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true }); fireEvent.timeUpdate(video); fireEvent.click(screen.getByText("editor.splitHere")); const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1); act(() => second!.focus()); fireEvent.click(second!); await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })), ); const last = screen.getByRole("button", { name: "editor.removeCut" }); act(() => last.focus()); fireEvent.click(last); await waitFor(() => expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")), ); }); it("a remove refused while the clips are queued leaves no focus to take later", async () => { let refuse: (e: unknown) => void = () => {}; vi.spyOn(api, "enqueueEdit").mockImplementation( () => new Promise((_, reject) => { refuse = reject; }) as never, ); split(); fireEvent.click(screen.getByText("editor.createN")); await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled()); // The cut list holds still while the clips are queued: the remove is refused. const remove = screen.getByRole("button", { name: "editor.removeCut" }); act(() => remove.focus()); fireEvent.click(remove); expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy(); await act(async () => refuse(new Error("refused"))); // The create failed and the editor stays; a later split keeps the focus where it was. const video = document.querySelector("video")!; Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true }); fireEvent.timeUpdate(video); const splitButton = screen.getByText("editor.splitHere").closest("button")!; act(() => splitButton.focus()); fireEvent.click(splitButton); expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2); expect(document.activeElement).toBe(splitButton); }); it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => { split(); const remove = screen.getByRole("button", { name: "editor.removeCut" }); const track = screen.getByRole("group", { name: "editor.timeline" }); // A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track. expect(track.contains(remove)).toBe(false); const cut = screen.getByRole("slider", { name: "editor.cutN" }); expect(remove.closest("[style]")?.style.left).toBe(cut.style.left); const toggles = within(track).getAllByRole("button", { pressed: true }); for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/); }); });