Ctrl K
ComunidadGuías…

src/components/checkin/checkin-page-frame.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
  render as rtlRender,
  screen,
  waitFor,
  within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckInPageFrame } from "./checkin-page-frame";
import type { User } from "@/lib/api/auth";
import type {
  CheckinDetails,
  CheckinDetailsUpdate,
} from "@/lib/api/checkin-details";
import { ownCheckInDiver } from "@/lib/checkin";
import { CheckinDetailsProvider } from "@/contexts/CheckinDetailsContext";
import type { Certification } from "@/lib/api/certifications";
import type { UserDiveStats } from "@/lib/api/dive-stats";
import type { SharedCheckInCertification } from "@/lib/api/checkin-links";
import { API_BASE_URL } from "@/lib/api-base";
import type { CheckinLinkControls } from "@/hooks/useCheckinLink";

// The summary is the thing a diver hands across a desk, so what a render can reach is
// what is printed and in what order - and, as much as it matters here, what is *not*
// printed. jsdom does no layout and no print media, so the `print:` classes are pinned
// as classes and the rendering itself is walked in a browser.

const auth = vi.hoisted(() => ({
  user: {
    uuid: "user-1",
    name: "Sam Reef",
    username: "sam",
    email: "sam@example.com",
    units: "metric",
    dive_form_hidden_fields: [],
    dive_form_preset_uuid: null,
  } as User,
  refreshUser: vi.fn(),
}));

vi.mock("@/contexts/AuthContext", () => ({
  useAuth: () => auth,
}));

// The three dialogs this page hosts reach the API on save. The agency vocabulary and
// `certificationFile` stay real - the summary's own rendering is built on them.
vi.mock("@/lib/api/auth", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/auth")>()),
  authAPI: { updateProfile: vi.fn(), getPictureBlob: vi.fn() },
}));

// The dialogs read and save the shared copy; `update` writes it the way the API does.
vi.mock("@/lib/api/checkin-details", async (importOriginal) => ({
  ...(await importOriginal<object>()),
  checkinDetailsAPI: { getCheckinDetails: vi.fn(), update: vi.fn() },
}));

vi.mock("@/lib/api/certifications", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/certifications")>()),
  certificationsAPI: {
    createCertification: vi.fn(),
    updateCertification: vi.fn(),
  },
}));

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

vi.mock("@/components/ui/use-toast", async (importOriginal) => ({
  ...(await importOriginal<object>()),
  useToast: () => ({ toast: vi.fn(), dismiss: vi.fn(), toasts: [] }),
}));

const { authAPI } = await import("@/lib/api/auth");
const { checkinDetailsAPI } = await import("@/lib/api/checkin-details");
const updateDetails = vi.mocked(checkinDetailsAPI.update);
const { coursesAPI } = await import("@/lib/api/courses");
const updateProfile = vi.mocked(authAPI.updateProfile);

// Both the portrait and a card thumbnail fetch their bytes through this, the endpoints
// being owner-only. Answering with a URL is what puts an `<img>` on the page.
const blobUrl = vi.hoisted(() =>
  vi.fn((fetchBlob: unknown) => ({
    url: fetchBlob ? "blob:card" : null,
    isLoading: false,
    hasError: false,
    error: null,
  })),
);
vi.mock("@/hooks/useAuthedBlobUrl", () => ({ useAuthedBlobUrl: blobUrl }));

const certification = (over: Partial<Certification> = {}): Certification => ({
  uuid: "cert-1",
  agency: "padi",
  name: "Rescue Diver",
  user_uuid: "user-1",
  created_at: "2026-01-01T00:00:00+00:00",
  ...over,
});

const stats: UserDiveStats = {
  user_uuid: "user-1",
  total_dives: 142,
  max_depth: 39.6,
  total_submerged_time: 360000,
  species_seen: 12,
  dive_site_count: 9,
  first_dive_on: "2019-05-02",
  last_dive_on: "2026-09-20",
  created_at: "2026-01-01T00:00:00+00:00",
};

type FrameProps = Parameters<typeof CheckInPageFrame>[0];

// The diver's check-in details, as the shared copy holds them.
let details: CheckinDetails;

// Under the root layout's provider, which every dialog here saves through.
const render = (ui: React.ReactElement) =>
  rtlRender(ui, { wrapper: CheckinDetailsProvider });

// What the signed-in page hands over from the session and the shared copy, read at
// render time so a test that changes the diver first draws the change.
const session = () => ({
  diver: ownCheckInDiver(auth.user, details),
  units: auth.user.units,
});

const loaded = (over: Partial<FrameProps> = {}) => (
  <CheckInPageFrame {...session()} isLoading={false} stats={stats} {...over} />
);

// Everything a desk asks for. Most tests here are about something other than an empty
// group, and this is what keeps every section's "Not filled in yet." out of their way.
const COMPLETE: CheckinDetails = {
  email: null,
  date_of_birth: "1988-04-02",
  phone: "+44 7700 900000",
  emergency_contacts: [
    { name: "Alex Reef", phone: "+44 7700 900111", relationship: null },
  ],
  insurance_policies: [
    { provider: "DAN Europe", number: "P-42", expires_on: null },
  ],
};

beforeEach(() => {
  Object.assign(auth.user, {
    units: "metric",
    avatar_sha256: null,
    portrait_sha256: null,
  });
  details = {
    email: null,
    phone: null,
    date_of_birth: null,
    emergency_contacts: [],
    insurance_policies: [],
  };
  vi.mocked(checkinDetailsAPI.getCheckinDetails)
    .mockReset()
    .mockImplementation(async () => structuredClone(details));
  updateDetails
    .mockReset()
    .mockImplementation(async (patch: CheckinDetailsUpdate) => {
      details = { ...details, ...patch };
      return structuredClone(details);
    });
  auth.refreshUser.mockReset().mockResolvedValue(undefined);
  updateProfile.mockReset().mockResolvedValue({ message: "ok" });
  vi.mocked(coursesAPI.getCourses).mockReset().mockResolvedValue({
    data: [],
    total_count: 0,
    has_more: false,
    page: 1,
    items_per_page: 10,
  });
});

