Ctrl K
ComunidadGuías…

src/components/certifications/certification-dialog.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CertificationDialog } from "./certification-dialog";
import type { Certification } from "@/lib/api/certifications";
import type { Course, CourseLookupItem } from "@/lib/api/courses";
import type { Contact } from "@/lib/api/contacts";
import type { Person } from "@/lib/api/people";

vi.mock("@/lib/api/certifications", async (importOriginal) => ({
  // The agency vocabulary, its labels and the form's default are real: the
  // picker's options and the create form's starting value both come from them,
  // and stubbing them would make this test agree with itself rather than with
  // the API.
  ...(await importOriginal<typeof import("@/lib/api/certifications")>()),
  certificationsAPI: {
    createCertification: vi.fn(),
    updateCertification: vi.fn(),
    getCertification: vi.fn(),
    uploadCertificationFile: vi.fn(),
    deleteCertificationFile: vi.fn(),
    getCertificationFileBlob: vi.fn(() => new Promise<Blob>(() => {})),
  },
}));

// jsdom has neither canvas nor an image decoder, so the two steps between picking
// a file and holding its cropped bytes stand in.
vi.mock("@/lib/image-crop", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/image-crop")>()),
  cropToBlob: vi.fn(),
  decodeImage: vi.fn(),
}));

// The cropper measures itself with a ResizeObserver that reports zeroes here, so
// it stands in as the one thing the form cares about: a Save that hands back a
// crop rectangle.
vi.mock("@/components/ui/image-crop-dialog", () => ({
  ImageCropDialog: ({
    saveLabel,
    onSave,
  }: {
    saveLabel: string;
    onSave: (area: {
      x: number;
      y: number;
      width: number;
      height: number;
    }) => void;
  }) => (
    <button
      type="button"
      onClick={() => onSave({ x: 0, y: 0, width: 1013, height: 638 })}
    >
      {saveLabel}
    </button>
  ),
}));

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

vi.mock("@/lib/api/courses", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/courses")>()),
  coursesAPI: {
    lookupCourses: vi.fn(),
    lookupCoursesByUuid: vi.fn(),
    getCourse: vi.fn(),
    createCourse: vi.fn(),
  },
}));

vi.mock("@/lib/api/contacts", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/contacts")>()),
  contactsAPI: {
    lookupContacts: vi.fn(),
    getContact: vi.fn(),
    createContact: vi.fn(),
  },
}));

vi.mock("@/lib/api/people", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/people")>()),
  peopleAPI: {
    lookupPeople: vi.fn(),
    getPerson: vi.fn(),
    createPerson: vi.fn(),
  },
}));

const { certificationsAPI } = await import("@/lib/api/certifications");
const { coursesAPI } = await import("@/lib/api/courses");
const { contactsAPI } = await import("@/lib/api/contacts");
const { peopleAPI } = await import("@/lib/api/people");
const { cropToBlob, decodeImage } = await import("@/lib/image-crop");
const createCertification = vi.mocked(certificationsAPI.createCertification);
const updateCertification = vi.mocked(certificationsAPI.updateCertification);
const getCertification = vi.mocked(certificationsAPI.getCertification);
const uploadFile = vi.mocked(certificationsAPI.uploadCertificationFile);
const deleteFile = vi.mocked(certificationsAPI.deleteCertificationFile);
const crop = vi.mocked(cropToBlob);
const decode = vi.mocked(decodeImage);
const CROPPED = new Blob(["cropped"], { type: "image/webp" });
const lookupCourses = vi.mocked(coursesAPI.lookupCourses);
const getCourse = vi.mocked(coursesAPI.getCourse);
const createCourse = vi.mocked(coursesAPI.createCourse);
const lookupContacts = vi.mocked(contactsAPI.lookupContacts);
const getContact = vi.mocked(contactsAPI.getContact);
const createContact = vi.mocked(contactsAPI.createContact);
const lookupPeople = vi.mocked(peopleAPI.lookupPeople);
const getPerson = vi.mocked(peopleAPI.getPerson);
const createPerson = vi.mocked(peopleAPI.createPerson);

