- A GIF squares its pixels before the size, so an anamorphic source keeps
its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
as one ("about 1 MB"), none is shown with nothing kept; the size choices
show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
now holds it); the English warns of "only a still picture".
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
||
//
|
||
// The clip panel: it opens on its In handle, every way of marking keeps a range the server takes,
|
||
// Cut sends the range (and a Plex clip's audio track) and closes, a refused Cut stays open and says
|
||
// so, and "Play selection" loops back to In. The range is the caller's state, so the test holds it.
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { useState } from "react";
|
||
import type { ClipRequest } from "../../lib/api";
|
||
import type { ClipRange } from "../../lib/clipRange";
|
||
|
||
vi.mock("react-i18next", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||
return {
|
||
...actual,
|
||
// A size option reads as its own words with its unit ("480 px"); every other text is its key.
|
||
useTranslation: () => ({
|
||
t: (k: string, o?: { n?: string }) => (k === "clip.gif.side" ? `${o?.n} px` : k),
|
||
i18n: { language: "en" },
|
||
}),
|
||
};
|
||
});
|
||
|
||
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
|
||
vi.mock("../../lib/api", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
|
||
});
|
||
const notify = vi.fn();
|
||
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||
return { ...actual, notify: (n: unknown) => notify(n) };
|
||
});
|
||
const showInTray = vi.fn();
|
||
vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) }));
|
||
const notifyActionError = vi.fn();
|
||
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
|
||
return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
|
||
});
|
||
|
||
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
||
import { msg, type NotifText } from "../../lib/notifications";
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.clearAllMocks();
|
||
});
|
||
beforeEach(() => {
|
||
// A block, not an expression: a function returned from beforeEach is run as its teardown.
|
||
enqueueClip.mockResolvedValue({ id: 1 });
|
||
});
|
||
|
||
function Harness({
|
||
src = { source: "plex", ref: "777", audioTrack: 1 },
|
||
start = { start: 100, end: 130 },
|
||
current = 100,
|
||
onClose = () => {},
|
||
onPlayFrom = () => {},
|
||
onSeek = () => {},
|
||
duration = 3600,
|
||
}: {
|
||
src?: ClipSource;
|
||
start?: ClipRange;
|
||
current?: number;
|
||
onClose?: () => void;
|
||
onPlayFrom?: (t: number) => void;
|
||
onSeek?: (t: number, final: boolean) => void;
|
||
duration?: number;
|
||
}) {
|
||
const [clip, setClip] = useState(start);
|
||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||
return (
|
||
<QueryClientProvider client={new QueryClient()}>
|
||
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
|
||
<output data-testid="crop">{crop ? JSON.stringify(crop) : "none"}</output>
|
||
<ClipPanel
|
||
src={src}
|
||
clip={clip}
|
||
onChange={setClip}
|
||
duration={duration}
|
||
current={current}
|
||
picture={16 / 9}
|
||
crop={crop}
|
||
onCrop={setCrop}
|
||
onSeek={onSeek}
|
||
onPlayFrom={onPlayFrom}
|
||
onClose={onClose}
|
||
/>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
const range = () => screen.getByTestId("range").textContent;
|
||
|
||
describe("ClipPanel", () => {
|
||
it("opens with the focus on its In handle, so the arrows move the mark at once", () => {
|
||
render(<Harness />);
|
||
const handle = screen.getByRole("slider", { name: "clip.in" });
|
||
expect(document.activeElement).toBe(handle);
|
||
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
|
||
expect(range()).toBe("99-130");
|
||
});
|
||
|
||
it("takes a typed time on Enter and carries the other end along past it", () => {
|
||
render(<Harness />);
|
||
const [inField] = screen.getAllByRole("textbox", { name: "clip.in" });
|
||
fireEvent.change(inField!, { target: { value: "2:20" } });
|
||
fireEvent.keyDown(inField!, { key: "Enter" });
|
||
expect(range()).toBe("140-170");
|
||
});
|
||
|
||
it("keeps the focus in a time field that took a typed time on Enter", () => {
|
||
render(<Harness />);
|
||
const field = () => screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||
field().focus();
|
||
fireEvent.change(field(), { target: { value: "2:20" } });
|
||
fireEvent.keyDown(field(), { key: "Enter" });
|
||
expect(range()).toBe("140-170");
|
||
expect(document.activeElement).toBe(field());
|
||
expect((field() as HTMLInputElement).value).toBe("0:02:20.0");
|
||
});
|
||
|
||
it("shows the mark it holds, not a time it could not take", () => {
|
||
render(<Harness start={{ start: 3500, end: 3600 }} />);
|
||
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
|
||
// Past the end: clamped back to the end, and the field says so.
|
||
fireEvent.change(out(), { target: { value: "1:15:00" } });
|
||
fireEvent.blur(out());
|
||
expect(range()).toBe("3500-3600");
|
||
expect(out().value).toBe("1:00:00.0");
|
||
expect(out().getAttribute("aria-invalid")).toBeNull();
|
||
// Not a time at all: kept for fixing, and marked as not taken.
|
||
fireEvent.change(out(), { target: { value: "abc" } });
|
||
fireEvent.blur(out());
|
||
expect(out().value).toBe("abc");
|
||
expect(out().getAttribute("aria-invalid")).toBe("true");
|
||
expect(range()).toBe("3500-3600");
|
||
});
|
||
|
||
it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => {
|
||
render(<Harness />);
|
||
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
|
||
fireEvent.change(out(), { target: { value: "abc" } });
|
||
fireEvent.blur(out());
|
||
const line = screen.getByRole("alert");
|
||
expect(line.textContent).toBe("common.timecodeInvalidHours");
|
||
expect(out().getAttribute("aria-describedby")).toBe(line.id);
|
||
// In the panel's flow, after the fields' row: it pushes the crop row down rather than lying over
|
||
// its buttons and taking their clicks.
|
||
expect(line.closest('[role="group"]')).toBeNull();
|
||
expect(line.className).not.toMatch(/\babsolute\b/);
|
||
expect(line.parentElement?.className).toMatch(/\bmedia-scope\b/);
|
||
});
|
||
|
||
it("takes a decimal comma, as a Hungarian hand types it", () => {
|
||
render(<Harness />);
|
||
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||
fireEvent.change(field, { target: { value: "1:42,5" } });
|
||
fireEvent.keyDown(field, { key: "Enter" });
|
||
expect(range()).toBe("102.5-130");
|
||
});
|
||
|
||
it("never seeks the player onto the item's very end (that would finish the item)", () => {
|
||
const onSeek = vi.fn();
|
||
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
|
||
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
|
||
expect(range()).toBe("590-600");
|
||
const [time, final] = onSeek.mock.lastCall!;
|
||
expect(final).toBe(true);
|
||
expect(time).toBeLessThan(600);
|
||
expect(time).toBeGreaterThan(599);
|
||
});
|
||
|
||
it("marks an end at the playhead", () => {
|
||
render(<Harness current={115.5} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
|
||
expect(range()).toBe("100-115.5");
|
||
});
|
||
|
||
it("cuts the range with the Plex audio track and closes", async () => {
|
||
const onClose = vi.fn();
|
||
render(<Harness onClose={onClose} start={{ start: 100.12345, end: 130 }} />);
|
||
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
|
||
target: { value: " The bit " },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||
expect(enqueueClip).toHaveBeenCalledWith({
|
||
source: "plex",
|
||
ref: "777",
|
||
start_s: 100.123,
|
||
end_s: 130,
|
||
audio_track: 1,
|
||
display_name: "The bit",
|
||
});
|
||
expect(notify).toHaveBeenCalledWith(
|
||
expect.objectContaining({ level: "success", message: msg("clip.queued") }),
|
||
);
|
||
// Its View opens the tray on the new clip.
|
||
(
|
||
notify.mock.calls[notify.mock.calls.length - 1]![0] as { action: { onClick: () => void } }
|
||
).action.onClick();
|
||
expect(showInTray).toHaveBeenCalledWith(1);
|
||
});
|
||
|
||
it("sends no audio track for a YouTube clip", async () => {
|
||
render(<Harness src={{ source: "youtube", ref: "abcdefghijk" }} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
|
||
const body = enqueueClip.mock.calls[0]![0];
|
||
expect(body.source).toBe("youtube");
|
||
expect(Object.keys(body)).not.toContain("audio_track");
|
||
});
|
||
|
||
it("stays open and says so when the cut is refused", async () => {
|
||
const onClose = vi.fn();
|
||
const err = new Error("boom");
|
||
enqueueClip.mockRejectedValue(err);
|
||
render(<Harness onClose={onClose} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
||
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
|
||
expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
||
false,
|
||
);
|
||
});
|
||
|
||
it("loops the selection: playing past End goes back to Start", () => {
|
||
const onPlayFrom = vi.fn();
|
||
const { rerender } = render(<Harness onPlayFrom={onPlayFrom} current={110} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.loop" }));
|
||
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
|
||
onPlayFrom.mockClear();
|
||
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={125} />));
|
||
expect(onPlayFrom).not.toHaveBeenCalled();
|
||
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
|
||
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
|
||
});
|
||
|
||
it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => {
|
||
render(<Harness />);
|
||
expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull();
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||
expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe(
|
||
"true",
|
||
);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
|
||
expect(enqueueClip.mock.calls[0]![0].crop).toEqual({
|
||
x: 0.1,
|
||
y: 0.1,
|
||
w: 0.8,
|
||
h: 0.8,
|
||
aspect: 1.7778,
|
||
});
|
||
});
|
||
|
||
it("holds the frame to a shape without cutting, and goes back to the whole picture", () => {
|
||
render(<Harness />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||
// A segmented choice inside the panel's form: never a submit.
|
||
fireEvent.click(screen.getByRole("button", { name: "9:16" }));
|
||
expect(enqueueClip).not.toHaveBeenCalled();
|
||
const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop;
|
||
expect(tall.shape).toBe("9:16");
|
||
expect(tall.rect.h).toBeCloseTo(1, 6);
|
||
expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.whole" }));
|
||
expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({
|
||
rect: { x: 0, y: 0, w: 1, h: 1 },
|
||
shape: "free",
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
|
||
expect(screen.getByTestId("crop").textContent).toBe("none");
|
||
});
|
||
});
|
||
|
||
describe("ClipPanel GIF", () => {
|
||
it("makes a GIF at the size picked, and says a GIF is being made", async () => {
|
||
const onClose = vi.fn();
|
||
render(<Harness onClose={onClose} start={{ start: 100, end: 108 }} />);
|
||
expect(screen.queryByRole("group", { name: "clip.gif.size" })).toBeNull();
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "320 px" }));
|
||
expect(enqueueClip).not.toHaveBeenCalled();
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.gif.make" }));
|
||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||
expect(enqueueClip.mock.calls[0]![0]).toMatchObject({
|
||
start_s: 100,
|
||
end_s: 108,
|
||
gif_side: 320,
|
||
});
|
||
expect(notify).toHaveBeenCalledWith(
|
||
expect.objectContaining({ level: "success", message: msg("clip.gif.queued") }),
|
||
);
|
||
});
|
||
|
||
it("starts at the default size, and a video again sends no GIF", async () => {
|
||
render(<Harness start={{ start: 100, end: 108 }} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
|
||
expect(screen.getByRole("button", { name: "480 px" }).getAttribute("aria-pressed")).toBe(
|
||
"true",
|
||
);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.formatVideo" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
|
||
expect(enqueueClip.mock.calls[0]![0]).not.toHaveProperty("gif_side");
|
||
});
|
||
|
||
it("a range longer than a GIF may be leaves Make unavailable, with its reason", () => {
|
||
render(<Harness start={{ start: 100, end: 131 }} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
|
||
const make = screen.getByRole("button", { name: "clip.gif.make" });
|
||
expect(make.getAttribute("aria-disabled")).toBe("true");
|
||
const note = document.getElementById(make.getAttribute("aria-describedby")!)!;
|
||
expect(note.textContent).toBe("clip.gif.tooLong");
|
||
fireEvent.click(make);
|
||
expect(enqueueClip).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("says what the expected size means: fine, no moving preview, or past the cap", () => {
|
||
render(<Harness start={{ start: 100, end: 110 }} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
|
||
const note = () =>
|
||
document.getElementById(
|
||
screen.getByRole("button", { name: "clip.gif.make" }).getAttribute("aria-describedby")!,
|
||
)!.textContent;
|
||
// 10 s at 480: about 4–10 MB.
|
||
expect(note()).toBe("clip.gif.hint");
|
||
// 20 s at 480: up to about 20 MB, past a link preview's 15.
|
||
fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), {
|
||
target: { value: "2:00" },
|
||
});
|
||
fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" });
|
||
expect(range()).toBe("100-120");
|
||
expect(note()).toBe("clip.gif.noPreview");
|
||
// 20 s at 640: up to about 32 MB, past the server's 30.
|
||
fireEvent.click(screen.getByRole("button", { name: "640 px" }));
|
||
expect(note()).toBe("clip.gif.mayFail");
|
||
});
|
||
|
||
it("says one size, not a range of one, when both ends read the same", () => {
|
||
// 1 s at 480: 0.4–1.0 MB, both shown as 1.
|
||
render(<Harness start={{ start: 100, end: 101 }} />);
|
||
fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" }));
|
||
expect(screen.getByText("clip.gif.estimateOne")).toBeTruthy();
|
||
expect(screen.queryByText("clip.gif.estimate")).toBeNull();
|
||
// 10 s: a range.
|
||
fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), {
|
||
target: { value: "1:50" },
|
||
});
|
||
fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" });
|
||
expect(screen.getByText("clip.gif.estimate")).toBeTruthy();
|
||
});
|
||
});
|