- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role alert) in its own flow under the fields' row: it no longer lies over the crop row taking its clicks, runs out of the editor's card, or joins the field's <label> name. - The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the cut's grab area, where a press drags it; RangeTrack's adornment slot is gone. - media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every bg-media-chrome surface; a contract test holds each such class string to media-focus or media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme. - An upload's quota headline says the converted file does not fit, not "Storage full". - DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all three colours on the chrome.
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
// @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<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
}));
|
|
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
|
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
|
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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoEditor job={job} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
// 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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoEditor job={job} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoEditor job={job} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoEditor job={job} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<VideoEditor job={job} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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 <body>", 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<HTMLElement>("[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/);
|
|
});
|
|
});
|