const contact = (uuid: string, name: string): Contact => ({
  uuid,
  name,
  roles: ["school"],
  notes: "",
  user_uuid: "user-1",
  created_at: "2026-03-01T09:00:00Z",
});

const BLUE_OCEAN = contact("contact-blue", "Blue Ocean, Koh Tao");
const RED_SEA = contact("contact-red", "Red Sea Divers");
const MY_SHOP = contact("contact-mine", "My Shop");
const OLD_SHOP = contact("contact-old", "Old Shop");
const NO_SHOP = contact("contact-none", "No shop");
const CONTACTS = [BLUE_OCEAN, RED_SEA, MY_SHOP, OLD_SHOP, NO_SHOP];

const person = (uuid: string, name: string): Person => ({
  uuid,
  name,
  notes: "",
  username: null,
  dive_count: 0,
  created_at: "2026-03-01T09:00:00Z",
});

const ALEX = person("person-alex", "Alex Diver");
const SAM = person("person-sam", "Sam Reef");
const KIM = person("person-kim", "Kim Solo");
const JO = person("person-jo", "Jo Teacher");
const PEOPLE = [ALEX, SAM, KIM, JO];

const STUDENT = person("person-student", "Pat Student");

// A course's instructor is a reference on its people, and not necessarily the
// first one - the card takes the first *instructor*.
const instructedBy = (who: Person) => [
  { person_uuid: STUDENT.uuid, role: "student" },
  { person_uuid: who.uuid, role: "instructor" },
];

const course = (overrides: Partial<Course> = {}): Course => ({
  uuid: "course-1",
  name: "Advanced Nitrox + Deco",
  agency: "tdi",
  agency_other: null,
  status: "completed",
  start_date: "2026-03-02",
  end_date: "2026-03-06",
  instructor_number: "123",
  contact_uuid: BLUE_OCEAN.uuid,
  people: instructedBy(ALEX),
  notes: "Ran the 21m and 30m dives on back gas.",
  user_uuid: "user-1",
  created_at: "2026-03-08T09:00:00Z",
  ...overrides,
});

// A referral: a different centre and instructor, no instructor number, and an
// agency the certification form does not default to either.
const OTHER_COURSE = course({
  uuid: "course-2",
  name: "Rescue Diver, Dahab",
  agency: "ssi",
  people: instructedBy(SAM),
  instructor_number: null,
  contact_uuid: RED_SEA.uuid,
  notes: "",
});

// A course run by a private instructor: no agency to hand over, while every
// other prefillable field is its own.
const AGENCYLESS_COURSE = course({
  uuid: "course-4",
  name: "Sidemount, with Kim",
  agency: null,
  people: instructedBy(KIM),
  instructor_number: "IND-3",
  contact_uuid: NO_SHOP.uuid,
});

// A course that names no contact at all.
const CONTACTLESS_COURSE = course({
  uuid: "course-5",
  name: "Deep Diver, self-study",
  contact_uuid: null,
});

const COURSE = course();

const EXISTING: Certification = {
  uuid: "cert-1",
  agency: "padi",
  agency_other: null,
  name: "Open Water Diver",
  certification_number: "OW-1",
  certified_on: "2020-05-01",
  expires_on: null,
  instructor_uuid: JO.uuid,
  instructor_number: "OLD-9",
  contact_uuid: OLD_SHOP.uuid,
  notes: "",
  course_uuid: null,
  user_uuid: "user-1",
  created_at: "2020-05-02T09:00:00Z",
};

const page = <T,>(items: T[]) => ({
  data: items,
  total_count: items.length,
  has_more: false,
  page: 1,
  items_per_page: 25,
});

