Files
siftlode/frontend/src/components/ui/PriorityStepper.dom.test.tsx
T
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00

138 lines
5.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: "channels.row.raisePriority" });
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
describe("PriorityStepper", () => {
it("shows the current priority and both step buttons", () => {
render(<PriorityStepper 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 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 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 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 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 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 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 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 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 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
});
});