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 { 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 = {}, ) { return render( , ); } function contact(overrides: Partial = {}): 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 { 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(); }); });