Ctrl K
ComunidadGuías…

src/components/dives/dive-detail-sidebar.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
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();
  });
});