beforeEach(() => {
  vi.clearAllMocks();
  createCertification.mockResolvedValue({ ...EXISTING, uuid: "cert-new" });
  updateCertification.mockResolvedValue({ message: "ok" });
  getCertification.mockResolvedValue(EXISTING);
  uploadFile.mockResolvedValue({
    uuid: "file-1",
    side: "front",
    content_type: "image/webp",
    byte_size: 7,
    original_filename: "card-front.webp",
  });
  deleteFile.mockResolvedValue({ message: "ok" });
  decode.mockResolvedValue({} as HTMLImageElement);
  crop.mockResolvedValue(CROPPED);
  // The server filters by `search`; the stub answers every query with both
  // courses, so a second pick doesn't depend on re-typing the exact name.
  listCourses([COURSE, OTHER_COURSE]);
  // The picker shows a contact's name once it has looked the uuid up, which is
  // what these tests read the field's value off.
  lookupContacts.mockImplementation(async () => page(CONTACTS));
  getContact.mockImplementation(async (uuid: string) => {
    const found = CONTACTS.find((one) => one.uuid === uuid);
    if (!found) throw new Error("not found");
    return found;
  });
  // And the instructor's, the same way.
  lookupPeople.mockImplementation(async () => page(PEOPLE));
  getPerson.mockImplementation(async (uuid: string) => {
    const found = PEOPLE.find((one) => one.uuid === uuid);
    if (!found) throw new Error("not found");
    return found;
  });
});

// The menu lists lookup rows and a held uuid resolves through the lookup, both
// from the one list a test sets up, holding only what the lookup carries.
function listCourses(courses: Course[]) {
  const rows = courses.map(lookupRow);
  lookupCourses.mockImplementation(async () => page(rows));
  vi.mocked(coursesAPI.lookupCoursesByUuid).mockImplementation(async (uuids) =>
    rows.filter((one) => uuids.includes(one.uuid)),
  );
}

const lookupRow = ({
  uuid,
  name,
  agency,
  agency_other,
  instructor_number,
  contact_uuid,
  people,
}: Course): CourseLookupItem => ({
  uuid,
  name,
  agency,
  agency_other,
  instructor_number,
  contact_uuid,
  people,
});

function open({
  certification,
  initialCourse,
}: { certification?: Certification; initialCourse?: Course } = {}) {
  const onSaved = vi.fn();
  render(
    <CertificationDialog
      open
      onOpenChange={vi.fn()}
      certification={certification}
      initialCourse={initialCourse}
      onSaved={onSaved}
    />,
  );
  return onSaved;
}

// Opens the course menu and picks one by name. Clicking rather than typing:
// the menu's own search runs on focus, and a pick is the path every one of
// these invariants is about.
async function pickCourse(name: string) {
  await userEvent.click(screen.getByLabelText("Course"));
  await userEvent.click(await screen.findByRole("option", { name }));
}

// The diver's own choice of dive center, made the same way.
async function pickDiveCenter(name: string) {
  await userEvent.click(diveCenter());
  await userEvent.click(await screen.findByRole("option", { name }));
}

const diveCenter = () => screen.getByLabelText("Dive center");
// A picker's own Clear button, which sits beside its input - the date fields
// carry one each too.
const clearOf = (input: HTMLElement) =>
  within(input.parentElement!).getByRole("button", { name: "Clear" });
const instructor = () => screen.getByLabelText("Instructor");
const instructorNumber = () => screen.getByLabelText("Instructor number");
const agency = () => screen.getByLabelText("Agency *");
const certificationName = () => screen.getByLabelText("Certification *");

const save = () =>
  userEvent.click(
    screen.getByRole("button", { name: /Create certification|Save changes/ }),
  );

