refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user