Ctrl K
ComunidadGuías…

src/components/dives/dive-card.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DiveCard } from "./dive-card";
import type { Dive } from "@/lib/api/dives";
import { dive as baseDive } from "@/test/dive-fixtures";
import { giveFramesASize } from "@/test/frame-size";
import { reveal } from "@/test/intersection";

// A card shows a map of a placed dive and open water for one without, the
// dive's depth outline across the foot of either, lays out its figures under
// their titles, and offers Delete only where its list can run one.

vi.mock("@/contexts/AuthContext", () => ({
  useAuth: () => ({ user: { uuid: "user-1", units: "metric" } }),
}));

const at = vi.hoisted(() => ({ pathname: "/trips/trip-1" }));
vi.mock("next/navigation", () => ({
  usePathname: () => at.pathname,
  useSearchParams: () => new URLSearchParams(),
}));
afterEach(() => {
  at.pathname = "/trips/trip-1";
});

vi.mock("next-themes", () => ({
  useTheme: () => ({ resolvedTheme: "light" }),
}));

const instance = vi.hoisted(() => ({ mapTiles: true }));
vi.mock("@/hooks/useInstanceConfig", () => ({
  useInstanceConfig: () => ({
    config: { map_tiles: instance.mapTiles },
    isLoading: false,
  }),
}));

// The tiles' bytes; what matters here is which cards ask for them, and what a
// card is called.
const { getMapTile } = vi.hoisted(() => ({ getMapTile: vi.fn() }));
vi.mock("@/lib/api/map-tiles", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/map-tiles")>()),
  mapTilesAPI: { getMapTile },
}));

// jsdom implements neither, and lays nothing out: each card's map is given a
// card's frame.
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
let restoreSize: () => void;
beforeEach(() => {
  instance.mapTiles = true;
  URL.createObjectURL = vi.fn(() => "blob:tile");
  URL.revokeObjectURL = vi.fn();
  restoreSize = giveFramesASize({ width: 400, height: 236 });
  getMapTile.mockReset();
  getMapTile.mockResolvedValue(new Blob(["webp"]));
});
afterEach(() => {
  URL.createObjectURL = originalCreate;
  URL.revokeObjectURL = originalRevoke;
  restoreSize();
});

vi.mock("@/components/dives/dive-profile-silhouette", () => ({
  DiveProfileSilhouette: ({ depths }: { depths: number[] }) => (
    <div data-testid="outline">{depths.join(" ")}</div>
  ),
}));

const dive = (overrides: Partial<Dive> = {}) =>
  baseDive({
    uuid: "dive-1",
    dive_number: 212,
    max_depth: 30.52,
    avg_depth: 18.2,
    bottom_temperature: 24.4,
    ...overrides,
  });

const card = (props: Partial<Parameters<typeof DiveCard>[0]> = {}) => {
  render(
    <ul>
      <DiveCard dive={dive()} {...props} />
    </ul>,
  );
  act(() => reveal());
  return screen.getByRole("listitem");
};

const water = (item: HTMLElement) => item.querySelector(".bg-map-water svg");