// The column the diver's portrait sits in, beside the one their name heads.
function portraitColumn(name: string): Element {
  return screen.getByRole("heading", { name }).parentElement!.parentElement!
    .previousElementSibling!;
}

// Every list read label-then-value. The diver's own, under the name, marks each
// value with an icon instead and has no label track to line up.
function labelledLists(container: HTMLElement): HTMLDListElement[] {
  return [...container.querySelectorAll("dl")].filter(
    (list) => !list.querySelector("dt svg"),
  );
}

describe("what the summary prints", () => {
  it("runs from the diver down to the cards, in the order a desk reads it", () => {
    Object.assign(details, {
      date_of_birth: "1988-04-02",
      phone: "+44 7700 900000",
      emergency_contacts: [
        { name: "Alex Reef", phone: null, relationship: null },
      ],
      insurance_policies: [
        { provider: "DAN Europe", number: null, expires_on: "2027-03-01" },
      ],
    });
    const { container } = render(
      loaded({
        certifications: [certification()],
        lastDiveAt: "2026-08-14T09:30:00+02:00",
      }),
    );

    // Two to a row on anything wider than a phone, so this is the order the cells
    // are filled in: the diver beside their diving, then the policy beside the
    // person to ring, and the cards under both.
    const text = container.textContent ?? "";
    const order = [
      "Sam Reef",
      "Date of birth",
      "Phone",
      "Diving",
      "Dive Insurance",
      "Emergency Contacts",
      "Certifications",
    ].map((label) => text.indexOf(label));

    expect(order.every((at) => at >= 0)).toBe(true);
    expect([...order].sort((a, b) => a - b)).toEqual(order);
  });

  it("pairs the sections up on paper, however narrow the sheet", () => {
    Object.assign(details, COMPLETE);
    const { container } = render(
      loaded({ certifications: [certification(), certification()] }),
    );

    // jsdom lays nothing out, so what is checkable is the pair of queries the
    // columns come from. The `print:` half is the one worth pinning: `md:` under
    // print media asks the paper's width, so without its twin a sheet printed from
    // a phone comes off as one long column on paper with room for two.
    const grids = [...container.querySelectorAll("div")].filter((el) =>
      el.classList.contains("grid"),
    );
    expect(grids.length).toBeGreaterThanOrEqual(2);
    for (const grid of grids) {
      expect(grid).toHaveClass("md:grid-cols-2", "print:grid-cols-2");
    }

    // The four that pair up, and then the cards two to a row under them.
    expect(grids[0].children).toHaveLength(4);
    expect(grids[grids.length - 1].children).toHaveLength(2);
  });

  it("leaves out a field the diver never filled in, rather than labelling a blank", () => {
    details.phone = "+44 7700 900000";
    render(loaded());

    expect(screen.getByText("Phone")).toBeInTheDocument();
    // Not "Date of birth: —": a blank line on a page handed to a stranger reads as
    // something withheld rather than something not held.
    expect(screen.queryByText("Date of birth")).toBeNull();
  });

  it("puts the date of birth, phone and email under the name, each marked by an icon", () => {
    Object.assign(details, COMPLETE, { email: "desk@example.org" });
    render(loaded());

    const nameColumn = screen.getByRole("heading", { name: "Sam Reef" })
      .parentElement!.parentElement!;
    for (const label of ["Date of birth", "Phone", "Email"]) {
      const term = within(nameColumn).getByText(label);
      // Heard by a screen reader, drawn as the icon beside it.
      expect(term).toHaveClass("sr-only");
      expect(term.closest("dt")!.querySelector("svg")).toHaveAttribute(
        "aria-hidden",
      );
    }
    expect(within(nameColumn).getByText("+44 7700 900000")).toBeInTheDocument();
    expect(
      within(nameColumn).getByText("desk@example.org"),
    ).toBeInTheDocument();
    // The address the diver gives out, never the one they sign in with.
    expect(screen.queryByText(auth.user.email)).toBeNull();
  });

  it("prints every policy and every contact, in the diver's order", () => {
    Object.assign(details, COMPLETE, {
      emergency_contacts: [
        { name: "First Call", phone: "0111", relationship: "Partner" },
        { name: "Second Call", phone: "0222", relationship: "Parent" },
      ],
      insurance_policies: [
        { provider: "DAN Europe", number: "P-42", expires_on: null },
        { provider: "DiveAssure", number: "D-7", expires_on: "2027-03-01" },
      ],
    });
    const { container } = render(loaded());

    const text = container.textContent ?? "";
    const order = [
      "DAN Europe",
      "P-42",
      "DiveAssure",
      "D-7",
      "First Call",
      "Second Call",
    ].map((value) => text.indexOf(value));
    expect(order.every((at) => at >= 0)).toBe(true);
    expect([...order].sort((a, b) => a - b)).toEqual(order);
  });

  it("prints nothing of the details for a diver who has saved none", () => {
    const { container } = render(loaded());

    for (const title of ["Dive Insurance", "Emergency Contacts"]) {
      expect(screen.getByText(title).closest("section")).toHaveClass(
        "print:hidden",
      );
    }
    expect(container.querySelector("dt svg")).toBeNull();
  });

  it("prints a card's dive center from what it is handed, and no row without one", () => {
    // The frame looks nothing up itself: the name comes in beside the card, by
    // the card's uuid, and a card with no entry there has no line at all.
    render(
      loaded({
        certifications: [
          certification({ uuid: "cert-1", name: "Rescue Diver" }),
          certification({ uuid: "cert-2", name: "Open Water Diver" }),
        ],
        contactNames: { "cert-1": "Blue Ocean" },
      }),
    );

    expect(screen.getAllByText("Dive center")).toHaveLength(1);
    expect(screen.getByText("Blue Ocean")).toBeInTheDocument();
  });

  it("keeps every section on screen, and takes the empty ones off the print", () => {
    Object.assign(details, { ...COMPLETE, emergency_contacts: [] });
    render(loaded({ certifications: [certification()] }));

    // Every heading is there with its own control, however little is under it -
    // which is what makes each group reachable without leaving the page.
    for (const title of ["Certifications", "Diving", "Dive Insurance"]) {
      expect(screen.getByText(title)).toBeInTheDocument();
    }
    expect(
      screen.getByText("Dive Insurance").closest("section"),
    ).not.toHaveClass("print:hidden");

    // A group the diver never filled in says so on screen and is gone from the
    // sheet: a heading with nothing under it is the labelled blank in another form.
    const emergency = screen.getByText("Emergency Contacts").closest("section");
    expect(emergency).toHaveClass("print:hidden");
    expect(
      within(emergency as HTMLElement).getByText("Not filled in yet."),
    ).toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: "Edit your emergency contacts" }),
    ).toBeInTheDocument();
  });

  it("never reads a failed fetch as an empty account", () => {
    Object.assign(details, COMPLETE);
    // A rejected list leaves the same empty array a diver with no cards has, and a
    // rejected `/user/dive-stats` the same null - so neither section may claim
    // emptiness here. The banner above says what happened and offers the retry.
    render(loaded({ loadFailed: true, stats: null }));

    expect(screen.queryByText("No certifications yet.")).toBeNull();
    const diving = screen.getByText("Diving").closest("section");
    expect(
      within(diving as HTMLElement).queryByText("Not filled in yet."),
    ).toBeNull();
  });

  it("says so where a diver holds no cards at all", () => {
    Object.assign(details, COMPLETE);
    render(loaded());

    const cards = screen.getByText("Certifications").closest("section");
    expect(cards).toHaveClass("print:hidden");
    expect(
      within(cards as HTMLElement).getByText("No certifications yet."),
    ).toBeInTheDocument();
  });

  it("names the agency in words and never draws its mark", () => {
    render(loaded({ certifications: [certification()] }));

    expect(screen.getByText("PADI Rescue Diver")).toBeInTheDocument();
  });

  it("says a card is on file as a PDF instead of attempting a thumbnail", () => {
    const { container } = render(
      loaded({
        certifications: [
          certification({
            files: [
              {
                uuid: "file-1",
                side: "front",
                content_type: "application/pdf",
                byte_size: 1024,
                original_filename: "card.pdf",
              },
            ],
          }),
        ],
      }),
    );

    expect(screen.getByText("card on file as PDF")).toBeInTheDocument();
    expect(container.querySelector("img")).toBeNull();
  });

  it("shows the stored front when it is an image", () => {
    render(
      loaded({
        certifications: [
          certification({
            files: [
              {
                uuid: "file-1",
                side: "front",
                content_type: "image/jpeg",
                byte_size: 1024,
                original_filename: "card.jpg",
              },
            ],
          }),
        ],
      }),
    );

    expect(
      screen.getByAltText("front of certification card"),
    ).toBeInTheDocument();
  });

  it("gives the depth in the diver's own units", () => {
    render(loaded());
    expect(screen.getByText("39.6 m")).toBeInTheDocument();

    auth.user.units = "imperial";
    render(loaded());
    expect(screen.getByText("130 ft")).toBeInTheDocument();
  });

  it("carries the last dive's date, and nothing where there is no dive", () => {
    const { rerender } = render(
      loaded({ lastDiveAt: "2026-08-14T09:30:00+02:00" }),
    );
    expect(screen.getByText("Last dive")).toBeInTheDocument();
    expect(screen.getByText("Aug 14, 2026")).toBeInTheDocument();

    rerender(loaded({ lastDiveAt: null }));
    expect(screen.queryByText("Last dive")).toBeNull();
  });
});

