- 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.
83 lines
2.8 KiB
TypeScript
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();
|
|
});
|
|
});
|