describe("the card's two dates are a range", () => {
  it("empties the expiry when the certified date moves past it", async () => {
    open({ certification: { ...EXISTING, expires_on: "2022-05-01" } });

    const certifiedOn = screen.getByLabelText("Certified on");
    await waitFor(() => expect(certifiedOn).toHaveValue("2020-05-01"));
    await userEvent.clear(certifiedOn);
    await userEvent.type(certifiedOn, "2023-01-15");
    await userEvent.tab();

    expect(screen.getByLabelText("Expires on")).toHaveValue("");
    await save();

    await waitFor(() => expect(updateCertification).toHaveBeenCalled());
    expect(updateCertification.mock.calls[0][1]).toMatchObject({
      certified_on: "2023-01-15",
      expires_on: null,
    });
  });
});

describe("picking a course fills the card's own fields in", () => {
  it("copies the level, dive center, instructor and agency across from the lookup row", async () => {
    open();

    await pickCourse(COURSE.name);
    expect(getCourse).not.toHaveBeenCalled();

    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    expect(certificationName()).toHaveValue("Advanced Nitrox + Deco");
    // The course's instructor, not the first person on it.
    await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
    expect(instructorNumber()).toHaveValue("123");
    // Despite `padi` being the create form's default rather than an empty box,
    // which is what rules a fill-only-if-empty prefill out.
    expect(agency()).toHaveTextContent("TDI");
  });

  it("leaves the notes for the diver to write about the card", async () => {
    // A course's notes describe the training and a card's describe the card, so
    // a course value here would be plausible-but-wrong and saved unread. The
    // level is prefilled and the notes are not, which is the whole difference.
    open();

    await pickCourse(COURSE.name);

    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    expect(screen.getByLabelText("Notes")).toHaveValue("");
  });

  it("keeps a level the diver typed off the card in their hand", async () => {
    // The course name is often longer than what the card says, and one course
    // can issue two differently-named cards - so this is the field the
    // typed-into guard matters most for.
    open();

    await userEvent.type(certificationName(), "Advanced Nitrox");
    await pickCourse(COURSE.name);

    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    expect(certificationName()).toHaveValue("Advanced Nitrox");
  });

  it("never overwrites a dive center the diver picked", async () => {
    open();

    await pickDiveCenter(MY_SHOP.name);
    await pickCourse(COURSE.name);

    await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
    expect(diveCenter()).toHaveValue(MY_SHOP.name);

    await save();
    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      contact_uuid: MY_SHOP.uuid,
    });
  });

  it("replaces its own earlier prefill when the course changes, and empties what the new one lacks", async () => {
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(instructorNumber()).toHaveValue("123"));
    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));

    await pickCourse(OTHER_COURSE.name);

    await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
    // The second course has no instructor number, so the first one's must go -
    // keeping it would attribute course A's instructor number to course B.
    expect(instructorNumber()).toHaveValue("");
    expect(agency()).toHaveTextContent("SSI");
    await waitFor(() => expect(diveCenter()).toHaveValue(RED_SEA.name));
  });

  it("keeps the diver's dive center two courses later", async () => {
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    await pickDiveCenter(MY_SHOP.name);

    await pickCourse(OTHER_COURSE.name);

    await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
    expect(diveCenter()).toHaveValue(MY_SHOP.name);
  });

  it("takes back a dive center it copied when the next course names none", async () => {
    // What the dialog wrote is the course's, and a course naming nobody has no
    // contact to leave behind - course A's must not be filed under course B.
    listCourses([COURSE, CONTACTLESS_COURSE]);
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));

    await pickCourse(CONTACTLESS_COURSE.name);

    await waitFor(() => expect(diveCenter()).toHaveValue(""));
    await save();
    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      course_uuid: CONTACTLESS_COURSE.uuid,
      contact_uuid: null,
    });
  });

  it("still replaces its own prefill after an unrelated field is cleared back to empty", async () => {
    // The regression this guards is not hypothetical: react-hook-form recomputes
    // `dirtyFields` for the *whole* form the moment any field is edited back to
    // its default, and a silently prefilled field differs from its default by
    // construction - so a dirtiness-based prefill marks every prefilled field
    // dirty here and quietly stops replacing them. See DECISIONS.md, "A silently
    // prefilled field is not a clean field".
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));

    await userEvent.type(certificationName(), "x");
    await userEvent.clear(certificationName());

    await pickCourse(OTHER_COURSE.name);

    await waitFor(() => expect(diveCenter()).toHaveValue(RED_SEA.name));
    await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
  });

  it("keeps every field when the course is cleared, and unlinks only", async () => {
    // Clearing asserts "no logged course", not "those facts are wrong" - a
    // certification is designed to stand alone.
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));

    await userEvent.click(clearOf(screen.getByLabelText("Course")));
    expect(diveCenter()).toHaveValue(BLUE_OCEAN.name);

    await userEvent.type(certificationName(), "Advanced Nitrox");
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      course_uuid: null,
      contact_uuid: BLUE_OCEAN.uuid,
      instructor_uuid: ALEX.uuid,
      agency: "tdi",
    });
  });

  it("takes back an instructor it copied when the next course names none", async () => {
    listCourses([
      COURSE,
      course({ uuid: "course-6", name: "Self-study", people: [] }),
    ]);
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));

    await pickCourse("Self-study");

    await waitFor(() => expect(instructor()).toHaveValue(""));
    await save();
    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      instructor_uuid: null,
    });
  });

  it("sends no instructor's name, only the person", async () => {
    // The name went with the text field; the pick has said who it is.
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).not.toHaveProperty(
      "instructor_name",
    );
  });

  it("sends no training center, only the contact", async () => {
    // A name sent as text would be matched to a contact, or make one, on the
    // API's side; the picker has already said which contact it is.
    open();

    await pickCourse(COURSE.name);
    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).not.toHaveProperty(
      "training_center",
    );
  });

  it("leaves the card's own agency alone for a course that names none", async () => {
    // A course's agency is optional and a certification's is required, so the
    // one field the prefill must not empty is this one.
    listCourses([AGENCYLESS_COURSE]);

    open();
    await pickCourse(AGENCYLESS_COURSE.name);

    await waitFor(() => expect(diveCenter()).toHaveValue(NO_SHOP.name));
    await waitFor(() => expect(instructor()).toHaveValue("Kim Solo"));
    expect(agency()).toHaveTextContent("PADI");
    expect(screen.queryByLabelText("Agency name *")).not.toBeInTheDocument();

    await userEvent.type(certificationName(), "Sidemount Diver");
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      agency: "padi",
      agency_other: null,
      contact_uuid: NO_SHOP.uuid,
    });
  });

  it("keeps an agency it copied itself when the next course names none", async () => {
    // The one exception to "switching A -> B empties what B lacks": neither
    // half of the pair is blanked, because the result would be unsubmittable.
    listCourses([COURSE, AGENCYLESS_COURSE]);

    open();
    await pickCourse(COURSE.name);
    await waitFor(() => expect(agency()).toHaveTextContent("TDI"));

    await pickCourse(AGENCYLESS_COURSE.name);

    await waitFor(() => expect(instructor()).toHaveValue("Kim Solo"));
    expect(instructorNumber()).toHaveValue("IND-3");
    expect(agency()).toHaveTextContent("TDI");
  });

  it("sends the agency pair the API validates together", async () => {
    // A named agency carrying an `agency_other` is a 422, and so is "other"
    // without one - so the two are only ever copied as a pair.
    const namedOther = course({
      uuid: "course-3",
      name: "Nitrox, FFESSM",
      agency: "other",
      agency_other: "FFESSM",
    });
    listCourses([namedOther]);

    open();
    await pickCourse(namedOther.name);

    // The picker itself reads "Other"; the name it stands for is the second
    // field, which only exists while the agency is "other" - so a prefill that
    // moved one without the other would leave the form unsubmittable.
    await waitFor(() => expect(agency()).toHaveTextContent("Other"));
    expect(await screen.findByLabelText("Agency name *")).toHaveValue("FFESSM");

    await userEvent.type(certificationName(), "Nitrox");
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      agency: "other",
      agency_other: "FFESSM",
    });
  });
});