describe("what the print leaves behind", () => {
  it("names the saved PDF after the diver and the day, and gives the tab back", () => {
    Object.assign(details, COMPLETE);
    const tabTitle = "OpenDiving";
    document.title = tabTitle;
    render(loaded({ certifications: [certification()] }));

    // There is no API for the filename: the browser takes it from `document.title`
    // as the dialog opens, so the swap rides `beforeprint` - which is also what
    // gives Cmd+P the same name as the Print button.
    window.dispatchEvent(new Event("beforeprint"));
    expect(document.title).toMatch(
      /^Sam Reef - diver check-in - \d{4}-\d{2}-\d{2}$/,
    );

    window.dispatchEvent(new Event("afterprint"));
    expect(document.title).toBe(tabTitle);
  });

  it("marks the sheet without vouching for it", () => {
    Object.assign(details, COMPLETE);
    const { container } = render(loaded({ certifications: [certification()] }));

    // The one page of this app that leaves it on paper, so it says where it came
    // from. In the footnote and nowhere else: a mark above a stranger's card
    // numbers reads as an attestation, and the sentence it shares a line with is
    // the page disclaiming exactly that.
    // Matched on the footnote's own text: the wordmark sits in a `<span>`, and
    // testing-library's text matcher reads a node's direct text nodes only.
    const footnote = screen.getByText(/own dive log/);
    expect(footnote.tagName).toBe("P");
    expect(footnote).toHaveTextContent(/^OpenDiving · Printed /);
    expect(footnote?.querySelector("svg")).not.toBeNull();
    expect(footnote).toHaveTextContent(
      /verified with the agency that issued it, not here\.$/,
    );

    // Not in a heading, and not repeated anywhere else on the sheet.
    expect(container.querySelectorAll("h1, h2, h3").length).toBeGreaterThan(0);
    for (const heading of container.querySelectorAll("h1, h2, h3")) {
      expect(heading.textContent).not.toContain("OpenDiving");
    }
  });

  // `md:` measures the sheet in print, and A4 or Letter falls short of it, so the
  // one line naming the document needs a size of its own there.
  it("prints its heading at the page's size", () => {
    render(loaded());

    expect(screen.getByRole("heading", { level: 1 })).toHaveClass(
      "print:text-3xl",
    );
  });

  it("hides its own controls, keeping the summary", () => {
    render(loaded());

    expect(
      screen.getByRole("button", { name: /print/i }).closest(".print\\:hidden"),
    ).not.toBeNull();
  });

  it("keeps each block a reader takes as one thing off a page boundary", () => {
    Object.assign(details, COMPLETE);
    const { container } = render(loaded({ certifications: [certification()] }));

    // An emergency contact split over a fold is a name on one sheet and the number
    // to ring on another. The policies and the contacts are kept whole one by one,
    // their sections running to several of them.
    expect(screen.getByText("Diving").closest("section")).toHaveClass(
      "break-inside-avoid",
    );
    for (const value of ["DAN Europe", "Alex Reef"]) {
      expect(screen.getByText(value).closest("dl")!.parentElement).toHaveClass(
        "break-inside-avoid",
      );
    }
    expect(screen.getByText(/own dive log/)).toHaveClass("break-inside-avoid");
    // The card is its own unit, and the list under it travels with it.
    expect(
      container.querySelector(".space-y-2.break-inside-avoid"),
    ).not.toBeNull();

    // Not the certifications section itself: a diver with a handful of cards is
    // taller than a page, and refusing to break something that cannot fit only moves
    // the break to the top and wastes the page. Its unit is the card.
    expect(
      screen.getByText("Certifications").closest("section"),
    ).not.toHaveClass("break-inside-avoid");
  });
});

