Files
siftlode/frontend/src/components/SortControl.dom.test.tsx
T
peter ccd7170a20 fix(sort): harden shared sort primitives (R13 S1 review round 2)
- SortControl no longer renders a blank select when an always-sorted view
  (no defaultLabel) is handed a key its options don't offer: it falls back to
  the first option instead of value="" (which also tripped React's controlled
  warning). Robustness now lives in the primitive, not each caller.
- PLEX_SORT_FALLBACK is Object.frozen: it's handed out by reference from
  healSort/healPlexSort and stored as provider state, so freezing turns any
  accidental in-place SortState mutation into a loud error instead of silently
  corrupting the shared fallback.
- Test: SortControl falls back to the first option for an unavailable key.

Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 106.
2026-08-14 00:10:13 +02:00

83 lines
2.8 KiB
TypeScript

// @vitest-environment jsdom
//
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
// columnSort.test.ts). Labels are plain props here — no i18n needed.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import SortControl from "./SortControl";
import type { SortOption, SortState } from "../lib/columnSort";
const OPTS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" },
{ key: "shuffle", label: "Shuffle", directionless: true },
];
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
const onChange = vi.fn();
render(
<SortControl
options={OPTS}
sort={sort}
onChange={onChange}
label="Sort"
ascLabel="Ascending"
descLabel="Descending"
{...extra}
/>,
);
return { onChange };
}
afterEach(cleanup);
describe("SortControl", () => {
it("renders one option per SortOption, no empty option without defaultLabel", () => {
renderControl({ key: "title", dir: "asc" });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"Date",
"Title",
"Shuffle",
]);
});
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
renderControl(null, { defaultLabel: "None" });
const none = screen.getByRole("option", { name: "None" }) as HTMLOptionElement;
expect(none.value).toBe("");
});
it("falls back to the first option (not a blank select) for an unavailable key without defaultLabel", () => {
renderControl({ key: "ghost", dir: "asc" });
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("date");
});
it("picking a new option fires onChange at that option's defaultDir", () => {
const { onChange } = renderControl({ key: "title", dir: "asc" });
fireEvent.change(screen.getByRole("combobox"), { target: { value: "date" } });
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
});
it("shows the direction toggle for a directional sort and flips it on click", () => {
const { onChange } = renderControl({ key: "date", dir: "desc" });
const toggle = screen.getByRole("button", { name: "Descending" });
fireEvent.click(toggle);
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
});
it("hides the direction toggle for a directionless sort", () => {
renderControl({ key: "shuffle", dir: "desc" });
expect(screen.queryByRole("button")).toBeNull();
});
it("renders the trailing adornment", () => {
renderControl(
{ key: "shuffle", dir: "desc" },
{
trailing: <button>Reshuffle</button>,
},
);
expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy();
});
});