Ctrl K
ComunidadGuías…

src/components/dives/species-multi-select.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
import { describe, expect, it, vi, beforeEach } from "vitest";
import { useState } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SpeciesMultiSelect } from "./species-multi-select";
import type { SightingWrite } from "@/lib/api/dives";
import type {
  Species,
  SpeciesSuggestion,
  SpeciesSuggestResponse,
  SpeciesSummary,
} from "@/lib/api/species";

// The first six cases pin the rules about what counts as choosing an item in an
// append-only field wrapping `CreatableCombobox`: only a click or Enter chooses,
// never a name typed or a blur. The rest
// cover the thing only this picker does: a pick that has to go to the API before
// it has a value at all.

const toast = vi.hoisted(() => vi.fn());

vi.mock("@/lib/api/species", () => ({
  MIN_SPECIES_QUERY_LENGTH: 2,
  MAX_SPECIES_QUERY_LENGTH: 255,
  speciesAPI: {
    suggestSpecies: vi.fn(),
    resolveSpecies: vi.fn(),
    getSpecies: vi.fn(),
  },
}));

vi.mock("@/components/ui/use-toast", () => ({
  useToast: () => ({ toast }),
}));

const { speciesAPI } = await import("@/lib/api/species");
const suggestSpecies = vi.mocked(speciesAPI.suggestSpecies);
const resolveSpecies = vi.mocked(speciesAPI.resolveSpecies);
const getSpecies = vi.mocked(speciesAPI.getSpecies);

// Already in the catalog, so picking it appends straight away.
const LOCAL: SpeciesSuggestion = {
  aphia_id: 105857,
  uuid: "species-manta",
  scientific_name: "Mobula birostris",
  common_name: "Giant manta ray",
  rank: "Species",
  status: "accepted",
  matched_name: null,
  source: "catalog",
  attribution:
    "[World Register of Marine Species](https://www.marinespecies.org) (CC BY)",
  dive_count_at_sites: 0,
  last_seen: null,
};

// Upstream only, so picking it has to resolve first.
const REMOTE: SpeciesSuggestion = {
  aphia_id: 278400,
  uuid: null,
  scientific_name: "Amphiprion ocellaris",
  common_name: "Ocellaris clownfish",
  rank: "Species",
  status: "accepted",
  matched_name: null,
  source: "wikidata",
  attribution: "Wikidata (CC0)",
  dive_count_at_sites: 0,
  last_seen: null,
};

const RESOLVED: Species = {
  uuid: "species-clownfish",
  aphia_id: 278400,
  scientific_name: "Amphiprion ocellaris",
  common_name: "Ocellaris clownfish",
  authority: "Cuvier, 1830",
  rank: "Species",
  status: "accepted",
  kingdom: "Animalia",
  phylum: "Chordata",
  class_name: "Teleostei",
  order_name: "Perciformes",
  family: "Pomacentridae",
  genus: "Amphiprion",
  is_marine: true,
  is_brackish: null,
  is_freshwater: null,
  wikidata_qid: "Q1126155",
  created_at: "2026-08-19T00:00:00Z",
  // The picker renders no photo, so what these say does not reach the assertions
  // below - they are here because `Species` mirrors a wire schema that always
  // carries them, and a fixture missing a field is a fixture that stops matching.
  photo_sha256: null,
  photo_file: null,
  photo_author: null,
  photo_license: null,
  photo_license_url: null,
  photo_source_url: null,
};

// The two above as the form hands them over, which is what lets a test seed a
// multi-row list without a lookup per row.
const MANTA_SUMMARY: SpeciesSummary = {
  uuid: "species-manta",
  scientific_name: "Mobula birostris",
  common_name: "Giant manta ray",
  rank: "Species",
};

const CLOWNFISH_SUMMARY: SpeciesSummary = {
  uuid: "species-clownfish",
  scientific_name: "Amphiprion ocellaris",
  common_name: "Ocellaris clownfish",
  rank: "Species",
};

const found = (
  results: SpeciesSuggestion[],
  has_more = false,
): SpeciesSuggestResponse => ({ results, has_more });

beforeEach(() => {
  toast.mockReset();
  suggestSpecies.mockReset();
  resolveSpecies.mockReset();
  getSpecies.mockReset();
  suggestSpecies.mockResolvedValue(found([LOCAL, REMOTE]));
});

