Ctrl K
ComunidadGuías…

src/components/courses/courses-filters.render.test.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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"]);
  });
});