src/components/dives/dive-card.render.test.tsx
Rama main · réplica de lectura del repositorio original.
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",
);
});
});