// @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(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); const enqueueClip = vi.fn<(body: ClipRequest) => Promise>(); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } }; }); const notify = vi.fn(); vi.mock("../lib/notifications", async (importOriginal) => { const actual = await importOriginal(); 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(); 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(null); return ( {`${clip.start}-${clip.end}`} {crop ? JSON.stringify(crop) : "none"} ); } 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); fireEvent.click(screen.getByRole("button", { name: "clip.loop" })); expect(onPlayFrom).toHaveBeenLastCalledWith(100); onPlayFrom.mockClear(); act(() => rerender()); expect(onPlayFrom).not.toHaveBeenCalled(); act(() => rerender()); 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(); 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(); 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"); }); });