describe("the edit dialog's values are a pure function of the card being edited", () => {
  it("changes nothing but the link when the course is relinked", async () => {
    open({ certification: EXISTING });

    await waitFor(() => expect(diveCenter()).toHaveValue(OLD_SHOP.name));

    await pickCourse(COURSE.name);

    // Every field still the card's own, however tempting the course's are.
    await waitFor(() =>
      expect(screen.getByLabelText("Course")).toHaveValue(COURSE.name),
    );
    expect(diveCenter()).toHaveValue(OLD_SHOP.name);
    await waitFor(() => expect(instructor()).toHaveValue("Jo Teacher"));
    expect(instructorNumber()).toHaveValue("OLD-9");
    expect(agency()).toHaveTextContent("PADI");

    await save();

    await waitFor(() => expect(updateCertification).toHaveBeenCalled());
    expect(updateCertification.mock.calls[0][1]).toMatchObject({
      course_uuid: COURSE.uuid,
      contact_uuid: OLD_SHOP.uuid,
      instructor_uuid: JO.uuid,
      agency: "padi",
    });
  });

  it("unlinks the instructor when the diver clears it", async () => {
    open({ certification: EXISTING });
    await waitFor(() => expect(instructor()).toHaveValue("Jo Teacher"));

    await userEvent.click(clearOf(instructor()));
    await save();

    await waitFor(() => expect(updateCertification).toHaveBeenCalled());
    expect(updateCertification.mock.calls[0][1]).toMatchObject({
      instructor_uuid: null,
    });
  });

  it("unlinks the contact when the diver clears it", async () => {
    // `null`, not an omitted key: the update leaves an absent key alone, so a
    // cleared picker has to say so for the link to go.
    open({ certification: EXISTING });
    await waitFor(() => expect(diveCenter()).toHaveValue(OLD_SHOP.name));

    await userEvent.click(clearOf(diveCenter()));
    await save();

    await waitFor(() => expect(updateCertification).toHaveBeenCalled());
    expect(updateCertification.mock.calls[0][1]).toMatchObject({
      contact_uuid: null,
    });
  });
});