// The last list the field handed back, so a test can assert what would be
// submitted rather than only what is on screen. Written from `onChange` rather
// than from the render, which would be a side effect during render - and would
// also stop telling "the field reported nothing" apart from "it reported the
// list it already had".
let submitted: SightingWrite[] = [];

function Field({
  initial = [] as SightingWrite[],
  known,
  sites,
}: {
  initial?: SightingWrite[];
  known?: SpeciesSummary[];
  sites?: string[];
}) {
  const [value, setValue] = useState<SightingWrite[]>(initial);
  return (
    <SpeciesMultiSelect
      value={value}
      knownSpecies={known}
      diveSiteUuids={sites}
      onChange={(next) => {
        submitted = next;
        setValue(next);
      }}
    />
  );
}

// Each row as it reads: a sighting by its header, a pending pick whole.
const rows = () =>
  Array.from(document.querySelectorAll("li")).map((li) =>
    (li.querySelector(":scope > div") ?? li).textContent?.trim(),
  );

const openMenu = async () => {
  await userEvent.click(screen.getByRole("combobox"));
  await waitFor(() =>
    expect(
      screen.getByRole("option", { name: /Giant manta ray/ }),
    ).toBeVisible(),
  );
};

describe("SpeciesMultiSelect", () => {
  beforeEach(() => {
    submitted = [];
  });

  it("adds a catalog species when its row is picked", async () => {
    render(<Field />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Giant manta ray/ }),
    );

    await waitFor(() =>
      expect(rows()).toEqual(["Giant manta ray Mobula birostris"]),
    );
    // The species alone: nothing counted and nothing written yet.
    expect(submitted).toEqual([{ species_uuid: "species-manta" }]);
    expect(resolveSpecies).not.toHaveBeenCalled();
  });

  it("adds a species on Enter", async () => {
    render(<Field />);
    await openMenu();

    await userEvent.paste("Giant manta ray");
    await userEvent.keyboard("{Enter}");

    await waitFor(() =>
      expect(submitted).toEqual([{ species_uuid: "species-manta" }]),
    );
  });

  it("adds nothing from typing the name alone", async () => {
    // A name typed on the way to a longer one is not a choice, and this field's
    // `onChange` appends.
    render(<Field />);
    await openMenu();

    await userEvent.paste("Giant manta ray");
    await waitFor(() => expect(suggestSpecies).toHaveBeenCalled());

    expect(rows()).toEqual([]);
  });

  it("says it is searching while the list is loading, not that there is none", async () => {
    suggestSpecies.mockReturnValue(new Promise(() => {}));
    render(<Field />);

    await userEvent.click(screen.getByRole("combobox"));

    expect(await screen.findByText("Searching...")).toBeInTheDocument();
    expect(screen.queryByText("No species found.")).not.toBeInTheDocument();
  });

  it("says the search is down when the opening query fails", async () => {
    suggestSpecies.mockRejectedValue(new Error("500"));
    render(<Field />);

    await userEvent.click(screen.getByRole("combobox"));

    expect(
      await screen.findByText("Search is unavailable right now."),
    ).toBeInTheDocument();
  });

  it("adds nothing on the way out of the field", async () => {
    render(
      <>
        <Field />
        <button type="button">Elsewhere</button>
      </>,
    );
    await openMenu();
    await userEvent.paste("Ocellaris clownfish");

    await userEvent.click(screen.getByRole("button", { name: "Elsewhere" }));

    expect(rows()).toEqual([]);
  });

  it("resolves an upstream pick and appends the uuid it comes back with", async () => {
    // The whole point of the resolve: what reaches form state is a real catalog
    // uuid, never the `aphia:` id the menu row was keyed by.
    resolveSpecies.mockResolvedValue(RESOLVED);
    render(<Field />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );

    await waitFor(() =>
      expect(submitted).toEqual([{ species_uuid: "species-clownfish" }]),
    );
    expect(resolveSpecies).toHaveBeenCalledWith(278400);
    expect(rows()).toEqual(["Ocellaris clownfish Amphiprion ocellaris"]);
  });

  it("shows a pending row while the resolve is in flight", async () => {
    let settle: (species: Species) => void = () => {};
    resolveSpecies.mockReturnValue(
      new Promise<Species>((resolve) => {
        settle = resolve;
      }),
    );
    render(<Field />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );

    // Named, so the diver can see which pick they are waiting on - and nothing
    // has reached form state yet.
    await waitFor(() => expect(rows()).toEqual(["Ocellaris clownfishAdding"]));
    expect(submitted).toEqual([]);

    settle(RESOLVED);
    await waitFor(() =>
      expect(submitted).toEqual([{ species_uuid: "species-clownfish" }]),
    );
  });

  it("reports a pick as pending until the resolve settles", async () => {
    // What stops a save from racing the resolve. Without it the loss is silent:
    // the dive is written without the sighting, and the resolve lands on a form
    // that has already navigated away.
    const onPendingChange = vi.fn();
    let settle: (species: Species) => void = () => {};
    resolveSpecies.mockReturnValue(
      new Promise<Species>((resolve) => {
        settle = resolve;
      }),
    );
    render(
      <SpeciesMultiSelect
        value={[]}
        onChange={() => {}}
        onPendingChange={onPendingChange}
      />,
    );
    await openMenu();
    expect(onPendingChange).toHaveBeenLastCalledWith(false);

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );
    await waitFor(() => expect(onPendingChange).toHaveBeenLastCalledWith(true));

    settle(RESOLVED);
    await waitFor(() =>
      expect(onPendingChange).toHaveBeenLastCalledWith(false),
    );
  });

  it("stops reporting pending when it unmounts mid-resolve", async () => {
    // The picker can now leave the page while a resolve is in flight: hiding
    // Species from the Fields dialog, or applying a preset that hides it, unmounts
    // it. Nothing else would ever report `false` again, so the card's submit button
    // stayed on "Adding species..." until a reload - a form wedged by a switch.
    const onPendingChange = vi.fn();
    resolveSpecies.mockReturnValue(new Promise(() => {}));
    const { unmount } = render(
      <SpeciesMultiSelect
        value={[]}
        onChange={() => {}}
        onPendingChange={onPendingChange}
      />,
    );
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );
    await waitFor(() => expect(onPendingChange).toHaveBeenLastCalledWith(true));

    unmount();

    expect(onPendingChange).toHaveBeenLastCalledWith(false);
  });

  it("stops reporting pending when the resolve fails", async () => {
    // Otherwise a 503 would leave the form's submit button disabled for good.
    const onPendingChange = vi.fn();
    resolveSpecies.mockRejectedValue(new Error("503"));
    render(
      <SpeciesMultiSelect
        value={[]}
        onChange={() => {}}
        onPendingChange={onPendingChange}
      />,
    );
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );

    await waitFor(() => expect(toast).toHaveBeenCalled());
    expect(onPendingChange).toHaveBeenLastCalledWith(false);
  });

  it("keeps a pending species out of the menu, so it can't be picked twice", async () => {
    resolveSpecies.mockReturnValue(new Promise(() => {}));
    render(<Field />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );

    await waitFor(() =>
      expect(
        screen.queryByRole("option", { name: /Ocellaris clownfish/ }),
      ).not.toBeInTheDocument(),
    );
    expect(resolveSpecies).toHaveBeenCalledTimes(1);
  });

  it("toasts and leaves form state untouched when the resolve fails", async () => {
    resolveSpecies.mockRejectedValue(new Error("503"));
    render(<Field />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );

    await waitFor(() => expect(toast).toHaveBeenCalled());
    expect(toast.mock.calls[0][0]).toMatchObject({
      description: "Couldn't add that species.",
      variant: "destructive",
    });
    // The pending row goes too - a spinner that never stops is worse than the
    // pick simply not having happened.
    expect(rows()).toEqual([]);
    expect(submitted).toEqual([]);
  });

  it("keeps both of two resolves that land out of order", async () => {
    // The menu stays open after a pick, so a diver can start a second resolve
    // while the first is still going. `value` captured in the first closure is
    // the empty list, and appending to that would drop whichever landed first.
    const settlers: ((species: Species) => void)[] = [];
    resolveSpecies.mockImplementation(
      (aphiaId: number) =>
        new Promise<Species>((resolve) => {
          settlers.push((species) =>
            resolve({ ...species, aphia_id: aphiaId }),
          );
        }),
    );
    suggestSpecies.mockResolvedValue(
      found([REMOTE, { ...REMOTE, aphia_id: 127405, uuid: null }]),
    );
    render(<Field />);
    await userEvent.click(screen.getByRole("combobox"));
    await waitFor(() =>
      expect(
        screen.getAllByRole("option", { name: /Ocellaris clownfish/ }),
      ).toHaveLength(2),
    );

    await userEvent.click(
      screen.getAllByRole("option", { name: /Ocellaris clownfish/ })[0],
    );
    await waitFor(() => expect(settlers).toHaveLength(1));
    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );
    await waitFor(() => expect(settlers).toHaveLength(2));

    settlers[1]({ ...RESOLVED, uuid: "species-second" });
    settlers[0]({ ...RESOLVED, uuid: "species-first" });

    await waitFor(() =>
      expect(submitted.map((sighting) => sighting.species_uuid).sort()).toEqual(
        ["species-first", "species-second"],
      ),
    );
  });

  it("credits the providers behind whatever the menu showed", async () => {
    render(<Field />);
    await openMenu();

    // The WoRMS credit arrives as a markdown link now, so the assertion is on
    // the anchor rather than on text: printing the string raw would put
    // `[World Register of Marine Species](https://...)` under the field, which
    // is a passing `getByText` and a broken credit line.
    const worms = screen.getByRole("link", {
      name: "World Register of Marine Species",
    });
    expect(worms).toHaveAttribute("href", "https://www.marinespecies.org");
    // The licence rides along as a plain run after the link, which is the half
    // `parseAttribution` would drop if it matched whole strings instead of
    // splitting them.
    expect(screen.getByText(/\(CC BY\)/)).toBeInTheDocument();
    expect(screen.getByText(/Wikidata \(CC0\)/)).toBeInTheDocument();
  });

  it("asks with the form's sites and held species, from the empty query on", async () => {
    render(
      <Field
        initial={[{ species_uuid: "species-turtle" }]}
        known={[
          {
            uuid: "species-turtle",
            scientific_name: "Chelonia mydas",
            common_name: "Green turtle",
            rank: "Species",
          },
        ]}
        sites={["site-a", "site-b"]}
      />,
    );
    await openMenu();

    expect(suggestSpecies).toHaveBeenCalledTimes(1);
    expect(suggestSpecies).toHaveBeenCalledWith("", {
      diveSiteUuids: ["site-a", "site-b"],
      excludeSpeciesUuids: ["species-turtle"],
    });
  });

  it("answers one typed letter with the rows that came back", async () => {
    // The diver's own species filter from the first letter. `minSearchLength`
    // still reads 2, but it only decides what an empty menu says.
    suggestSpecies.mockImplementation(async (query) =>
      found(query ? [LOCAL] : [LOCAL, REMOTE], query !== ""),
    );
    render(<Field />);
    await openMenu();

    await userEvent.keyboard("g");

    await waitFor(() =>
      expect(suggestSpecies).toHaveBeenLastCalledWith("g", {
        diveSiteUuids: undefined,
        excludeSpeciesUuids: [],
      }),
    );
    await waitFor(() =>
      expect(
        screen.queryByRole("option", { name: /Ocellaris clownfish/ }),
      ).not.toBeInTheDocument(),
    );
    expect(
      screen.getByRole("option", { name: /Giant manta ray/ }),
    ).toBeVisible();
    // The one letter never reached the catalog search, which the API says.
    expect(
      screen.getByText("More matches than shown - keep typing to narrow."),
    ).toBeInTheDocument();
  });

  it("says why a row is near the top", async () => {
    suggestSpecies.mockResolvedValue(
      found([
        { ...LOCAL, dive_count_at_sites: 3, last_seen: "2026-03-12T09:00:00Z" },
        {
          ...REMOTE,
          uuid: "species-clownfish",
          source: "catalog",
          last_seen: "2026-03-12T09:00:00Z",
        },
      ]),
    );
    render(<Field sites={["site-a"]} />);
    await openMenu();

    expect(
      screen.getByRole("option", { name: /Giant manta ray/ }),
    ).toHaveTextContent("Mobula birostris · 3 dives here");
    expect(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    ).toHaveTextContent("Amphiprion ocellaris · last seen Mar 12, 2026");
  });

  it("asks again after a pick, without what was just picked", async () => {
    // The server leaves the held species out before it cuts the page, so the
    // refetch is what fills the slot the pick vacated. `excludeIds` would hide
    // the picked row either way; the call's arguments are what pin the refill.
    const NEXT: SpeciesSuggestion = {
      ...LOCAL,
      aphia_id: 137117,
      uuid: "species-turtle",
      scientific_name: "Chelonia mydas",
      common_name: "Green turtle",
    };
    suggestSpecies
      .mockResolvedValueOnce(found([LOCAL, REMOTE]))
      .mockResolvedValueOnce(found([REMOTE, NEXT]));
    render(<Field sites={["site-a"]} />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Giant manta ray/ }),
    );

    await waitFor(() => expect(suggestSpecies).toHaveBeenCalledTimes(2));
    expect(suggestSpecies).toHaveBeenLastCalledWith("", {
      diveSiteUuids: ["site-a"],
      excludeSpeciesUuids: ["species-manta"],
    });
    expect(
      await screen.findByRole("option", { name: /Green turtle/ }),
    ).toBeVisible();
  });

  it("labels a selection it was handed the names for without a lookup", async () => {
    render(
      <SpeciesMultiSelect
        value={[{ species_uuid: "species-manta" }]}
        knownSpecies={[MANTA_SUMMARY]}
        onChange={() => {}}
      />,
    );

    expect(rows()).toEqual(["Giant manta ray Mobula birostris"]);
    expect(getSpecies).not.toHaveBeenCalled();
  });

  it("fetches the name for a selection it was told nothing about", async () => {
    getSpecies.mockResolvedValue(RESOLVED);
    render(
      <SpeciesMultiSelect
        value={[{ species_uuid: "species-clownfish" }]}
        onChange={() => {}}
      />,
    );

    await waitFor(() =>
      expect(rows()).toEqual(["Ocellaris clownfish Amphiprion ocellaris"]),
    );
    expect(getSpecies).toHaveBeenCalledWith("species-clownfish");
  });

  it("names every per-row control after the species it acts on", async () => {
    // Two rows is the smallest list that can prove it: with one, "Remove" and
    // "Remove Giant manta ray" are equally unambiguous. The name is the display
    // name alone - the italic binomial beside it is not part of any label.
    render(
      <SpeciesMultiSelect
        value={[
          { species_uuid: "species-manta" },
          { species_uuid: "species-clownfish" },
        ]}
        knownSpecies={[MANTA_SUMMARY, CLOWNFISH_SUMMARY]}
        onChange={() => {}}
      />,
    );

    for (const name of ["Giant manta ray", "Ocellaris clownfish"]) {
      expect(
        screen.getByRole("button", { name: `Remove ${name}` }),
      ).toBeInTheDocument();
      expect(
        screen.getByRole("textbox", { name: `Count of ${name}` }),
      ).toBeInTheDocument();
      expect(
        screen.getByRole("textbox", { name: `Notes on ${name}` }),
      ).toBeInTheDocument();
    }
    expect(
      screen.getByRole("button", {
        name: /^Reorder Giant manta ray, position 1 of 2\./,
      }),
    ).toBeInTheDocument();
    expect(
      screen.getByRole("button", {
        name: /^Reorder Ocellaris clownfish, position 2 of 2\./,
      }),
    ).toBeInTheDocument();
  });

  it("removes a species without touching the others", async () => {
    render(
      <Field
        initial={[
          { species_uuid: "species-manta", count: 2 },
          {
            species_uuid: "species-clownfish",
            count: 5,
            notes: "In the anemone",
          },
        ]}
        known={[MANTA_SUMMARY, CLOWNFISH_SUMMARY]}
      />,
    );

    await userEvent.click(
      screen.getByRole("button", { name: "Remove Giant manta ray" }),
    );

    // The row that stays keeps what was typed on it.
    expect(submitted).toEqual([
      { species_uuid: "species-clownfish", count: 5, notes: "In the anemone" },
    ]);
  });
});

