src/app/home/page.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import HomePage from "./page";
import type { UserDiveStats } from "@/lib/api/dive-stats";
// The hero's figures must render what the API derived rather than a constant, and
// hold a "—" while their request is still in flight.
// Returned by identity rather than rebuilt per call, and for `user` that is
// load-bearing rather than tidiness: the real `AuthContext` holds it in state, so it
// keeps one identity across renders, and this page's stats effect lists `user` in its
// dependencies. A mock handing back a fresh `user` per render re-runs that effect on
// every render, and the effect's own `setStats` causes one. See "The new-dive render
// test's mocks are identity-stable, so the prefill effect runs once" in DECISIONS.md,
// and "reads the stats once" below.
//
// Both hooks are pinned, but only the guard's `user` can start the loop - the page
// reads the context for `units` alone, and a string has no identity to churn.
// Measured: rebuilding the context's object per call leaves the fetch count at 1.
// The guard is the one that matters; the context is here so the file has one rule
// rather than two.
//
// `vi.hoisted` because a `vi.mock` factory is hoisted above every other statement in
// the file and so cannot close over an ordinary `const`.
const stable = vi.hoisted(() => ({
guard: {
user: { uuid: "user-1", first_name: "Sam" },
isAuthenticated: true,
isLoading: false,
},
auth: { user: { uuid: "user-1", name: "Sam Diver", units: "metric" } },
}));
vi.mock("@/hooks/useAuthGuard", () => ({
useAuthGuard: () => stable.guard,
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => stable.auth,
}));
vi.mock("@/lib/api/dive-stats", () => ({
diveStatsAPI: {
getDiveStats: vi.fn(),
getGasUseHistory: vi.fn(),
getDiveActivity: vi.fn(),
},
}));
// The rest of the page is other cards with their own requests and their own
// tests. Stubbed rather than mounted: none of them says anything about the stats
// row, and each would otherwise need its whole API surface mocked to render.
vi.mock("@/components/dives/recent-dives-card", () => ({
RecentDivesCard: () => null,
}));
vi.mock("@/components/dives/recent-trips-card", () => ({
RecentTripsCard: () => null,
}));
vi.mock("@/components/dives/dive-activity-card", () => ({
DiveActivityCard: () => null,
}));
vi.mock("@/components/dives/gas-use-card", () => ({ GasUseCard: () => null }));
vi.mock("@/components/home/setup-checklist-card", () => ({
SetupChecklistCard: () => null,
}));
vi.mock("@/lib/api/trips", () => ({
tripsAPI: { getTripPlaces: vi.fn() },
}));
// The map is covered where it lives; here it is only what the hero is handed.
vi.mock("@/components/map/map-backdrop", () => ({
MapBackdrop: vi.fn(() => null),
useMapTiles: () => true,
}));
const { MapBackdrop } = await import("@/components/map/map-backdrop");
const { diveStatsAPI } = await import("@/lib/api/dive-stats");
const getDiveStats = vi.mocked(diveStatsAPI.getDiveStats);
const { tripsAPI } = await import("@/lib/api/trips");
const getTripPlaces = vi.mocked(tripsAPI.getTripPlaces);
const DAHAB = {
name: "Dahab, South Sinai, Egypt",
latitude: 28.5,
longitude: 34.51,
};
const MOALBOAL = { name: "Moalboal", latitude: 9.95, longitude: 123.4 };
const stats = (overrides: Partial<UserDiveStats> = {}): UserDiveStats => ({
user_uuid: "user-1",
total_dives: 212,
max_depth: 39.4,
total_submerged_time: 561600,
species_seen: 17,
dive_site_count: 48,
first_dive_on: "2014-03-08",
last_dive_on: "2026-10-05",
created_at: "2026-04-04T12:00:00+00:00",
...overrides,
});
const figure = (label: string) =>
screen.queryByText(label, { selector: "dt" })?.nextElementSibling;
beforeEach(() => {
getDiveStats.mockReset();
getTripPlaces.mockReset();
getTripPlaces.mockResolvedValue([]);
vi.mocked(MapBackdrop).mockClear();
});
describe("Home heading", () => {
it("is the diver's name alone, beside their picture", async () => {
getDiveStats.mockResolvedValue(stats());
render(<HomePage />);
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(
/^Sam Diver$/,
);
// The initials, for an account with no picture - hidden from a screen reader,
// which would hear the name twice.
expect(
screen.getByText("SD").closest("[aria-hidden='true']"),
).not.toBeNull();
});
});
const mapProps = () => vi.mocked(MapBackdrop).mock.lastCall![0];
describe("Home hero map", () => {
it("frames the places of the diver's trips", async () => {
getDiveStats.mockResolvedValue(stats());
getTripPlaces.mockResolvedValue([DAHAB, MOALBOAL]);
render(<HomePage />);
await waitFor(() =>
expect(mapProps().locations).toEqual([DAHAB, MOALBOAL]),
);
expect(mapProps().hero).toBe(true);
// The shipped picture of the world, credited as the shipped styles are.
expect(mapProps().world).toBe(true);
expect(screen.getByText("OpenFreeMap")).toBeInTheDocument();
});
it("draws the whole world for a diver with no placed trips", async () => {
getDiveStats.mockResolvedValue(stats());
render(<HomePage />);
await waitFor(() => expect(mapProps().showWhenEmpty).toBe(true));
expect(mapProps().locations).toEqual([]);
});
it("asks for no world while the places are still loading", async () => {
getDiveStats.mockResolvedValue(stats());
getTripPlaces.mockReturnValue(new Promise(() => {}));
render(<HomePage />);
await screen.findByText("Species seen");
expect(mapProps().showWhenEmpty).toBe(false);
});
it("falls back to the whole world when the places cannot be read", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
getDiveStats.mockResolvedValue(stats());
getTripPlaces.mockRejectedValue(new Error("down"));
render(<HomePage />);
await waitFor(() => expect(mapProps().showWhenEmpty).toBe(true));
expect(screen.queryByText("Destinations")).not.toBeInTheDocument();
});
});
describe("Home hero figures", () => {
it("shows the four figures the API derived, species linking to the life list", async () => {
getDiveStats.mockResolvedValue(stats());
render(<HomePage />);
await screen.findByText("Species seen");
expect(figure("Total dives")).toHaveTextContent("212");
expect(figure("Total dive time")).toHaveTextContent(/^156h$/);
expect(figure("Max depth")).toHaveTextContent(/^39 m$/);
expect(
within(figure("Species seen") as HTMLElement).getByRole("link", {
name: "17",
}),
).toHaveAttribute("href", "/species");
});
it("rounds the time to whole hours past the first, and minutes under it", async () => {
// 156h 31min.
getDiveStats.mockResolvedValue(stats({ total_submerged_time: 563460 }));
const { unmount } = render(<HomePage />);
await screen.findByText("Total dives");
expect(figure("Total dive time")).toHaveTextContent(/^157h$/);
unmount();
getDiveStats.mockResolvedValue(stats({ total_submerged_time: 2400 }));
render(<HomePage />);
await screen.findByText("Total dives");
expect(figure("Total dive time")).toHaveTextContent(/^40min$/);
});
it("counts the dive sites, and leaves them out at zero", async () => {
getDiveStats.mockResolvedValue(stats());
const { unmount } = render(<HomePage />);
await screen.findByText("Total dives");
expect(figure("Dive sites")).toHaveTextContent(/^48$/);
unmount();
getDiveStats.mockResolvedValue(stats({ dive_site_count: 0 }));
render(<HomePage />);
await screen.findByText("Total dives");
expect(screen.queryByText("Dive sites")).not.toBeInTheDocument();
});
it("reads the logbook's totals, then where it went, then what it saw", async () => {
getDiveStats.mockResolvedValue(stats());
getTripPlaces.mockResolvedValue([DAHAB, MOALBOAL]);
render(<HomePage />);
await screen.findByText("Destinations");
expect(screen.getAllByRole("term").map((term) => term.textContent)).toEqual(
[
"Total dives",
"Max depth",
"Total dive time",
"Destinations",
"Dive sites",
"Species seen",
],
);
});
it("counts the places the diver's trips went", async () => {
getDiveStats.mockResolvedValue(stats());
getTripPlaces.mockResolvedValue([DAHAB, MOALBOAL]);
render(<HomePage />);
await waitFor(() =>
expect(figure("Destinations")).toHaveTextContent(/^2$/),
);
});
it("leaves Destinations out for a diver with no placed trips", async () => {
getDiveStats.mockResolvedValue(stats());
render(<HomePage />);
await waitFor(() => expect(getTripPlaces).toHaveBeenCalled());
await screen.findByText("Total dives");
expect(screen.queryByText("Destinations")).not.toBeInTheDocument();
});
it("leaves Species seen out for a diver who has logged none", async () => {
// Only once the answer is in: the loading dash below stands for it until then.
getDiveStats.mockResolvedValue(stats({ species_seen: 0 }));
render(<HomePage />);
await screen.findByText("Total dives");
expect(figure("Total dives")).toHaveTextContent("212");
expect(screen.queryByText("Species seen")).not.toBeInTheDocument();
});
it("holds a dash while the stats are still loading", async () => {
getDiveStats.mockReturnValue(new Promise(() => {}));
getTripPlaces.mockReturnValue(new Promise(() => {}));
render(<HomePage />);
await screen.findByText("Species seen");
// One per figure, and the counts are among them - "0 species" before the
// answer is known reads as a statement about the logbook.
expect(screen.getAllByText("—")).toHaveLength(6);
expect(figure("Destinations")).toHaveTextContent("—");
});
it("are in the hero, not a card under it", async () => {
getDiveStats.mockResolvedValue(stats());
render(<HomePage />);
await screen.findByText("Species seen");
expect(
screen.getByText("Total dives").closest(".rounded-lg.border"),
).toBeNull();
});
it("are left out for a diver with no dives, but for their destinations", async () => {
// A trip can be planned before a dive on it is logged.
getDiveStats.mockResolvedValue(stats({ total_dives: 0 }));
getTripPlaces.mockResolvedValue([DAHAB]);
render(<HomePage />);
await waitFor(() =>
expect(figure("Destinations")).toHaveTextContent(/^1$/),
);
expect(screen.queryByText("Total dives")).not.toBeInTheDocument();
expect(screen.queryByText("Species seen")).not.toBeInTheDocument();
});
});
describe("Home hero line", () => {
afterEach(() => {
vi.useRealTimers();
});
it("says when the logbook starts and when it was last added to", async () => {
// Only `Date`: faked timers would hold the requests' promises too.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 9, 8, 12));
getDiveStats.mockResolvedValue(stats());
render(<HomePage />);
await screen.findByText(/Diving since/);
expect(
screen.getByRole("heading", { level: 1 }).nextElementSibling,
).toHaveTextContent(/^Diving since 2014 · Last dive 3 days ago$/);
});
it("is left out until the stats are in", async () => {
getDiveStats.mockReturnValue(new Promise(() => {}));
render(<HomePage />);
await screen.findByText("Total dives");
expect(screen.queryByText(/Diving since/)).not.toBeInTheDocument();
});
});
// The stats effect lists `user`, so anything that gives `user` a new identity per
// render puts the effect in a loop with its own `setStats`.
//
// `mockImplementation` rather than this file's usual `mockResolvedValue`, and that is
// the whole reason this test can fail: a single resolved value is one object handed
// back to every call, so `setStats` receives the object it already holds, React bails
// out of the re-render, and the loop stalls after two passes. Measured, the same
// mount goes from 2 fetches a second to ~430 once each call answers with its own
// object, which is what a real API client does. See "A shared mock response object
// hides a render loop" in DECISIONS.md.
describe("the stats are read once, not once per render", () => {
it("reads the stats once", async () => {
getDiveStats.mockImplementation(async () => stats());
render(<HomePage />);
await screen.findByText("17");
// The loop turns on effects, which React runs on a task rather than a
// microtask, so awaiting the rendered figure alone gets here before the second
// pass. A short settle is what makes the difference visible: one fetch when the
// effect is keyed on a stable `user`, tens of them when it is not.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(getDiveStats).toHaveBeenCalledTimes(1);
});
});