describe("a dialog opened from a course page starts on that course", () => {
  it("opens pre-linked and prefilled", async () => {
    open({ initialCourse: COURSE });

    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
    expect(instructorNumber()).toHaveValue("123");
    expect(agency()).toHaveTextContent("TDI");
    expect(certificationName()).toHaveValue("Advanced Nitrox + Deco");

    // The seeded level is a starting point, not an answer: this course issues a
    // card whose printed name is shorter than the course's own.
    await userEvent.clear(certificationName());
    await userEvent.type(certificationName(), "Advanced Nitrox");
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      name: "Advanced Nitrox",
      course_uuid: COURSE.uuid,
      contact_uuid: BLUE_OCEAN.uuid,
    });
  });

  it("opens on its own agency when the seed course names none", async () => {
    // The seeded path is the only one that could open a create dialog with its
    // required agency unset, so it answers the field from the form's default.
    open({ initialCourse: AGENCYLESS_COURSE });

    await waitFor(() => expect(diveCenter()).toHaveValue(NO_SHOP.name));
    expect(agency()).toHaveTextContent("PADI");

    await userEvent.clear(certificationName());
    await userEvent.type(certificationName(), "Sidemount Diver");
    await save();

    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      agency: "padi",
      agency_other: null,
      course_uuid: AGENCYLESS_COURSE.uuid,
    });
  });

  it("still protects what the diver picks over the seeded values", async () => {
    open({ initialCourse: COURSE });

    await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
    await pickDiveCenter(MY_SHOP.name);

    await pickCourse(OTHER_COURSE.name);

    await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
    expect(diveCenter()).toHaveValue(MY_SHOP.name);
  });
});

