- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
273 lines
11 KiB
TypeScript
273 lines
11 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, useTranslation: () => ({ t: (k: string) => k }) };
|
|
});
|
|
|
|
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: string) => notifyActionError(e, t) };
|
|
});
|
|
|
|
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
|
|
|
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: "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]).toBe("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");
|
|
});
|
|
});
|