describe("SpeciesMultiSelect counts and notes", () => {
  beforeEach(() => {
    submitted = [];
  });

  const twoRows = (initial?: SightingWrite[]) =>
    render(
      <Field
        initial={
          initial ?? [
            { species_uuid: "species-manta" },
            { species_uuid: "species-clownfish" },
          ]
        }
        known={[MANTA_SUMMARY, CLOWNFISH_SUMMARY]}
      />,
    );

  const countOf = (name: string) =>
    screen.getByRole("textbox", { name: `Count of ${name}` });
  const notesOn = (name: string) =>
    screen.getByRole("textbox", { name: `Notes on ${name}` });

  it("puts a count typed on a row on that row's sighting", async () => {
    twoRows();

    await userEvent.type(countOf("Ocellaris clownfish"), "12");

    expect(submitted).toEqual([
      { species_uuid: "species-manta" },
      { species_uuid: "species-clownfish", count: 12 },
    ]);
    expect(countOf("Ocellaris clownfish")).toHaveValue("12");
  });

  it("sends no count once the box is emptied, rather than a 0 or a 1", async () => {
    twoRows([
      { species_uuid: "species-manta", count: 3 },
      { species_uuid: "species-clownfish" },
    ]);

    await userEvent.clear(countOf("Giant manta ray"));

    expect(submitted[0]).toEqual({ species_uuid: "species-manta" });
    expect("count" in submitted[0]).toBe(false);
    expect(countOf("Giant manta ray")).toHaveValue("");
  });

  it("takes a positive whole number and nothing else", async () => {
    // Refused keystrokes leave the box as it was, so it never holds a 0, a
    // fraction or anything but digits. The ceiling is the schema's to refuse.
    twoRows();
    const box = countOf("Giant manta ray");

    await userEvent.type(box, "0");
    expect(box).toHaveValue("");
    await userEvent.type(box, "-a.");
    expect(box).toHaveValue("");

    await userEvent.type(box, "2.5");
    expect(box).toHaveValue("25");
    expect(submitted[0]).toEqual({ species_uuid: "species-manta", count: 25 });
  });

  it("keeps the line breaks in a note, on that row's sighting", async () => {
    twoRows();

    await userEvent.type(
      notesOn("Giant manta ray"),
      "Cleaning station{Enter}at 18 m",
    );

    expect(submitted[0]).toEqual({
      species_uuid: "species-manta",
      notes: "Cleaning station\nat 18 m",
    });
    expect(submitted[1]).toEqual({ species_uuid: "species-clownfish" });
  });

  it("sends an empty note once the diver clears it", async () => {
    // What the dive's own notes field sends, and the API's spelling of none.
    twoRows([
      { species_uuid: "species-manta", notes: "Two juveniles" },
      { species_uuid: "species-clownfish" },
    ]);

    await userEvent.clear(notesOn("Giant manta ray"));

    expect(submitted[0]).toEqual({ species_uuid: "species-manta", notes: "" });
  });

  it("moves a row with its count and note when it is reordered", async () => {
    twoRows([
      { species_uuid: "species-manta", count: 2, notes: "Overhead" },
      { species_uuid: "species-clownfish" },
    ]);

    screen.getByRole("button", { name: /^Reorder Giant manta ray/ }).focus();
    await userEvent.keyboard("{ArrowDown}");

    expect(submitted).toEqual([
      { species_uuid: "species-clownfish" },
      { species_uuid: "species-manta", count: 2, notes: "Overhead" },
    ]);
  });

  it("shows neither input on a row still being resolved", async () => {
    // There is nothing in form state for them to write to yet.
    resolveSpecies.mockReturnValue(new Promise(() => {}));
    render(<Field />);
    await openMenu();

    await userEvent.click(
      screen.getByRole("option", { name: /Ocellaris clownfish/ }),
    );

    await waitFor(() => expect(rows()).toEqual(["Ocellaris clownfishAdding"]));
    expect(
      screen.queryByRole("textbox", { name: /Ocellaris clownfish/ }),
    ).not.toBeInTheDocument();
  });

  it("says under its row what the form refused, and marks that input", () => {
    render(
      <SpeciesMultiSelect
        value={[
          { species_uuid: "species-manta" },
          { species_uuid: "species-clownfish", count: 3_000_000_000 },
        ]}
        knownSpecies={[MANTA_SUMMARY, CLOWNFISH_SUMMARY]}
        onChange={() => {}}
        errors={[undefined, { count: "That's more than a count can hold" }]}
      />,
    );

    const box = screen.getByRole("textbox", {
      name: "Count of Ocellaris clownfish",
    });
    expect(box).toHaveAttribute("aria-invalid", "true");
    expect(box).toHaveAccessibleDescription(
      "That's more than a count can hold",
    );
    expect(
      screen.getByRole("textbox", { name: "Count of Giant manta ray" }),
    ).not.toHaveAttribute("aria-invalid");
  });
});

