// @vitest-environment jsdom // // The shared time track: a handle is a slider the keyboard steps (and the step stops at its range), // a drag reports its moves as not-final and its release as final, a press on the bare track seeks, // and the window decides what is drawn. jsdom lays nothing out, so the track's box is stubbed. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { RangeTrack, RulerTicks, type TrackHandle } from "./RangeTrack"; afterEach(cleanup); beforeAll(() => { // jsdom has no PointerEvent: a MouseEvent carries the button and clientX a drag reads. class PointerEventStub extends MouseEvent { pointerId: number; constructor(type: string, init: PointerEventInit = {}) { super(type, init); this.pointerId = init.pointerId ?? 1; } } globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent; }); function box(el: Element, left = 0, width = 1000) { el.getBoundingClientRect = () => ({ left, width, top: 0, height: 40, right: left + width, bottom: 40, x: left, y: 0, }) as DOMRect; } function handle(over: Partial = {}): TrackHandle { return { id: "in", time: 50, min: 10, max: 90, label: "In", valueText: "0:50.0", step: 0.1, bigStep: 1, onChange: vi.fn(), ...over, }; } describe("RangeTrack", () => { it("draws a handle as a slider with its time as the value", () => { render(); const s = screen.getByRole("slider", { name: "In" }); expect(s.getAttribute("aria-valuenow")).toBe("50"); expect(s.getAttribute("aria-valuetext")).toBe("0:50.0"); expect(s.style.left).toBe("50%"); }); it("steps a focused handle with the arrows, Shift for the big step, and takes the key", () => { const h = handle(); render(); const s = screen.getByRole("slider", { name: "In" }); const right = new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true, }); s.dispatchEvent(right); expect(h.onChange).toHaveBeenLastCalledWith(50.1, true); expect(right.defaultPrevented).toBe(true); fireEvent.keyDown(s, { key: "ArrowLeft", shiftKey: true }); expect(h.onChange).toHaveBeenLastCalledWith(49, true); }); it("stops a step at the handle's range", () => { const h = handle({ time: 89.95 }); render(); const s = screen.getByRole("slider", { name: "In" }); fireEvent.keyDown(s, { key: "ArrowRight", shiftKey: true }); expect(h.onChange).toHaveBeenLastCalledWith(90, true); fireEvent.keyDown(s, { key: "Home" }); expect(h.onChange).toHaveBeenLastCalledWith(10, true); }); it("leaves a modified arrow to the page", () => { const h = handle(); render(); fireEvent.keyDown(screen.getByRole("slider", { name: "In" }), { key: "ArrowRight", ctrlKey: true, }); expect(h.onChange).not.toHaveBeenCalled(); }); it("reports a drag's moves as not final and its release as final, clamped to the range", () => { const h = handle(); const onSeek = vi.fn(); render(); box(screen.getByRole("group", { name: "Clip" })); const s = screen.getByRole("slider", { name: "In" }); fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 }); fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 }); expect(h.onChange).toHaveBeenLastCalledWith(70, false); fireEvent.pointerUp(s, { clientX: 990, pointerId: 1 }); expect(h.onChange).toHaveBeenLastCalledWith(90, true); // The handle's press is not a seek of the track under it. expect(onSeek).not.toHaveBeenCalled(); }); it("reports a drag the browser took away (a cancel) as final where it stopped", () => { // A caller that acts on the final move only (the Plex seek, the clip strip's window) was left // with a not-final one for good. const h = handle(); render(); box(screen.getByRole("group", { name: "Clip" })); const s = screen.getByRole("slider", { name: "In" }); fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 }); fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 }); fireEvent.pointerCancel(s, { pointerId: 1 }); expect(h.onChange).toHaveBeenLastCalledWith(70, true); }); it("follows the pointer that took a handle, not a second finger", () => { const a = handle({ id: "in", label: "In", time: 30 }); const b = handle({ id: "out", label: "Out", time: 70 }); render(); box(screen.getByRole("group", { name: "Clip" })); const inH = screen.getByRole("slider", { name: "In" }); const outH = screen.getByRole("slider", { name: "Out" }); fireEvent.pointerDown(inH, { button: 0, clientX: 300, pointerId: 1 }); fireEvent.pointerDown(outH, { button: 0, clientX: 700, pointerId: 2 }); fireEvent.pointerMove(inH, { clientX: 400, pointerId: 1 }); expect(a.onChange).toHaveBeenLastCalledWith(40, false); fireEvent.pointerMove(outH, { clientX: 800, pointerId: 2 }); expect(b.onChange).not.toHaveBeenCalled(); fireEvent.pointerUp(inH, { clientX: 400, pointerId: 1 }); expect(a.onChange).toHaveBeenLastCalledWith(40, true); }); it("ends a drag whose handle leaves the window under it, so the track takes the next press", () => { const onChange = vi.fn(); const onSeek = vi.fn(); const { rerender } = render( , ); const track = screen.getByRole("group", { name: "Clip" }); box(track); fireEvent.pointerDown(screen.getByRole("slider", { name: "In" }), { button: 0, clientX: 250, pointerId: 7, }); // A key moves the mark out of the window while the finger is still down: the handle unmounts. rerender( , ); expect(screen.queryByRole("slider", { name: "In" })).toBeNull(); // Released where the handle no longer is: the drag ends, as final, where the mark now is. fireEvent.pointerUp(document.body, { pointerId: 7 }); expect(onChange).toHaveBeenLastCalledWith(20, true); fireEvent.pointerDown(track, { button: 0, clientX: 500, pointerId: 8 }); expect(onSeek).toHaveBeenCalledWith(150, false); }); it("seeks on a press of the bare track, following the drag, final on release", () => { const onSeek = vi.fn(); render(); const track = screen.getByRole("group", { name: "Clip" }); box(track); fireEvent.pointerDown(track, { button: 0, clientX: 250, pointerId: 1 }); expect(onSeek).toHaveBeenLastCalledWith(50, false); fireEvent.pointerMove(track, { clientX: 500, pointerId: 1 }); expect(onSeek).toHaveBeenLastCalledWith(100, false); fireEvent.pointerUp(track, { clientX: 500, pointerId: 1 }); expect(onSeek).toHaveBeenLastCalledWith(100, true); }); it("maps time through its window, and draws nothing that falls outside it", () => { const onSeek = vi.fn(); render( , ); const track = screen.getByRole("group", { name: "Zoom" }); box(track); expect(screen.getByRole("slider", { name: "In" }).style.left).toBe("50%"); expect(screen.queryByRole("slider", { name: "Out" })).toBeNull(); expect(screen.queryByTestId("range-playhead")).toBeNull(); fireEvent.pointerDown(track, { button: 0, clientX: 100, pointerId: 1 }); expect(onSeek).toHaveBeenLastCalledWith(610, false); }); }); describe("RulerTicks", () => { it("picks a step that keeps the window to a handful of ticks", () => { const { container } = render( `${t}s`} />); const labels = [...container.querySelectorAll("span")].map((s) => s.textContent); expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]); }); });