describe("before the requests land", () => {
  it("puts each placeholder card where its row will land", () => {
    const { container } = render(<CheckInPageFrame {...session()} />);

    // jsdom lays nothing out, so what is checkable is that the placeholder carries
    // the same geometry as `CertificationSummary` - the image slot, on a line of its
    // own above the list. Without it the whole list jumps and resizes the moment the
    // fetch returns.
    const rows = container.querySelectorAll("[aria-hidden] .flex.gap-4");
    expect(rows.length).toBeGreaterThan(0);
    rows.forEach((row) => {
      expect(row.firstElementChild).toHaveClass(
        "w-16",
        "sm:w-24",
        "print:w-24",
        "h-12",
      );
      // The gap beside the picture as well as the picture's own width: the two
      // together are what put the name bar where the name lands.
      expect(row).toHaveClass("gap-4");
      expect(row.className).not.toMatch(/(sm|print):gap-/);
    });
  });

  it("holds the shape with placeholders, and announces the regions as busy", () => {
    const { container } = render(<CheckInPageFrame {...session()} />);

    expect(container.querySelector("[aria-busy='true']")).not.toBeNull();
    const bars = container.querySelectorAll(".animate-skeleton");
    expect(bars.length).toBeGreaterThan(0);
    bars.forEach((bar) => expect(bar).toHaveAttribute("aria-hidden", "true"));
  });

  it("draws the name straight away, and holds the details' shape until they land", () => {
    render(
      <CheckInPageFrame
        diver={ownCheckInDiver(auth.user, null)}
        units="metric"
        details="loading"
      />,
    );

    expect(screen.getByText("Sam Reef")).toBeInTheDocument();
    for (const title of ["Dive Insurance", "Emergency Contacts"]) {
      const section = screen.getByText(title).closest("section")!;
      expect(section).toHaveAttribute("aria-busy", "true");
      expect(section).not.toHaveClass("print:hidden");
    }
    expect(screen.queryByText("Not filled in yet.")).toBeNull();
    // Nothing opens a form over a copy that has not arrived.
    expect(screen.queryByRole("button", { name: /^Edit your/ })).toBeNull();
    expect(
      screen.getByRole("button", { name: "Add a portrait" }),
    ).toBeDisabled();
  });

  it("claims nothing about the details when they failed to load", () => {
    render(
      <CheckInPageFrame
        diver={ownCheckInDiver(auth.user, null)}
        units="metric"
        isLoading={false}
        loadFailed
        details="failed"
      />,
    );

    expect(screen.queryByText("Not filled in yet.")).toBeNull();
    expect(screen.queryByRole("button", { name: /^Edit your/ })).toBeNull();
  });
});

describe("the picture at the top", () => {
  it("draws the portrait, and never the avatar", () => {
    Object.assign(details, COMPLETE);
    Object.assign(auth.user, {
      avatar_sha256: "avatar1",
      portrait_sha256: "portrait1",
    });
    render(loaded({ certifications: [certification()] }));

    const portrait = screen.getByAltText("Portrait of Sam Reef");
    expect(portrait).toHaveAttribute("src", "blob:card");
    // In the name's own column, at 7:9.
    const slot = portraitColumn("Sam Reef");
    expect(slot).toContainElement(portrait);
    expect(portrait.parentElement).toHaveClass("aspect-[7/9]");
    expect(screen.queryByAltText(/avatar/i)).toBeNull();
    expect(screen.queryByText("SR")).toBeNull();
  });

  it("puts nothing on the sheet for a diver with no portrait, whatever the avatar", () => {
    // The avatar does not stand in: a desk is looking for the diver's face, and the
    // avatar is whatever the diver shows the app. Nor do initials, which identify
    // nobody.
    Object.assign(details, COMPLETE);
    auth.user.avatar_sha256 = "avatar1";
    render(loaded({ certifications: [certification()] }));

    expect(screen.queryByRole("img")).toBeNull();
    expect(screen.queryByText("SR")).toBeNull();
    // What the screen offers instead never reaches the paper.
    const add = screen.getByRole("button", { name: "Add a portrait" });
    expect(add.closest(".aspect-\\[7\\/9\\]")).toHaveClass("print:hidden");
  });

  it("opens About You, portrait and all, from the empty frame", async () => {
    Object.assign(details, COMPLETE);
    render(loaded({ certifications: [certification()] }));

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

    const dialog = await screen.findByRole("dialog", { name: "About You" });
    expect(
      await within(dialog).findByLabelText("Choose a portrait"),
    ).toBeInTheDocument();
    expect(within(dialog).getByLabelText("Phone number")).toBeInTheDocument();
  });
});