describe("the instructor is a person", () => {
  it("makes a typed name a person on Enter, and names them by uuid", async () => {
    // Naming a new instructor takes the steps typing one did: the name, then
    // Enter - no dialog.
    const created = person("person-new", "Robin Reef");
    lookupPeople.mockImplementation(async () => page([]));
    createPerson.mockResolvedValue(created);
    open();

    await userEvent.type(certificationName(), "Rescue Diver");
    await userEvent.click(instructor());
    await screen.findByText(/No people yet/);
    await userEvent.type(instructor(), "Robin Reef{Enter}");

    await waitFor(() => expect(instructor()).toHaveValue("Robin Reef"));
    expect(createPerson).toHaveBeenCalledWith({ name: "Robin Reef" });

    await save();
    await waitFor(() => expect(createCertification).toHaveBeenCalled());
    expect(createCertification.mock.calls[0][0]).toMatchObject({
      instructor_uuid: created.uuid,
    });
  });

  it("files no half-typed name on the way out of the field", async () => {
    lookupPeople.mockImplementation(async () => page([]));
    open();

    await userEvent.click(instructor());
    await userEvent.type(instructor(), "Rob");
    await userEvent.click(certificationName());

    expect(createPerson).not.toHaveBeenCalled();
  });
});

describe("a contact made three dialogs deep", () => {
  it("comes back through the course dialog into the certification", async () => {
    // Certification -> "Add course..." -> "Add dive center...": three forms, one
    // above the other, each portalled to the body. Each inner save has to land
    // in the form that opened it and nowhere else - the course dialog selects
    // the new contact, the certification selects the new course and copies the
    // contact across from it, and the certification's own field keeps its text.
    const created = contact("contact-new", "Sea Dragon Diving");
    createContact.mockResolvedValue(created);
    getContact.mockImplementation(async (uuid: string) => {
      const found = [...CONTACTS, created].find((one) => one.uuid === uuid);
      if (!found) throw new Error("not found");
      return found;
    });
    createCourse.mockImplementation(async (body) =>
      course({
        uuid: "course-new",
        name: body.name,
        contact_uuid: body.contact_uuid ?? null,
        people: [],
        instructor_number: null,
      }),
    );
    open();
    await userEvent.type(certificationName(), "Deep Diver");

    await userEvent.click(screen.getByLabelText("Course"));
    await userEvent.click(
      await screen.findByRole("option", { name: "Add course..." }),
    );
    const courseDialog = await screen.findByRole("dialog", {
      name: "New Course",
    });
    await userEvent.type(
      within(courseDialog).getByLabelText("Course *"),
      "Deep Specialty",
    );

    await userEvent.click(within(courseDialog).getByLabelText("Dive center"));
    await userEvent.click(
      await screen.findByRole("option", { name: "Add dive center..." }),
    );
    const contactDialog = await screen.findByRole("dialog", {
      name: "New Contact",
    });
    // The role a course's contact starts with, already ticked.
    expect(within(contactDialog).getByLabelText("School")).toBeChecked();
    await userEvent.type(
      within(contactDialog).getByLabelText("Name *"),
      created.name,
    );
    await userEvent.click(
      within(contactDialog).getByRole("button", { name: "Create contact" }),
    );

    await waitFor(() =>
      expect(
        screen.queryByRole("dialog", { name: "New Contact" }),
      ).not.toBeInTheDocument(),
    );
    expect(createContact).toHaveBeenCalledWith(
      expect.objectContaining({ name: created.name, roles: ["school"] }),
    );
    expect(within(courseDialog).getByLabelText("Dive center")).toHaveValue(
      created.name,
    );
    expect(createCertification).not.toHaveBeenCalled();

    await userEvent.click(
      within(courseDialog).getByRole("button", { name: "Create course" }),
    );
    await waitFor(() =>
      expect(
        screen.queryByRole("dialog", { name: "New Course" }),
      ).not.toBeInTheDocument(),
    );
    expect(createCourse).toHaveBeenCalledWith(
      expect.objectContaining({ contact_uuid: created.uuid }),
    );

    expect(screen.getByLabelText("Course")).toHaveValue("Deep Specialty");
    await waitFor(() => expect(diveCenter()).toHaveValue(created.name));
    expect(certificationName()).toHaveValue("Deep Diver");
    expect(createCertification).not.toHaveBeenCalled();
  });
});

