Files
siftlode/frontend/src/components/ui/RangeTrack.dom.test.tsx
T
peter 977ba244fa fix(clip): a time's error line sits in the flow, the remove button off the cut, and every player surface rings amber
- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role
  alert) in its own flow under the fields' row: it no longer lies over the crop row taking its
  clicks, runs out of the editor's card, or joins the field's <label> name.
- The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the
  cut's grab area, where a press drags it; RangeTrack's adornment slot is gone.
- media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every
  bg-media-chrome surface; a contract test holds each such class string to media-focus or
  media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme.
- An upload's quota headline says the converted file does not fit, not "Storage full".
- DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all
  three colours on the chrome.
2026-10-10 21:29:55 +02:00

219 lines
8.7 KiB
TypeScript

// @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> = {}): 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(<RangeTrack duration={100} label="Clip" handles={[handle()]} />);
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(<RangeTrack duration={100} label="Clip" handles={[h]} />);
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(<RangeTrack duration={100} label="Clip" handles={[h]} />);
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(<RangeTrack duration={100} label="Clip" handles={[h]} />);
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(<RangeTrack duration={100} label="Clip" handles={[h]} onSeek={onSeek} />);
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(<RangeTrack duration={100} label="Clip" handles={[h]} />);
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(<RangeTrack duration={100} label="Clip" handles={[a, b]} />);
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(
<RangeTrack duration={200} label="Clip" handles={[handle({ onChange })]} onSeek={onSeek} />,
);
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(
<RangeTrack
duration={200}
label="Clip"
handles={[handle({ onChange, time: 20 })]}
view={{ from: 100, to: 200 }}
onSeek={onSeek}
/>,
);
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(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
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(
<RangeTrack
duration={3600}
view={{ from: 600, to: 700 }}
label="Zoom"
playhead={50}
onSeek={onSeek}
handles={[
handle({ id: "in", label: "In", time: 650, min: 0, max: 3600 }),
handle({ id: "out", label: "Out", time: 800, min: 0, max: 3600 }),
]}
/>,
);
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(<RulerTicks view={{ from: 0, to: 60 }} format={(t) => `${t}s`} />);
const labels = [...container.querySelectorAll("span")].map((s) => s.textContent);
expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]);
});
});