describe("labels and values line up", () => {
  it("puts both halves of every pair straight into the list's own grid", () => {
    Object.assign(details, COMPLETE);
    const { container } = render(
      loaded({
        certifications: [certification({ certification_number: "12345" })],
      }),
    );

    const lists = labelledLists(container);
    expect(lists.length).toBeGreaterThan(0);
    for (const list of lists) {
      expect(list.className).toContain("grid-cols-[auto_1fr]");
      // A pair wrapped in a `<div>` of its own would occupy one cell, and each value
      // would start wherever its own label happened to end - which is the ragged
      // edge the grid exists to remove. jsdom does no layout, so the structure is
      // what can be pinned here.
      for (const cell of list.querySelectorAll("dt, dd")) {
        expect(cell.parentElement).toBe(list);
      }
    }
  });
  it("starts a name and the values under it on one edge", () => {
    Object.assign(details, COMPLETE);
    auth.user.portrait_sha256 = "abc123";
    const { container } = render(
      loaded({
        certifications: [certification({ certification_number: "1" })],
      }),
    );

    // jsdom lays nothing out, so what is checkable is the arithmetic these classes
    // encode, and it is arithmetic rather than taste: the picture's column plus the
    // gap beside it has to come to the label track plus the list's own column gap,
    // or every name on the sheet starts eight pixels off the values under it.
    // 6rem + 1rem = 6rem + 1rem. Change one of the three and this is what says the
    // other two have to move.
    // The picture's own column is what identifies a name row: a section heading
    // carries `break-after-avoid` too and has nothing beside it.
    const pictures = [...container.querySelectorAll("div")].filter((el) =>
      el.classList.contains("sm:w-24"),
    );
    // The diver's own block and the certification.
    expect(pictures).toHaveLength(2);
    for (const picture of pictures) {
      expect(picture).toHaveClass("print:w-24");
      expect(picture.parentElement).toHaveClass("gap-4");
      expect(picture.parentElement?.firstElementChild).toBe(picture);
    }

    for (const list of labelledLists(container)) {
      expect(list.className).toContain("sm:grid-cols-[minmax(6rem,auto)_1fr]");
      expect(list.className).toContain(
        "print:grid-cols-[minmax(6rem,auto)_1fr]",
      );
      expect(list).toHaveClass("gap-x-4");
    }
  });

  it("holds every heading row to one height, edit buttons or not", () => {
    Object.assign(details, COMPLETE);
    render(loaded());

    // The edit buttons are off the paper and off a link's page, so no row may take
    // its height from one: the name's line is taller than a heading's, and each
    // heading would sit a different distance above its rows.
    for (const name of [
      "Sam Reef",
      "Diving",
      "Dive Insurance",
      "Emergency Contacts",
      "Certifications",
    ]) {
      expect(screen.getByRole("heading", { name }).parentElement).toHaveClass(
        "min-h-9",
      );
    }
  });

  it("holds the picture's column for a diver who stored none", () => {
    Object.assign(details, COMPLETE);
    render(loaded({ certifications: [certification()] }));

    // Nothing in it prints, but the column stays - the name meets the same edge as
    // every certification's either way.
    const slot = portraitColumn("Sam Reef");
    expect(slot).toHaveClass("sm:w-24");
    for (const child of slot.children) {
      expect(child).toHaveClass("print:hidden");
    }
  });
});