describe("DiveCard", () => {
  // Named from the dive's own sites and fixes: its tiles carry nothing of it.
  it("shows a map of the dive, named by its site and its fixes", async () => {
    const item = card({
      dive: dive({
        dive_sites: [
          {
            uuid: "site-1",
            name: "Blue Hole",
            latitude: 28.57,
            longitude: 34.54,
          },
        ],
        exit_latitude: 28.58,
        exit_longitude: 34.55,
      }),
    });

    const map = await within(item).findByRole("img", {
      name: "Map of Blue Hole; Exit",
    });
    expect(map.querySelector("img")).toHaveAttribute("src", "blob:tile");
    expect(map.querySelectorAll("[data-marker]")).toHaveLength(2);
    // In the page's theme, at the zoom a dive this small opens at.
    expect(getMapTile).toHaveBeenCalledWith(
      expect.stringMatching(
        /^\/map-tiles\/light\/9\/\d+\/\d+\?language=en-US$/,
      ),
      expect.any(AbortSignal),
    );
  });

  it("draws water and the bubbles for a dive with no position at all", () => {
    const item = card({
      dive: dive({ dive_sites: [{ uuid: "site-1", name: "Unpinned reef" }] }),
    });

    expect(within(item).queryByRole("img")).not.toBeInTheDocument();
    expect(water(item)).toBeInTheDocument();
  });

  it("draws water, and asks for nothing, where this instance draws no map", () => {
    instance.mapTiles = false;
    const item = card({
      dive: dive({
        dive_sites: [
          {
            uuid: "site-1",
            name: "Blue Hole",
            latitude: 28.57,
            longitude: 34.54,
          },
        ],
      }),
    });

    expect(within(item).queryByRole("img")).not.toBeInTheDocument();
    expect(water(item)).toBeInTheDocument();
    expect(getMapTile).not.toHaveBeenCalled();
  });

  it("draws water where a tile cannot be had", async () => {
    getMapTile.mockRejectedValue(
      Object.assign(new Error("Service Unavailable"), {
        response: { status: 503 },
      }),
    );
    const item = card({
      dive: dive({
        dive_sites: [
          {
            uuid: "site-1",
            name: "Blue Hole",
            latitude: 28.57,
            // Somewhere this page holds no tile of: the tests share it.
            longitude: 100.54,
          },
        ],
      }),
    });

    await waitFor(() => expect(getMapTile).toHaveBeenCalled());
    await act(async () => {});
    expect(within(item).queryByRole("img")).not.toBeInTheDocument();
    expect(water(item)).toBeInTheDocument();
  });

  it("draws the dive's depth outline over its backdrop", () => {
    const item = card({
      dive: dive({
        depth_outline: { span: 2_700_000, values: [300, 3052, 500] },
      }),
    });

    expect(within(item).getByTestId("outline")).toHaveTextContent(
      "300 3052 500",
    );
  });

  it("draws no outline for a dive without one", () => {
    const item = card({ dive: dive({ depth_outline: null }) });

    expect(within(item).queryByTestId("outline")).not.toBeInTheDocument();
  });

  const figuresOf = (item: HTMLElement) =>
    Array.from(item.querySelectorAll("dt"), (term) => [
      term.textContent,
      term.nextElementSibling?.textContent,
    ]);

  it("titles its figures", () => {
    expect(figuresOf(card())).toEqual([
      ["Dive time", "45min"],
      ["Max depth", "31 m"],
      ["Water temp", "24°C"],
    ]);
  });

  it("leaves out a temperature it has not got, and no average depth takes its place", () => {
    const item = card({
      dive: dive({ max_depth: undefined, bottom_temperature: undefined }),
    });

    expect(figuresOf(item)).toEqual([
      ["Dive time", "45min"],
      ["Max depth", "-"],
    ]);
  });

  it("shows a depth of zero rather than leaving it out", () => {
    const item = card({
      dive: dive({ max_depth: 0, bottom_temperature: undefined }),
    });

    expect(figuresOf(item)).toEqual([
      ["Dive time", "45min"],
      ["Max depth", "0 m"],
    ]);
  });

  it("counts the species seen in the slot a missing temperature leaves", () => {
    const item = card({
      dive: dive({ bottom_temperature: undefined, species_count: 4 }),
    });

    expect(figuresOf(item)).toEqual([
      ["Dive time", "45min"],
      ["Max depth", "31 m"],
      ["Species seen", "4"],
    ]);
  });

  it("leaves the species off a card whose three slots are full", () => {
    expect(figuresOf(card({ dive: dive({ species_count: 4 }) }))).toEqual([
      ["Dive time", "45min"],
      ["Max depth", "31 m"],
      ["Water temp", "24°C"],
    ]);
  });

  it("leaves the species off a dive that saw none", () => {
    const item = card({
      dive: dive({ bottom_temperature: undefined, species_count: 0 }),
    });

    expect(figuresOf(item)).toEqual([
      ["Dive time", "45min"],
      ["Max depth", "31 m"],
    ]);
  });

  it("shows a water temperature of zero rather than leaving it out", () => {
    const item = card({ dive: dive({ bottom_temperature: 0 }) });

    expect(figuresOf(item)[2]).toEqual(["Water temp", "0°C"]);
  });

  it("names the dive, says when and where it was, and leads back to the page it is on", () => {
    const item = card({
      dive: dive({
        dive_sites: [
          {
            uuid: "site-1",
            name: "Blue Hole",
            location: { name: "Dahab, Egypt" },
          },
        ],
      }),
    });

    expect(
      within(item).getByRole("link", { name: "#212 Blue Hole" }),
    ).toHaveAttribute("href", "/dives/dive-1?from=%2Ftrips%2Ftrip-1");
    expect(item).toHaveTextContent("Apr 4, 2026, 10:04 · Dahab, Egypt");
  });

  // The hero's title and line, so a dive reads the same on its page as here.
  it("marks a training dive, tags its water and kind, and carries the hero's line", () => {
    const item = card({
      dive: dive({
        course_uuid: "course-1",
        water_type: "fresh",
        boat_name: "Legend",
        type: "closed_circuit",
      }),
    });

    expect(item).toHaveTextContent(
      "FreshFresh waterCCRClosed circuitDive #212 (training dive)Apr 4, 2026, 10:04 · Boat Legend",
    );
    expect(within(item).getByText("CCR")).toHaveAttribute("aria-hidden");
  });

  it("follows the kind with the diver's tags in teal, and rates the dive after its name", () => {
    const item = card({
      dive: dive({
        type: "closed_circuit",
        tags: ["night", "Wreck"],
        rating: 4,
      }),
    });

    expect(item).toHaveTextContent("CCRClosed circuitnightWreckDive #212");
    expect(within(item).getByText("Wreck")).toHaveClass("bg-teal");
    expect(
      within(item).getByRole("link", { name: "Dive #212 4 of 5 stars" }),
    ).toBeInTheDocument();
  });

  it("tags a dive that owed decompression after its kind", () => {
    const item = card({
      dive: dive({ type: "closed_circuit", deco: true, tags: ["Wreck"] }),
    });

    expect(item).toHaveTextContent(
      "CCRClosed circuitDecoDecompression diveWreckDive #212",
    );
  });

  it("ends its line with a dive computer where one recorded the dive", () => {
    expect(card({ dive: dive({ recording_count: 1 }) })).toHaveTextContent(
      "Apr 4, 2026, 10:04 · 1 dive computer",
    );
  });

  it("draws as many dive computers as recorded the dive", () => {
    const item = card({ dive: dive({ recording_count: 3 }) });
    expect(item).toHaveTextContent("Apr 4, 2026, 10:04 · 3 dive computers");
    expect(item.querySelectorAll("svg.lucide-dive-computer")).toHaveLength(3);
  });

  it("draws no dive computer for a dive with no recording", () => {
    expect(card({ dive: dive({ recording_count: 0 }) })).not.toHaveTextContent(
      "dive computer",
    );
  });

  it("opens the dive plainly from the dive list, where its back link goes anyway", () => {
    at.pathname = "/dives";
    expect(
      within(card()).getByRole("link", { name: "Dive #212" }),
    ).toHaveAttribute("href", "/dives/dive-1");
  });

  it("edits back to the list it was opened from, and deletes only when asked to", async () => {
    const onDelete = vi.fn();
    const item = card({ onDelete });

    await userEvent.click(
      within(item).getByRole("button", { name: "Actions for dive #212" }),
    );
    expect(
      await screen.findByRole("menuitem", { name: "Edit" }),
    ).toHaveAttribute("href", "/dives/dive-1/edit?from=%2Ftrips%2Ftrip-1");
    await userEvent.click(screen.getByRole("menuitem", { name: "Delete" }));

    expect(onDelete).toHaveBeenCalledOnce();
  });

  it("offers Edit alone where the list has no delete", async () => {
    const item = card();

    await userEvent.click(
      within(item).getByRole("button", { name: "Actions for dive #212" }),
    );

    expect(
      await screen.findByRole("menuitem", { name: "Edit" }),
    ).toBeInTheDocument();
    expect(
      screen.queryByRole("menuitem", { name: "Delete" }),
    ).not.toBeInTheDocument();
  });

  it("draws a dive the trip does not hold yet muted, saying so, with the caller's control in place of the menu", () => {
    const item = card({
      addToTrip: <button type="button">Add to trip</button>,
    });

    expect(
      // jsdom's name computation drops the space a browser keeps.
      within(item).getByRole("link", {
        name: /^Dive #212 ?\(Not in this trip\)$/,
      }),
    ).toBeInTheDocument();
    expect(
      within(item).getByRole("button", { name: "Add to trip" }),
    ).toBeInTheDocument();
    expect(
      within(item).queryByRole("button", { name: "Actions for dive #212" }),
    ).not.toBeInTheDocument();
    expect(item).toHaveClass("border-dashed");
    // Its text is muted by colour, never by opacity.
    expect(within(item).getByRole("link").parentElement).toHaveClass(
      "text-muted-foreground",
    );
  });

  it("changes nothing without the control", () => {
    const item = card();

    expect(
      within(item).getByRole("link", { name: "Dive #212" }),
    ).toBeInTheDocument();
    expect(
      within(item).queryByText(/Not in this trip/),
    ).not.toBeInTheDocument();
    expect(item).not.toHaveClass("border-dashed");
    expect(within(item).getByRole("link").parentElement).not.toHaveClass(
      "text-muted-foreground",
    );
  });
});