// A photo file, small enough to pass the size check this form makes before the
// API's own.
const photo = () =>
  new File([new Uint8Array([1, 2, 3])], "card.jpg", { type: "image/jpeg" });

const frontPicker = () =>
  screen.getByLabelText("Choose a front card image") as HTMLInputElement;

// Pick an image for the front slot and take the stub cropper's crop.
async function pickFrontImage() {
  await userEvent.upload(frontPicker(), photo());
  await userEvent.click(
    await screen.findByRole("button", { name: "Use this crop" }),
  );
}

// Picking an image sends nothing: a diver who cancels leaves the stored cards as
// they found them, and a card being created has no uuid to upload against until
// the save resolves.
describe("card images ride on the form's own save", () => {
  it("sends nothing while the diver is still picking", async () => {
    open({ certification: EXISTING });

    await pickFrontImage();

    expect(await screen.findByText(/Added when you save/)).toBeInTheDocument();
    expect(uploadFile).not.toHaveBeenCalled();
    expect(updateCertification).not.toHaveBeenCalled();
  });

  it("sends a struck-off image nowhere until the form is saved", async () => {
    open({
      certification: {
        ...EXISTING,
        files: [
          {
            uuid: "file-1",
            side: "front",
            content_type: "image/webp",
            byte_size: 7,
            original_filename: "padi-ow.webp",
          },
        ],
      },
    });

    await userEvent.click(
      screen.getByRole("button", { name: /Remove the front image/ }),
    );

    expect(screen.getByText("Deleted when you save")).toBeInTheDocument();
    expect(deleteFile).not.toHaveBeenCalled();

    await save();

    await waitFor(() =>
      expect(deleteFile).toHaveBeenCalledWith("cert-1", "front"),
    );
  });

  it("uploads the cropped bytes after the details, against the card just created", async () => {
    // The ordering the API forces: `PUT .../file/{side}` needs a uuid, and a card
    // being created does not have one until `createCertification` resolves.
    open();
    await userEvent.type(certificationName(), "Advanced Nitrox");
    await pickFrontImage();

    await save();

    await waitFor(() => expect(uploadFile).toHaveBeenCalled());
    expect(uploadFile).toHaveBeenCalledWith(
      "cert-new",
      "front",
      CROPPED,
      "card-front.webp",
    );
  });

  it("hands back a re-read card, the embedded file metadata being stale either way", async () => {
    // Only the API knows which sides landed, and the list and the check-in sheet
    // both render their thumbnails from that embedded metadata.
    const withFile = { ...EXISTING, files: [] };
    getCertification.mockResolvedValue(withFile);
    const onSaved = open({ certification: EXISTING });

    await pickFrontImage();
    await save();

    await waitFor(() => expect(onSaved).toHaveBeenCalledWith(withFile));
    expect(getCertification).toHaveBeenCalledWith("cert-1");
  });

  it("re-reads nothing when no image was touched", async () => {
    const onSaved = open({ certification: EXISTING });

    await save();

    await waitFor(() => expect(onSaved).toHaveBeenCalled());
    expect(getCertification).not.toHaveBeenCalled();
  });

  it("keeps the save when an image fails, and says which side", async () => {
    // The details are already written by then, so failing the whole save would
    // make the diver fill the form in again to retry one picture.
    uploadFile.mockRejectedValue(new Error("nope"));
    const onSaved = open({ certification: EXISTING });

    await pickFrontImage();
    await save();

    await waitFor(() => expect(onSaved).toHaveBeenCalled());
    expect(toast).toHaveBeenCalledWith(
      expect.objectContaining({
        title: expect.stringContaining("front image did not"),
      }),
    );
  });
});