describe("editing from the sheet", () => {
  it("opens the settings form over the summary, and gives the summary back on save", async () => {
    Object.assign(details, COMPLETE);
    render(loaded({ certifications: [certification()] }));

    await userEvent.click(
      screen.getByRole("button", {
        name: "Edit your name, portrait, date of birth, phone number and email",
      }),
    );

    const dialog = await screen.findByRole("dialog", { name: "About You" });
    // The very fields `/settings` shows, because they are the same component.
    expect(await within(dialog).findByLabelText("Phone number")).toHaveValue(
      "+44 7700 900000",
    );
    expect(within(dialog).getByLabelText("Full name")).toHaveValue("Sam Reef");

    await userEvent.clear(within(dialog).getByLabelText("Phone number"));
    await userEvent.type(within(dialog).getByLabelText("Phone number"), "0123");
    await userEvent.click(
      within(dialog).getByRole("button", { name: /save changes/i }),
    );

    // The name is the account's and did not change, so only the object is sent:
    // its About You members and no other.
    await waitFor(() =>
      expect(updateDetails).toHaveBeenCalledWith({
        date_of_birth: "1988-04-02",
        phone: "0123",
        email: null,
      }),
    );
    expect(updateProfile).not.toHaveBeenCalled();
    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "About You" })).toBeNull(),
    );
  });

  it("sends a changed name to the account first, then the object, and re-reads the user", async () => {
    Object.assign(details, COMPLETE);
    const sent: string[] = [];
    updateProfile.mockImplementation(async () => {
      sent.push("user");
      return { message: "ok" };
    });
    updateDetails.mockImplementation(async (patch) => {
      sent.push("checkin-details");
      return { ...details, ...patch };
    });
    render(loaded());

    await userEvent.click(
      screen.getByRole("button", {
        name: "Edit your name, portrait, date of birth, phone number and email",
      }),
    );
    const dialog = await screen.findByRole("dialog", { name: "About You" });
    const name = await within(dialog).findByLabelText("Full name");
    await userEvent.clear(name);
    await userEvent.type(name, "Sam Coral");
    await userEvent.click(
      within(dialog).getByRole("button", { name: /save changes/i }),
    );

    await waitFor(() => expect(auth.refreshUser).toHaveBeenCalled());
    expect(updateProfile).toHaveBeenCalledWith({ name: "Sam Coral" });
    expect(sent).toEqual(["user", "checkin-details"]);
  });

  it("gives each section a control that opens that group and nothing else", async () => {
    Object.assign(details, COMPLETE);
    render(loaded({ certifications: [certification()] }));

    await userEvent.click(
      screen.getByRole("button", { name: "Edit your insurance policies" }),
    );
    const insurance = await screen.findByRole("dialog", {
      name: "Dive Insurance",
    });
    expect(
      await within(insurance).findByRole("textbox", {
        name: "Provider policy 1 of 1",
      }),
    ).toHaveValue("DAN Europe");
    // The emergency contacts have their own control and their own dialog, so a diver
    // correcting one group is never handed the other two to scroll past.
    expect(
      within(insurance).queryByRole("textbox", { name: /^Relationship/ }),
    ).toBeNull();

    await userEvent.click(
      within(insurance).getByRole("button", { name: /save changes/i }),
    );
    await waitFor(() =>
      expect(updateDetails).toHaveBeenCalledWith({
        insurance_policies: [
          { provider: "DAN Europe", number: "P-42", expires_on: null },
        ],
      }),
    );
  });

  it("opens a card in the certification form it is edited in everywhere else", async () => {
    Object.assign(details, COMPLETE);
    render(loaded({ certifications: [certification()] }));

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

    const dialog = await screen.findByRole("dialog", {
      name: "Edit Certification",
    });
    expect(within(dialog).getByLabelText("Certification *")).toHaveValue(
      "Rescue Diver",
    );
  });

  it("re-reads the list rather than editing the card it holds", async () => {
    Object.assign(details, COMPLETE);
    const onCertificationsChanged = vi.fn();
    render(
      loaded({ certifications: [certification()], onCertificationsChanged }),
    );

    await userEvent.click(
      screen.getByRole("button", { name: "Edit Rescue Diver" }),
    );
    const dialog = await screen.findByRole("dialog", {
      name: "Edit Certification",
    });
    await userEvent.clear(within(dialog).getByLabelText("Certification *"));
    await userEvent.type(
      within(dialog).getByLabelText("Certification *"),
      "Rescue Diver (2026)",
    );
    await userEvent.click(
      within(dialog).getByRole("button", { name: /save changes/i }),
    );

    // The page re-reads `GET /certifications` rather than this component patching
    // its own copy: the order is the endpoint's, and a `certified_on` edited here
    // has to land where that puts it.
    await waitFor(() => expect(onCertificationsChanged).toHaveBeenCalled());
  });

  it("offers no way to add a card, cards being kept where cards are kept", () => {
    Object.assign(details, COMPLETE);
    render(loaded({ certifications: [certification()] }));

    expect(screen.queryByRole("button", { name: "Add" })).toBeNull();
  });
});

describe("correcting the diving figures", () => {
  const withDiving = () =>
    loaded({
      certifications: [certification()],
      lastDiveAt: "2026-08-14T09:30:00+02:00",
    });

  it("prints what the diver typed, and says it went nowhere", async () => {
    Object.assign(details, COMPLETE);
    render(withDiving());
    expect(screen.getByText("142")).toBeInTheDocument();

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    const dialog = await screen.findByRole("dialog", { name: "Diving" });
    const dives = within(dialog).getByLabelText("Dives logged");
    await userEvent.clear(dives);
    await userEvent.type(dives, "310");
    await userEvent.click(
      within(dialog).getByRole("button", { name: /use on this summary/i }),
    );

    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );
    expect(screen.getByText("310")).toBeInTheDocument();
    expect(screen.queryByText("142")).toBeNull();
    expect(
      screen.getByText(/nothing was saved to your log/i),
    ).toBeInTheDocument();
  });

  it("hands the log's own figures back", async () => {
    Object.assign(details, COMPLETE);
    render(withDiving());

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    const dives = within(
      await screen.findByRole("dialog", { name: "Diving" }),
    ).getByLabelText("Dives logged");
    await userEvent.clear(dives);
    await userEvent.type(dives, "310");
    await userEvent.click(
      screen.getByRole("button", { name: /use on this summary/i }),
    );
    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );

    // The undo only exists once there is something to undo, which is why it is
    // looked for after the correction rather than before it.
    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    await userEvent.click(
      await screen.findByRole("button", { name: /use logged figures/i }),
    );

    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );
    expect(screen.getByText("142")).toBeInTheDocument();
    expect(screen.queryByText(/nothing was saved to your log/i)).toBeNull();
  });

  it("lets a diver whose log has nothing in it correct the count", async () => {
    Object.assign(details, COMPLETE);
    // What `/user/dive-stats` answers for a diver with nothing logged, which is the
    // diver this dialog is for: the box opens on that zero, and a schema refusing it
    // would block the submit on a field nobody touched.
    render(
      loaded({
        stats: { ...stats, total_dives: 0, max_depth: 0 },
        certifications: [certification()],
      }),
    );

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    const dialog = await screen.findByRole("dialog", { name: "Diving" });
    expect(within(dialog).getByLabelText("Max depth (m)")).toHaveValue(0);

    const dives = within(dialog).getByLabelText("Dives logged");
    await userEvent.clear(dives);
    await userEvent.type(dives, "400");
    await userEvent.click(
      within(dialog).getByRole("button", { name: /use on this summary/i }),
    );

    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );
    expect(screen.getByText("400")).toBeInTheDocument();
  });

  it("keeps the way back when every figure is cleared, and drops the heading from the sheet", async () => {
    Object.assign(details, COMPLETE);
    render(withDiving());

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    const dialog = await screen.findByRole("dialog", { name: "Diving" });
    await userEvent.clear(within(dialog).getByLabelText("Dives logged"));
    await userEvent.clear(within(dialog).getByLabelText("Max depth (m)"));
    await userEvent.click(
      within(dialog).getByRole("button", { name: "Clear" }),
    );
    await userEvent.click(
      within(dialog).getByRole("button", { name: /use on this summary/i }),
    );

    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );
    expect(screen.queryByText("Dives logged")).toBeNull();
    // The section that removed itself would take the only route back to the logged
    // figures with it, leaving a correction in force with nothing on screen saying so.
    expect(
      screen.getByRole("button", { name: "Correct these figures" }),
    ).toBeInTheDocument();
    expect(
      screen.getByText(/nothing was saved to your log/i),
    ).toBeInTheDocument();
    // Not "Not filled in yet." beside it: the one diver who can reach this state is
    // the one who just emptied figures that were there.
    const diving = screen.getByText("Diving").closest("section");
    expect(
      within(diving as HTMLElement).queryByText("Not filled in yet."),
    ).toBeNull();
    // A desk is handed no heading with nothing under it.
    expect(screen.getByText("Diving").closest("section")).toHaveClass(
      "print:hidden",
    );

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    await userEvent.click(
      await screen.findByRole("button", { name: /use logged figures/i }),
    );
    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );
    expect(screen.getByText("142")).toBeInTheDocument();
  });

  it("keeps the correction off the sheet's own ink", async () => {
    Object.assign(details, COMPLETE);
    render(withDiving());

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    await userEvent.click(
      within(await screen.findByRole("dialog", { name: "Diving" })).getByRole(
        "button",
        { name: /use on this summary/i },
      ),
    );

    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );
    expect(screen.getByText(/nothing was saved to your log/i)).toHaveClass(
      "print:hidden",
    );
  });
});

