mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 16:50:59 +01:00
Matches the Phase 1 design_system pattern the brief points at: own lint config, own tests, own coverage gate enforced in CI. The lint script was declared but unrunnable -- there was no eslint flat config, and the build tsconfig excludes tests (correctly; they must not ship in dist/), so type-aware linting had no project for them. Adds eslint.config.js and a lint-only tsconfig that widens the include without emitting. Running it surfaced one real defect: nth() interpolated a number into a template literal without String(), which the consumers' configs would have flagged. The gate also checks that the committed dist/ matches a fresh build. This is not theoretical -- the check caught the stale dist/ produced by that very String() fix on its first run. A stale dist/ in a tag ships old code to every consumer with no error anywhere, which is the one failure mode this packaging choice introduces. dist/ is excluded from the whitespace hooks: they would "fix" generated output that the next build immediately reverts. CI additionally re-runs the categorical ramp's constraint proof, so the palette cannot regress silently either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
248 lines
8.5 KiB
TypeScript
248 lines
8.5 KiB
TypeScript
import { fireEvent, render, screen } from "@testing-library/react";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
|
||
import { RangeSlider, steppedValue } from "./range-slider.tsx";
|
||
|
||
/** Ten evenly spaced values, so a quantile fraction maps to a predictable value. */
|
||
const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
|
||
|
||
/** The track width the jsdom rect stub reports (see src/test/setup.ts). */
|
||
const TRACK_WIDTH = 1000;
|
||
|
||
function renderSlider(
|
||
properties: Partial<React.ComponentProps<typeof RangeSlider>> = {},
|
||
): { onChange: ReturnType<typeof vi.fn> } {
|
||
const onChange = vi.fn();
|
||
render(
|
||
<RangeSlider
|
||
hi={90}
|
||
lo={0}
|
||
onChange={onChange}
|
||
values={VALUES}
|
||
{...properties}
|
||
/>,
|
||
);
|
||
return { onChange };
|
||
}
|
||
|
||
function thumbs(): HTMLElement[] {
|
||
return screen.getAllByRole("slider");
|
||
}
|
||
|
||
describe("steppedValue", () => {
|
||
it("advances one sample at a time", () => {
|
||
expect(steppedValue(VALUES, 30, 1)).toBe(40);
|
||
});
|
||
|
||
it("moves backwards for a negative delta", () => {
|
||
expect(steppedValue(VALUES, 30, -1)).toBe(20);
|
||
});
|
||
|
||
it("clamps at the top", () => {
|
||
expect(steppedValue(VALUES, 90, 5)).toBe(90);
|
||
});
|
||
|
||
it("clamps at the bottom", () => {
|
||
expect(steppedValue(VALUES, 0, -5)).toBe(0);
|
||
});
|
||
|
||
it("rounds a between-samples value to a real sample", () => {
|
||
expect(steppedValue(VALUES, 34, 1)).toBe(40);
|
||
});
|
||
|
||
it("returns the current sample for a zero delta", () => {
|
||
expect(steppedValue(VALUES, 30, 0)).toBe(30);
|
||
});
|
||
|
||
describe("on a lumpy distribution", () => {
|
||
// The shape that actually broke in the browser: awesome-mcp-explorer's
|
||
// star counts hold ~1500 duplicate zeros, so an index step inside the run
|
||
// moved the index but not the value, and the arrow key looked dead.
|
||
const LUMPY = [...Array.from({ length: 50 }, () => 0), 5, 5, 5, 100, 5000];
|
||
|
||
it("escapes a run of duplicates instead of stalling", () => {
|
||
expect(steppedValue(LUMPY, 0, 1)).toBe(5);
|
||
});
|
||
|
||
it("keeps stepping past the second run", () => {
|
||
expect(steppedValue(LUMPY, 5, 1)).toBe(100);
|
||
});
|
||
|
||
it("steps back out of a run", () => {
|
||
expect(steppedValue(LUMPY, 5, -1)).toBe(0);
|
||
});
|
||
|
||
it("counts distinct values for a multi-step jump", () => {
|
||
expect(steppedValue(LUMPY, 0, 2)).toBe(100);
|
||
});
|
||
|
||
it("clamps at the top rather than running off the end", () => {
|
||
expect(steppedValue(LUMPY, 5000, 1)).toBe(5000);
|
||
});
|
||
|
||
it("clamps at the bottom", () => {
|
||
expect(steppedValue(LUMPY, 0, -1)).toBe(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("RangeSlider", () => {
|
||
it("renders nothing when there is nothing to range over", () => {
|
||
const { container } = render(<RangeSlider hi={1} lo={1} onChange={vi.fn()} values={[1]} />);
|
||
expect(container).toBeEmptyDOMElement();
|
||
});
|
||
|
||
it("exposes both thumbs as sliders with the value trio", () => {
|
||
renderSlider();
|
||
const [low, high] = thumbs();
|
||
expect(low).toHaveAttribute("aria-valuemin", "0");
|
||
expect(low).toHaveAttribute("aria-valuemax", "90");
|
||
expect(low).toHaveAttribute("aria-valuenow", "0");
|
||
expect(high).toHaveAttribute("aria-valuenow", "90");
|
||
});
|
||
|
||
it("names the thumbs from the label", () => {
|
||
renderSlider({ label: "Stars" });
|
||
expect(screen.getByLabelText("Stars minimum")).toBeInTheDocument();
|
||
expect(screen.getByLabelText("Stars maximum")).toBeInTheDocument();
|
||
});
|
||
|
||
it("falls back to a generic name without a label", () => {
|
||
renderSlider();
|
||
expect(screen.getByLabelText("Range minimum")).toBeInTheDocument();
|
||
});
|
||
|
||
it("draws the head only when both label and format are given", () => {
|
||
renderSlider({ format: (v): string => `${String(v)}*`, label: "Stars" });
|
||
expect(screen.getByText("Stars")).toBeInTheDocument();
|
||
expect(screen.getByText("0* – 90*")).toBeInTheDocument();
|
||
});
|
||
|
||
it("omits the head when the caller draws its own chrome", () => {
|
||
renderSlider({ label: "Stars" });
|
||
expect(screen.queryByText("Stars")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("is focusable, so the control is a keyboard stop", () => {
|
||
renderSlider();
|
||
const [low] = thumbs();
|
||
low?.focus();
|
||
expect(low).toHaveFocus();
|
||
});
|
||
|
||
describe("keyboard", () => {
|
||
it.each([
|
||
["ArrowRight", 10],
|
||
["ArrowUp", 10],
|
||
])("%s steps the low thumb up", (key, expected) => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.keyDown(thumbs()[0]!, { key });
|
||
expect(onChange).toHaveBeenCalledWith(expected, 90);
|
||
});
|
||
|
||
it.each([
|
||
["ArrowLeft", 80],
|
||
["ArrowDown", 80],
|
||
])("%s steps the high thumb down", (key, expected) => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.keyDown(thumbs()[1]!, { key });
|
||
expect(onChange).toHaveBeenCalledWith(0, expected);
|
||
});
|
||
|
||
it("PageUp jumps ten samples", () => {
|
||
const { onChange } = renderSlider({ lo: 0 });
|
||
fireEvent.keyDown(thumbs()[0]!, { key: "PageUp" });
|
||
expect(onChange).toHaveBeenCalledWith(90, 90);
|
||
});
|
||
|
||
it("PageDown jumps ten samples down", () => {
|
||
const { onChange } = renderSlider({ hi: 90 });
|
||
fireEvent.keyDown(thumbs()[1]!, { key: "PageDown" });
|
||
expect(onChange).toHaveBeenCalledWith(0, 0);
|
||
});
|
||
|
||
it("Home sends a thumb to the floor", () => {
|
||
const { onChange } = renderSlider({ lo: 50 });
|
||
fireEvent.keyDown(thumbs()[0]!, { key: "Home" });
|
||
expect(onChange).toHaveBeenCalledWith(0, 90);
|
||
});
|
||
|
||
it("End sends a thumb to the ceiling", () => {
|
||
const { onChange } = renderSlider({ hi: 50 });
|
||
fireEvent.keyDown(thumbs()[1]!, { key: "End" });
|
||
expect(onChange).toHaveBeenCalledWith(0, 90);
|
||
});
|
||
|
||
it("ignores keys it does not handle", () => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.keyDown(thumbs()[0]!, { key: "a" });
|
||
expect(onChange).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not let the low thumb cross the high one", () => {
|
||
const { onChange } = renderSlider({ hi: 10, lo: 10 });
|
||
fireEvent.keyDown(thumbs()[0]!, { key: "ArrowRight" });
|
||
expect(onChange).toHaveBeenCalledWith(10, 10);
|
||
});
|
||
|
||
it("does not let the high thumb cross the low one", () => {
|
||
const { onChange } = renderSlider({ hi: 10, lo: 10 });
|
||
fireEvent.keyDown(thumbs()[1]!, { key: "ArrowLeft" });
|
||
expect(onChange).toHaveBeenCalledWith(10, 10);
|
||
});
|
||
});
|
||
|
||
describe("pointer", () => {
|
||
function track(): HTMLElement {
|
||
// Queried by class rather than by role: the track is presentational, so
|
||
// it has neither a role nor text to find it by. (The consumers run
|
||
// eslint-plugin-testing-library, which flags this; this package does not
|
||
// install that plugin, so a disable comment here would itself be an
|
||
// unknown-rule error.)
|
||
return document.querySelector(".slider-track")!;
|
||
}
|
||
|
||
it("grabs the nearer thumb when pressed anywhere on the track", () => {
|
||
const { onChange } = renderSlider();
|
||
// 10% along: nearer the low thumb, which sits at 0. The track is
|
||
// quantile-mapped over n-1 intervals, so 0.1 lands on 9, not 10.
|
||
fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.1, pointerId: 1 });
|
||
expect(onChange).toHaveBeenCalledWith(9, 90);
|
||
});
|
||
|
||
it("grabs the high thumb when the press is nearer to it", () => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.9, pointerId: 1 });
|
||
expect(onChange).toHaveBeenCalledWith(0, 81);
|
||
});
|
||
|
||
it("follows the pointer while dragging", () => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 });
|
||
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
|
||
expect(onChange).toHaveBeenLastCalledWith(27, 90);
|
||
});
|
||
|
||
it("ignores movement when no thumb is held", () => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
|
||
expect(onChange).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("stops dragging on pointer up", () => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 });
|
||
fireEvent.pointerUp(track(), { pointerId: 1 });
|
||
onChange.mockClear();
|
||
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.5, pointerId: 1 });
|
||
expect(onChange).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("ignores a pointer up that was never preceded by a press", () => {
|
||
const { onChange } = renderSlider();
|
||
fireEvent.pointerUp(track(), { pointerId: 1 });
|
||
expect(onChange).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|