src/components/dives/dive-detail-sidebar.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { DiveDetailSidebar } from "./dive-detail-sidebar";
import type { Dive, DiveSiteSummary } from "@/lib/api/dives";
import { dive } from "@/test/dive-fixtures";
import type { Course } from "@/lib/api/courses";
import type { Contact } from "@/lib/api/contacts";
import type { Person } from "@/lib/api/people";
import type { UnitSystem } from "@/lib/units";
// These renders read the diver's units, so they need an auth context. Held in a
// mutable box rather than a fixed literal so a test can switch systems - `vi.mock`'s
// factory is hoisted above the file, and `vi.hoisted` is what lets it close over
// something the tests can still reach.
const auth = vi.hoisted(() => ({ units: "metric" as UnitSystem }));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ user: { uuid: "user-1", units: auth.units } }),
}));
afterEach(() => {
auth.units = "metric";
});
// The card these cover has to hold four positions that arrive in any
// combination - a site's pin, an entry fix, an exit fix, none of them - and the
// combinations are the whole point: exit-only is the ordinary recording, and a
// dive with fixes but no trip and no site still has somewhere to show them.
// Their map is the hero's, covered in `dive-hero.render.test.tsx`.
// Dahab: the exit fix from the corpus file this feature was built against, and
// a second pair a short surface swim away from it.
const EXIT = { exit_latitude: 28.4375, exit_longitude: 34.4584 };
const ENTRY = { entry_latitude: 28.4392, entry_longitude: 34.4571 };
function site(overrides: Partial<DiveSiteSummary> = {}): DiveSiteSummary {
return {
uuid: "site-uuid",
name: "Blue Hole",
latitude: 28.5721,
longitude: 34.5372,
...overrides,
};
}
function renderSidebar(
subject: Dive,
course: Course | null = null,
contact: Contact | null = null,
people: Record<string, Person> = {},
) {
return render(
<DiveDetailSidebar
dive={subject}
trip={null}
course={course}
contact={contact}
people={people}
onRecordingsChanged={vi.fn()}
/>,
);
}
function contact(overrides: Partial<Contact> = {}): Contact {
return {
uuid: "contact-uuid",
name: "Blue Ocean Dive Center",
roles: ["dive_center"],
notes: "",
user_uuid: "user-uuid",
created_at: "2026-03-01T09:00:00Z",
...overrides,
};
}
function course(overrides: Partial<Course> = {}): Course {
return {
uuid: "course-uuid",
name: "Advanced Nitrox + Decompression Procedures",
agency: "tdi",
status: "completed",
user_uuid: "user-uuid",
created_at: "2026-03-08T09:00:00Z",
...overrides,
};
}
describe("DiveDetailSidebar locations", () => {
it("shows an exit-only dive as a complete recording", () => {
// Every GPS-carrying file in the API's corpus takes its first fix after
// surfacing, so this - not the pair - is the case the card is built around.
// An empty "Entry" row beside it would read as a failed reading.
renderSidebar(dive(EXIT));
expect(screen.getByText("Location")).toBeInTheDocument();
expect(screen.getByText("Exit")).toBeInTheDocument();
expect(screen.getByText("28.4375, 34.4584")).toBeInTheDocument();
expect(screen.queryByText("Entry")).not.toBeInTheDocument();
expect(screen.queryByText(/entry → exit/i)).not.toBeInTheDocument();
});
it("carries the card on GPS alone, with no trip and no site", () => {
// The widened gate. Before it, a dive imported from a file with fixes but
// never attached to a site had nowhere to show them at all.
renderSidebar(dive(EXIT));
expect(screen.queryByText("Trip")).not.toBeInTheDocument();
expect(screen.queryByText("Dive site")).not.toBeInTheDocument();
expect(screen.getByText("Exit")).toBeInTheDocument();
});
it("measures the drift when both fixes were recorded", () => {
renderSidebar(dive({ ...ENTRY, ...EXIT }));
expect(screen.getByText("28.4392, 34.4571")).toBeInTheDocument();
expect(screen.getByText("28.4375, 34.4584")).toBeInTheDocument();
// Text rather than a drawn line: at the map's zoom-10 cap the two fixes are
// the same pixel. See `lib/geo-distance.ts`.
expect(screen.getByText("Entry → exit")).toBeInTheDocument();
expect(screen.getByText("228 m")).toBeInTheDocument();
});
it("measures the drift in feet for an imperial diver", () => {
auth.units = "imperial";
renderSidebar(dive({ ...ENTRY, ...EXIT }));
expect(screen.getByText("747 ft")).toBeInTheDocument();
});
it("shows an entry fix on its own without a distance to nowhere", () => {
// The mirror of the exit-only case. Nothing in the corpus records this way,
// but the `drift` expression is the one output that reads asymmetrically -
// it needs *both* pairs, not either - so it is worth pinning.
renderSidebar(dive(ENTRY));
expect(screen.getByText("Entry")).toBeInTheDocument();
expect(screen.getByText("28.4392, 34.4571")).toBeInTheDocument();
expect(screen.queryByText("Exit")).not.toBeInTheDocument();
expect(screen.queryByText(/entry → exit/i)).not.toBeInTheDocument();
});
it("keeps an equator coordinate, which is a position rather than an absence", () => {
// The zero-versus-absent trap, on the one row where a 0 is real: a dive off
// West Africa exits at longitude 0.
renderSidebar(dive({ exit_latitude: 0, exit_longitude: 0 }));
expect(screen.getByText("Exit")).toBeInTheDocument();
expect(screen.getByText("0, 0")).toBeInTheDocument();
});
// How the diver got in, and off which boat, are facts about the place, so
// each carries this card alone rather than Environment's.
it.each([
["Entry type", { entry_type: "pier" }, "Pier"],
["Boat name", { boat_name: "Legend" }, "Legend"],
] as const)(
"shows the %s, and carries the card on it alone",
(label, fields, shown) => {
renderSidebar(dive(fields));
const card = within(
screen
.getByRole("heading", { name: "Location" })
.closest(".rounded-lg") as HTMLElement,
);
expect(card.getByText(label)).toBeInTheDocument();
expect(card.getByText(shown)).toBeInTheDocument();
expect(screen.queryByText("Environment")).not.toBeInTheDocument();
},
);
it("lists the entry type and the boat last, after the fixes and their drift", () => {
renderSidebar(
dive({ ...ENTRY, ...EXIT, entry_type: "boat", boat_name: "Legend" }),
);
expect(
screen
.getByText("Entry → exit")
.compareDocumentPosition(screen.getByText("Entry type")),
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(
screen
.getByText("Entry type")
.compareDocumentPosition(screen.getByText("Boat name")),
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it("lists every site, in the order visited, each linked with its place", () => {
// The hero and the cards show the first with a "+N"; this card names them all.
renderSidebar(
dive({
dive_sites: [
site({ location: { name: "Dahab" } }),
site({ uuid: "canyon-uuid", name: "The Canyon" }),
],
}),
);
expect(screen.getByText("Dive sites")).toBeInTheDocument();
const rows = screen.getAllByRole("listitem");
expect(rows.map((row) => row.textContent)).toEqual([
"Blue HoleDahab",
"The Canyon",
]);
expect(
within(rows[1]).getByRole("link", { name: "The Canyon" }),
).toHaveAttribute("href", expect.stringContaining("/sites/canyon-uuid"));
});
it("names a lone site in the singular", () => {
renderSidebar(dive({ dive_sites: [site()] }));
expect(screen.getByText("Dive site")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Blue Hole" })).toBeInTheDocument();
});
it("renders no card at all for a dive with nothing to place", () => {
renderSidebar(dive());
expect(screen.queryByText("Location")).not.toBeInTheDocument();
});
});
// The Environment card has the same shape of gate as the Location one above: it
// renders when *any* of its rows has something to say, and each row is on its
// own `!= null` guard.
describe("DiveDetailSidebar environment", () => {
it("names the water type rather than showing the wire value", () => {
renderSidebar(dive({ water_type: "salt" }));
expect(screen.getByText("Environment")).toBeInTheDocument();
expect(screen.getByText("Water type")).toBeInTheDocument();
expect(screen.getByText("Salt water")).toBeInTheDocument();
});
it("shows the altitude in meters", () => {
renderSidebar(dive({ altitude: 372 }));
expect(screen.getByText("Altitude")).toBeInTheDocument();
expect(screen.getByText("372 m")).toBeInTheDocument();
});
// Sea level is a recorded answer. A truthiness guard would hide exactly the
// dives where "this was at sea level" is the interesting half of the pair.
it("shows an altitude of zero, which is a reading and not an absence", () => {
renderSidebar(dive({ altitude: 0 }));
expect(screen.getByText("0 m")).toBeInTheDocument();
});
it("carries the card on the new rows alone", () => {
// No temperature and no visibility: before these two fields the card would
// not have rendered at all, so the gate is what makes them reachable.
renderSidebar(dive({ water_type: "salt" }));
expect(screen.getByText("Environment")).toBeInTheDocument();
expect(screen.queryByText("Visibility")).not.toBeInTheDocument();
});
it("hides both rows on a dive that records neither", () => {
renderSidebar(dive({ current: "strong" }));
expect(screen.getByText("Environment")).toBeInTheDocument();
expect(screen.queryByText("Water type")).not.toBeInTheDocument();
expect(screen.queryByText("Altitude")).not.toBeInTheDocument();
});
it("renders no Environment card for a dive that records none of it", () => {
renderSidebar(dive());
expect(screen.queryByText("Environment")).not.toBeInTheDocument();
});
// Both are among the hero's figures, and carry no card here.
it("leaves the water's temperature and the visibility to the hero", () => {
renderSidebar(dive({ bottom_temperature: 22.5, visibility: 12 }));
expect(screen.queryByText("Environment")).not.toBeInTheDocument();
expect(screen.queryByText(/temperature/i)).not.toBeInTheDocument();
expect(screen.queryByText("Visibility")).not.toBeInTheDocument();
});
});
// The water type's describe, once per value the card gained beside it: each row
// is on its own guard, carries the card alone, and names its value.
describe.each([
["Current", { current: "strong" }, "Strong"],
["Waves", { waves: "slight" }, "Slight"],
["Weather", { weather: "partly_cloudy" }, "Partly cloudy"],
["Air temperature", { air_temperature: 24 }, "24°C"],
] as const)("DiveDetailSidebar environment: %s", (label, fields, shown) => {
it("carries the card alone, and names the value", () => {
renderSidebar(dive(fields));
expect(screen.getByText("Environment")).toBeInTheDocument();
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByText(shown)).toBeInTheDocument();
});
it("hides the row on a dive that does not record it", () => {
renderSidebar(dive({ water_type: "salt" }));
expect(screen.queryByText(label)).not.toBeInTheDocument();
});
});
describe("DiveDetailSidebar environment, read from a newer API", () => {
// A member the API grew before this build shipped a label renders de-snaked
// rather than as a blank row.
it("shows a value this build has no label for de-snaked", () => {
renderSidebar(dive({ current: "tidal_surge" as Dive["current"] }));
expect(screen.getByText("Tidal surge")).toBeInTheDocument();
});
});
describe("DiveDetailSidebar training", () => {
it("links the course, outside the Location card", () => {
// A course is not a place. The Location card renders on the strength of the
// dive having one, and this dive has none - so a course row folded into it
// would be invisible on exactly the training dives it is for.
renderSidebar(dive(), course());
const link = screen.getByRole("link", {
name: "Advanced Nitrox + Decompression Procedures",
});
expect(link).toHaveAttribute("href", "/courses/course-uuid");
expect(screen.getByText("Training")).toBeInTheDocument();
expect(screen.queryByText("Location")).not.toBeInTheDocument();
});
it("shows nothing at all for a dive with no course", () => {
// The sidebar without a course looks exactly as it did before courses
// existed - no empty card, no placeholder row.
renderSidebar(dive());
expect(screen.queryByText("Training")).not.toBeInTheDocument();
expect(screen.queryByText("Course")).not.toBeInTheDocument();
});
});
describe("DiveDetailSidebar dive center", () => {
it("shows a fun dive's dive center, with no Training card", () => {
// The card is its own because a dive with no course is exactly the one whose
// dive center a Training row would never show.
renderSidebar(
dive(),
null,
contact({
phone: "+20 69 364 0000",
website: "https://blueocean.example/",
}),
);
expect(
screen.getByRole("heading", { name: "Location" }),
).toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Dive Center" }),
).not.toBeInTheDocument();
expect(screen.getByText("Blue Ocean Dive Center")).toBeInTheDocument();
expect(screen.queryByText("Training")).not.toBeInTheDocument();
// Dialled as digits, shown as typed.
expect(
screen.getByRole("link", { name: "+20 69 364 0000" }),
).toHaveAttribute("href", "tel:+20693640000");
expect(
screen.getByRole("link", { name: "blueocean.example" }),
).toHaveAttribute("href", "https://blueocean.example/");
});
it("leaves out the ways to reach it that were not recorded", () => {
renderSidebar(dive(), null, contact());
expect(screen.getByText("Blue Ocean Dive Center")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("names the city, province and country where they are set", () => {
renderSidebar(
dive(),
null,
contact({
address: {
street: "1 Harbour Rd",
city: "Dahab",
region: "South Sinai",
country: "Egypt",
},
}),
);
expect(screen.getByText("Dahab, South Sinai, Egypt")).toBeInTheDocument();
expect(screen.queryByText(/Harbour/)).not.toBeInTheDocument();
});
it("shows no dive center row for a dive with no contact", () => {
renderSidebar(dive(), course());
expect(screen.queryByText("Dive center")).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Location" }),
).not.toBeInTheDocument();
});
});
describe("DiveDetailSidebar people", () => {
const person = (
uuid: string,
name: string,
username: string | null = null,
): Person => ({
uuid,
name,
username,
notes: "",
dive_count: 1,
created_at: "2026-03-01T09:00:00Z",
});
const ALEX = person("person-alex", "Alex M.", "alexm");
const SAM = person("person-sam", "Sam");
const PEOPLE = { [ALEX.uuid]: ALEX, [SAM.uuid]: SAM };
it("names each person with their role and linked username, in the dive's order", () => {
renderSidebar(
dive({
people: [
{ person_uuid: SAM.uuid, role: "guide" },
{ person_uuid: ALEX.uuid, role: null },
],
}),
null,
null,
PEOPLE,
);
expect(screen.getByRole("heading", { name: "People" })).toBeInTheDocument();
expect(
screen.getAllByRole("listitem").map((item) => item.textContent),
).toEqual(["SamGuide", "Alex M. @alexm"]);
expect(screen.getByRole("link", { name: "Alex M." })).toHaveAttribute(
"href",
"/people/person-alex",
);
});
it("shows no card until a name is known, nor for a dive with nobody on it", () => {
renderSidebar(dive({ people: [{ person_uuid: SAM.uuid, role: "buddy" }] }));
expect(
screen.queryByRole("heading", { name: "People" }),
).not.toBeInTheDocument();
});
});