describe("sharing it as a link", () => {
  const controls = (
    over: Partial<CheckinLinkControls> = {},
  ): CheckinLinkControls => ({
    live: null,
    busy: false,
    mint: vi.fn().mockResolvedValue(undefined),
    revoke: vi.fn().mockResolvedValue(undefined),
    ...over,
  });

  it("waits for the figures, then sends the ones the page shows", async () => {
    Object.assign(details, COMPLETE);
    const sharing = controls();
    const { rerender } = render(
      <CheckInPageFrame {...session()} sharing={sharing} />,
    );
    // The link keeps what it is made with for its whole life, so a click before the
    // figures land would publish blanks.
    expect(screen.getByRole("button", { name: /share/i })).toBeDisabled();

    rerender(
      loaded({
        sharing,
        certifications: [certification()],
        lastDiveAt: "2026-08-14T09:30:00+02:00",
      }),
    );
    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    const dives = within(
      await screen.findByRole("dialog", { name: "Diving" }),
    ).getByLabelText("Dives logged");
    await userEvent.clear(dives);
    await userEvent.type(dives, "310");
    await userEvent.click(
      screen.getByRole("button", { name: /use on this summary/i }),
    );
    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );

    await userEvent.click(screen.getByRole("button", { name: /share/i }));
    expect(sharing.mint).toHaveBeenCalledWith({
      totalDives: 310,
      maxDepth: 39.6,
      lastDiveOn: "2026-08-14",
    });
  });

  it("holds Share after a failed read, until the diver types the figures in", async () => {
    Object.assign(details, COMPLETE);
    const sharing = controls();
    // A rejected `/user/dive-stats` leaves the same nulls as one still in flight,
    // and a link made then would show no diving for its whole day.
    render(
      loaded({ sharing, stats: null, loadFailed: true, figuresFailed: true }),
    );
    expect(screen.getByRole("button", { name: /share/i })).toBeDisabled();

    await userEvent.click(
      screen.getByRole("button", { name: "Correct these figures" }),
    );
    const dives = within(
      await screen.findByRole("dialog", { name: "Diving" }),
    ).getByLabelText("Dives logged");
    await userEvent.type(dives, "310");
    await userEvent.click(
      screen.getByRole("button", { name: /use on this summary/i }),
    );
    await waitFor(() =>
      expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
    );

    expect(screen.getByRole("button", { name: /share/i })).toBeEnabled();
  });

  // The cards and their dive centers are read by the server whenever the link is
  // opened, so a failure there costs the link nothing it keeps.
  it("shares when only the cards failed to load", () => {
    Object.assign(details, COMPLETE);
    render(loaded({ sharing: controls(), loadFailed: true }));

    expect(screen.getByRole("button", { name: /share/i })).toBeEnabled();
  });

  it("shows a link made now as a QR code and an address, and keeps both off the sheet", () => {
    Object.assign(details, COMPLETE);
    const url = "https://dive.example/checkin/tok";
    render(
      loaded({
        sharing: controls({
          live: { expiresAt: "2026-09-27T10:00:00Z", url },
        }),
      }),
    );

    const panel = screen.getByRole("region", { name: "Check-in link" });
    expect(panel).toHaveClass("print:hidden");
    expect(
      within(panel).getByRole("img", { name: /QR code/ }),
    ).toBeInTheDocument();
    expect(within(panel).getByLabelText("Link address")).toHaveValue(url);
    expect(
      within(panel).getByRole("button", { name: "Copy" }),
    ).toBeInTheDocument();
    expect(
      within(panel).getByRole("button", { name: "Revoke" }),
    ).toBeInTheDocument();
  });

  it("says in one sentence why a link found on a later visit has no QR code", async () => {
    Object.assign(details, COMPLETE);
    const sharing = controls({
      live: { expiresAt: "2026-09-27T10:00:00Z", url: null },
    });
    render(loaded({ sharing }));

    const panel = screen.getByRole("region", { name: "Check-in link" });
    expect(within(panel).queryByRole("img")).toBeNull();
    expect(within(panel).queryByLabelText("Link address")).toBeNull();
    expect(
      within(panel).getByText(
        /can.t be shown again, because only a fingerprint/,
      ),
    ).toBeInTheDocument();

    await userEvent.click(
      within(panel).getByRole("button", { name: "Revoke" }),
    );
    expect(sharing.revoke).toHaveBeenCalled();
  });

  it("copies the address", async () => {
    Object.assign(details, COMPLETE);
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", {
      value: { writeText },
      configurable: true,
    });
    const url = "https://dive.example/checkin/tok";
    render(
      loaded({
        sharing: controls({
          live: { expiresAt: "2026-09-27T10:00:00Z", url },
        }),
      }),
    );

    await userEvent.click(screen.getByRole("button", { name: "Copy" }));
    expect(writeText).toHaveBeenCalledWith(url);
  });
});

