src/components/courses/courses-filters.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CERTIFICATION_AGENCIES } from "@/lib/api/certifications";
import { COURSE_STATUSES } from "@/lib/api/courses";
import {
CoursesFilters,
NO_COURSE_FILTERS,
type CourseListFilters,
type CoursesFiltersProps,
} from "./courses-filters";
// The row narrows one query rather than four, so what these pin is that each
// control reports its own field and leaves the other three where they were -
// the failure that turns "PADI courses in 2025" into "PADI courses".
function Row({
initial = NO_COURSE_FILTERS,
...options
}: {
initial?: CourseListFilters;
} & Pick<CoursesFiltersProps, "agencies" | "statuses">) {
const [filters, setFilters] = useState(initial);
return (
<>
<CoursesFilters
filters={filters}
onFiltersChange={setFilters}
{...options}
/>
<output data-testid="asked">{JSON.stringify(filters)}</output>
</>
);
}
const asked = () =>
JSON.parse(screen.getByTestId("asked").textContent ?? "{}") as Record<
string,
string
>;
const optionsOf = (label: string) =>
[...screen.getByLabelText<HTMLSelectElement>(label).options].map(
(option) => option.text,
);
const agencyOptions = () => optionsOf("Agency");
const statusOptions = () => optionsOf("Status");
describe("CoursesFilters", () => {
it("opens on no filter at all, with every control reachable by its label", () => {
render(<Row />);
expect(screen.getByLabelText("From")).toHaveValue("");
expect(screen.getByLabelText("To")).toHaveValue("");
expect(screen.getByLabelText("Agency")).toHaveValue("");
expect(screen.getByLabelText("Status")).toHaveValue("");
});
it("offers an 'any' option on each select, so a pick can be taken back", () => {
render(<Row initial={{ ...NO_COURSE_FILTERS, agency: "padi" }} />);
expect(
screen.getByRole("option", { name: "Any agency" }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Any status" }),
).toBeInTheDocument();
});
it("reports a picked agency and leaves the rest of the row alone", async () => {
render(<Row initial={{ ...NO_COURSE_FILTERS, status: "completed" }} />);
await userEvent.selectOptions(screen.getByLabelText("Agency"), "tdi");
expect(asked()).toMatchObject({ agency: "tdi", status: "completed" });
});
it("reports a picked status by its wire value, not its label", async () => {
render(<Row />);
await userEvent.selectOptions(
screen.getByLabelText("Status"),
"in_progress",
);
expect(asked().status).toBe("in_progress");
});
it("reports a typed date once the field is left", async () => {
render(<Row />);
await userEvent.type(screen.getByLabelText("From"), "2025-01-01");
await userEvent.tab();
expect(asked().dateFrom).toBe("2025-01-01");
});
it("holds both ends of the window at once", async () => {
render(<Row initial={{ ...NO_COURSE_FILTERS, dateFrom: "2025-01-01" }} />);
await userEvent.type(screen.getByLabelText("To"), "2025-12-31");
await userEvent.tab();
expect(asked()).toMatchObject({
dateFrom: "2025-01-01",
dateTo: "2025-12-31",
});
});
it("empties the end of the window when the start moves past it", async () => {
render(
<Row
initial={{
...NO_COURSE_FILTERS,
dateFrom: "2025-01-01",
dateTo: "2025-03-31",
}}
/>,
);
await userEvent.clear(screen.getByLabelText("From"));
await userEvent.type(screen.getByLabelText("From"), "2025-04-01");
await userEvent.tab();
expect(asked()).toMatchObject({ dateFrom: "2025-04-01", dateTo: "" });
});
it("keeps an end the moved start has not passed", async () => {
render(
<Row
initial={{
...NO_COURSE_FILTERS,
dateFrom: "2025-01-01",
dateTo: "2025-03-31",
}}
/>,
);
await userEvent.clear(screen.getByLabelText("From"));
await userEvent.type(screen.getByLabelText("From"), "2025-03-31");
await userEvent.tab();
expect(asked()).toMatchObject({
dateFrom: "2025-03-31",
dateTo: "2025-03-31",
});
});
// Clearing the lot in one go belongs to the button that shuts the panel
// (`CoursesPageFrame`), so the row itself offers only per-control clears.
it("carries no control that clears the row", () => {
render(<Row initial={{ ...NO_COURSE_FILTERS, agency: "padi" }} />);
expect(
screen.queryByRole("button", { name: /^Clear filters/ }),
).not.toBeInTheDocument();
});
// Every other agency is a way to empty the list for a diver who trained with
// one of them. What the selects offer is the vocabulary the courses themselves
// carry, which the page reads off its own list.
it("offers only the agencies and statuses it is given", () => {
render(<Row agencies={["padi", "sdi"]} statuses={["completed"]} />);
expect(agencyOptions()).toEqual(["Any agency", "PADI", "SDI"]);
expect(statusOptions()).toEqual(["Any status", "Completed"]);
});
it("falls back to the whole vocabulary when it is given none", () => {
render(<Row />);
expect(agencyOptions()).toHaveLength(CERTIFICATION_AGENCIES.length + 1);
expect(statusOptions()).toHaveLength(COURSE_STATUSES.length + 1);
});
// The last PADI course deleted while the filter still says PADI. Dropping the
// option would blank the select while the list stayed narrowed by it.
it("keeps a picked value that is no longer in use", () => {
render(
<Row
initial={{ ...NO_COURSE_FILTERS, agency: "padi" }}
agencies={["sdi"]}
/>,
);
expect(screen.getByLabelText("Agency")).toHaveValue("padi");
expect(agencyOptions()).toEqual(["Any agency", "SDI", "PADI"]);
});
});