Files
siftlode/frontend/src/components/ui/PriorityStepper.dom.test.tsx
T
peter dac9d89f91 feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
2026-09-24 23:48:22 +02:00

196 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is
// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual
// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { PriorityStepper } from "./PriorityStepper";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(() => {
cleanup();
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
});
const raise = () => screen.getByRole("button", { name: "common.priority.raise" });
const lower = () => screen.getByRole("button", { name: "common.priority.lower" });
const valueBtn = () => screen.getByRole("button", { name: "common.priority.edit" });
describe("PriorityStepper", () => {
it("hands the focus back to the value after Enter or Escape, not to <body>", () => {
const onSet = vi.fn();
render(
<PriorityStepper hint="what it orders" priority={3} onPriority={vi.fn()} onSet={onSet} />,
);
for (const key of ["Escape", "Enter"]) {
fireEvent.click(valueBtn());
const field = screen.getByRole("spinbutton");
field.focus();
fireEvent.keyDown(field, { key });
expect(document.activeElement).toBe(valueBtn());
}
});
it("shows the current priority and both step buttons", () => {
render(
<PriorityStepper hint="what it orders" priority={12} onPriority={vi.fn()} onSet={vi.fn()} />,
);
expect(valueBtn().textContent).toBe("12");
expect(raise()).toBeTruthy();
expect(lower()).toBeTruthy();
});
it("tapping + / − nudges by ±1", () => {
const onPriority = vi.fn();
render(
<PriorityStepper
hint="what it orders"
priority={0}
onPriority={onPriority}
onSet={vi.fn()}
/>,
);
fireEvent.click(raise());
fireEvent.click(lower());
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
});
it("clicking the value opens an inline editor seeded with the current value", () => {
render(
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={vi.fn()} />,
);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton") as HTMLInputElement;
expect(input.value).toBe("7");
});
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
const onSet = vi.fn();
render(
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={onSet} />,
);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "-3" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSet).toHaveBeenCalledWith(-3);
});
it("Escape cancels the edit without committing", () => {
const onSet = vi.fn();
render(
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={onSet} />,
);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "99" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onSet).not.toHaveBeenCalled();
// editor closed → the value button is back
expect(valueBtn().textContent).toBe("7");
});
it("committing an unchanged value does not call onSet", () => {
const onSet = vi.fn();
render(
<PriorityStepper hint="what it orders" priority={7} onPriority={vi.fn()} onSet={onSet} />,
);
fireEvent.click(valueBtn());
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
expect(onSet).not.toHaveBeenCalled();
});
it("renders the same interactive contract at size=md", () => {
const onPriority = vi.fn();
render(
<PriorityStepper
hint="what it orders"
priority={3}
onPriority={onPriority}
onSet={vi.fn()}
size="md"
/>,
);
expect(valueBtn().textContent).toBe("3");
fireEvent.click(raise());
expect(onPriority).toHaveBeenCalledWith(1);
});
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
render(
<PriorityStepper
hint="what it orders"
priority={0}
onPriority={onPriority}
onSet={vi.fn()}
/>,
);
fireEvent.pointerDown(raise());
vi.advanceTimersByTime(349);
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
expect(onPriority).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000); // several accelerating repeats while held
const heldCount = onPriority.mock.calls.length;
expect(heldCount).toBeGreaterThan(3);
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
fireEvent.pointerUp(raise());
vi.advanceTimersByTime(1000);
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
expect(onPriority.mock.calls.length).toBe(heldCount);
});
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
render(
<PriorityStepper
hint="what it orders"
priority={0}
onPriority={onPriority}
onSet={vi.fn()}
/>,
);
fireEvent.pointerDown(lower());
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
fireEvent.pointerUp(lower());
fireEvent.click(lower()); // the tap's click is NOT suppressed
expect(onPriority.mock.calls).toEqual([[-1]]);
});
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
const { unmount } = render(
<PriorityStepper
hint="what it orders"
priority={0}
onPriority={onPriority}
onSet={vi.fn()}
/>,
);
fireEvent.pointerDown(raise());
vi.advanceTimersByTime(350); // repeat armed + first tick
const before = onPriority.mock.calls.length;
expect(before).toBeGreaterThan(0);
unmount();
vi.advanceTimersByTime(1000);
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
});
});