describe("on a link's page", () => {
  const PORTRAIT = "portrait1";
  const LAST_DIVE_AT = "2026-08-14T09:30:00+02:00";
  const imageFront = {
    uuid: "file-1",
    side: "front" as const,
    content_type: "image/jpeg",
    byte_size: 1024,
    original_filename: "card.jpg",
  };
  const pdfFront = {
    ...imageFront,
    uuid: "file-2",
    content_type: "application/pdf",
    original_filename: "card.pdf",
  };

  // The same diver and cards twice over: as the signed-in page holds them, and as a
  // link's summary carries them.
  const ownCards = () => [
    certification({
      uuid: "cert-1",
      certification_number: "12345",
      certified_on: "2020-05-01",
      files: [imageFront],
    }),
    certification({ uuid: "cert-2", name: "Nitrox", files: [pdfFront] }),
  ];
  const sharedCards = (): SharedCheckInCertification[] => [
    {
      uuid: "cert-1",
      agency: "padi",
      name: "Rescue Diver",
      certification_number: "12345",
      certified_on: "2020-05-01",
      contact_name: "Blue Ocean",
      front_content_type: "image/jpeg",
    },
    {
      uuid: "cert-2",
      agency: "padi",
      name: "Nitrox",
      front_content_type: "application/pdf",
    },
  ];
  const linkView = {
    token: "tok",
    expiresAt: "2026-09-27T10:00:00Z",
    diving: { totalDives: 142, maxDepth: 39.6, lastDiveOn: "2026-08-14" },
  };
  const shared = (over: Partial<FrameProps> = {}) => (
    <CheckInPageFrame
      diver={{
        ...details,
        name: auth.user.name,
        portrait_sha256: auth.user.portrait_sha256,
      }}
      units={auth.user.units}
      certifications={sharedCards()}
      contactNames={{ "cert-1": "Blue Ocean" }}
      isLoading={false}
      link={linkView}
      {...over}
    />
  );

  // What a desk reads off either page: every heading, label and value, in order.
  const printed = (container: HTMLElement) =>
    [...container.querySelectorAll("h1, h2, h3, dt, dd")].map(
      (el) => el.textContent,
    );

  it("prints the same sheet from a link's summary as from the session", () => {
    Object.assign(details, COMPLETE);
    auth.user.portrait_sha256 = PORTRAIT;
    const { container, unmount } = render(
      loaded({
        certifications: ownCards(),
        contactNames: { "cert-1": "Blue Ocean" },
        lastDiveAt: LAST_DIVE_AT,
      }),
    );
    const fromSession = printed(container);
    const sessionFootnote = screen.getByText(/own dive log/).textContent;
    unmount();

    const { container: linkContainer } = render(shared());
    expect(printed(linkContainer)).toEqual(fromSession);
    expect(screen.getByText(/own dive log/).textContent).toBe(sessionFootnote);
    expect(fromSession).toContain("Blue Ocean");
    expect(fromSession).toContain("142");
  });

  it("offers no control that edits, and no dialog", () => {
    Object.assign(details, COMPLETE);
    auth.user.portrait_sha256 = PORTRAIT;
    render(shared());

    // Print is the one control a desk gets.
    expect(
      screen.getAllByRole("button").map((button) => button.textContent),
    ).toEqual(["Print"]);
    expect(screen.queryByRole("dialog")).toBeNull();
    expect(screen.queryByRole("region", { name: "Check-in link" })).toBeNull();
  });

  it("reads every picture from the link's own routes, and none through the session", async () => {
    Object.assign(details, COMPLETE);
    auth.user.portrait_sha256 = PORTRAIT;
    blobUrl.mockClear();
    vi.mocked(authAPI.getPictureBlob).mockClear();
    const fetchMock = vi.fn(async () => new Response("png"));
    vi.stubGlobal("fetch", fetchMock);
    const { container } = render(shared());

    expect(container.querySelectorAll("img")).toHaveLength(2);
    await Promise.all(
      blobUrl.mock.calls.map(([fetchBlob]) =>
        (fetchBlob as () => Promise<Blob>)(),
      ),
    );
    expect(fetchMock.mock.calls).toEqual([
      [`${API_BASE_URL}/checkin/tok/portrait`, { credentials: "omit" }],
      [
        `${API_BASE_URL}/checkin/tok/certification/cert-1/front`,
        { credentials: "omit" },
      ],
    ]);
    vi.unstubAllGlobals();
    // The PDF front is named, never fetched.
    expect(screen.getByText("card on file as PDF")).toBeInTheDocument();
    expect(authAPI.getPictureBlob).not.toHaveBeenCalled();
    expect(updateProfile).not.toHaveBeenCalled();
  });

  it("shows the sheet as it prints, and when the link stops working", () => {
    // Nothing but a name: every section is empty, and a desk sees none of them.
    render(shared({ certifications: [], contactNames: {} }));

    expect(screen.queryByText("Not filled in yet.")).toBeNull();
    expect(screen.queryByText("No certifications yet.")).toBeNull();
    for (const title of [
      "Dive Insurance",
      "Emergency Contacts",
      "Certifications",
    ]) {
      expect(screen.getByText(title).closest("section")).toHaveClass("hidden");
    }
    // The lines written to the diver go; the expiry, written to the desk, comes.
    expect(screen.queryByText(/on one page you can hand over/)).toBeNull();
    expect(screen.queryByText(/save this as a PDF/)).toBeNull();
    expect(screen.getByText(/This link stops working on/)).toHaveClass(
      "print:hidden",
    );
    // The portrait's column stands, empty, with no dashed offer in it.
    const slot = portraitColumn(auth.user.name);
    expect(slot).toHaveClass("sm:w-24");
    expect(slot.children).toHaveLength(0);
  });
});