describe("SpeciesMultiSelect asks how many as one question", () => {
  beforeEach(() => {
    submitted = [];
  });

  const oneRow = (initial: SightingWrite) =>
    render(<Field initial={[initial]} known={[MANTA_SUMMARY]} />);

  const choices = () =>
    within(screen.getByRole("group", { name: "How many Giant manta ray" }));
  const choice = (name: RegExp) => choices().getByRole("button", { name });
  const countBox = () =>
    screen.getByRole("textbox", { name: "Count of Giant manta ray" });

  it("labels each band with its range", () => {
    oneRow({ species_uuid: "species-manta" });

    expect(
      choices()
        .getAllByRole("button")
        .map((button) => button.getAttribute("aria-label")),
    ).toEqual(["One", "Few, 2–10", "Many, 11–100", "Abundant, 101+"]);
    for (const button of choices().getAllByRole("button")) {
      expect(button).toHaveAttribute("aria-pressed", "false");
    }
  });

  it("stores a chosen band alone, clearing the count box", async () => {
    oneRow({ species_uuid: "species-manta", count: 4 });

    await userEvent.click(choice(/^Many/));

    expect(submitted).toEqual([
      { species_uuid: "species-manta", abundance: "many" },
    ]);
    expect(countBox()).toHaveValue("");
    expect(choice(/^Many/)).toHaveAttribute("aria-pressed", "true");
  });

  it("stores one as a count of 1 and no band", async () => {
    oneRow({ species_uuid: "species-manta", abundance: "few" });

    await userEvent.click(choice(/^One/));

    expect(submitted).toEqual([{ species_uuid: "species-manta", count: 1 }]);
    expect(countBox()).toHaveValue("1");
    expect(choice(/^One/)).toHaveAttribute("aria-pressed", "true");
    expect(choice(/^Few/)).toHaveAttribute("aria-pressed", "false");
  });

  it("takes the answer off when the chosen choice is chosen again", async () => {
    oneRow({ species_uuid: "species-manta", abundance: "abundant" });

    await userEvent.click(choice(/^Abundant/));
    expect(submitted).toEqual([{ species_uuid: "species-manta" }]);

    await userEvent.click(choice(/^One/));
    await userEvent.click(choice(/^One/));
    expect(submitted).toEqual([{ species_uuid: "species-manta" }]);
    expect(countBox()).toHaveValue("");
  });

  it("clears a chosen band when a count is typed", async () => {
    oneRow({ species_uuid: "species-manta", abundance: "few" });

    await userEvent.type(countBox(), "37");

    expect(submitted).toEqual([{ species_uuid: "species-manta", count: 37 }]);
    expect(choice(/^Few/)).toHaveAttribute("aria-pressed", "false");
  });

  it("marks the band a typed count falls in without choosing it", async () => {
    oneRow({ species_uuid: "species-manta", count: 37 });

    const many = choice(/^Many/);
    expect(many).toHaveAttribute("aria-pressed", "false");
    expect(many).toHaveClass("border-dashed");
    expect(choice(/^Few/)).not.toHaveClass("border-dashed");

    await userEvent.click(many);

    expect(submitted).toEqual([
      { species_uuid: "species-manta", abundance: "many" },
    ]);
    expect(countBox()).toHaveValue("");
    expect(choice(/^Many/)).toHaveAttribute("aria-pressed", "true");
    expect(choice(/^Many/)).not.toHaveClass("border-dashed");
  });

  it("marks no band for a count of one", () => {
    oneRow({ species_uuid: "species-manta", count: 1 });

    for (const name of [/^Few/, /^Many/, /^Abundant/]) {
      expect(choice(name)).not.toHaveClass("border-dashed");
    }
  });

  it("carries neither once the box is emptied", async () => {
    oneRow({ species_uuid: "species-manta", count: 12 });

    await userEvent.clear(countBox());

    expect(submitted).toEqual([{ species_uuid: "species-manta" }]);
  });

  it("says a band's refusal under its row", () => {
    render(
      <SpeciesMultiSelect
        value={[{ species_uuid: "species-manta", abundance: "few" }]}
        knownSpecies={[MANTA_SUMMARY]}
        onChange={() => {}}
        errors={[
          { abundance: "A sighting carries a count or a band, not both" },
        ]}
      />,
    );

    expect(
      choices().getByRole("button", { name: /^Few/ }).closest("li"),
    ).toHaveTextContent("A sighting carries a count or a band, not both");
    expect(
      screen.getByRole("group", { name: "How many Giant manta ray" }),
    ).toHaveAccessibleDescription(
      "A sighting carries a count or a band, not both",
    );
  });
});