Files
siftlode/frontend/src/features/player/ClipPanel.dom.test.tsx
T
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- 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".
2026-11-12 23:24:25 +01:00

360 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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();
});
});