import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { DiveNeighborNav } from "./dive-neighbor-nav"; import type { DiveNeighbors } from "@/lib/api/dives"; // What only a render reaches is the wiring: which button points at which end of the // log (getting this backwards is invisible until you click), that the word on screen // stays put while the destination behind it changes - the reason the date is on the // accessible name and not in the label - that an end of the log leaves a button // present but dead rather than dropping it, that a new `diveUuid` never leaves the // pager aimed at the dive you just left - the reason the fetched neighbours are keyed // by uuid - and that the link keeps its DOM node while it goes dead and comes back, // which is the only thing standing between a keyboard diver and re-tabbing to "Next" // on every dive in a trip. // // The prop change below is a `rerender`, and a real step is a route change: the two // were conflated here for a while, and the difference is why this file passed for // months while a diver following the arrows lost focus on every single one. Whether // this component survives a step at all is the route tree's business - see // "`dives/(detail)/layout.tsx` owns the dive fetch, so a step keeps the page // mounted" in DECISIONS.md. What is left here is what it does once it // has survived one. vi.mock("@/lib/api/dives", async (importOriginal) => ({ ...(await importOriginal()), divesAPI: { getDiveNeighbors: vi.fn(), }, })); const push = vi.fn(); // Stable reference, as in `useAuthGuard.test.tsx`. const router = { push }; const at = vi.hoisted(() => ({ searchParams: new URLSearchParams() })); vi.mock("next/navigation", () => ({ useRouter: () => router, useSearchParams: () => at.searchParams, })); const { divesAPI } = await import("@/lib/api/dives"); function neighbors(overrides: Partial = {}): DiveNeighbors { return { previous: { uuid: "older-uuid", dive_number: 11, start_time: "2021-04-03T09:00:00+02:00", }, next: { uuid: "newer-uuid", dive_number: 13, start_time: "2021-04-05T09:00:00+02:00", }, ...overrides, }; } describe("DiveNeighborNav", () => { beforeEach(() => { vi.mocked(divesAPI.getDiveNeighbors).mockReset(); push.mockClear(); at.searchParams = new URLSearchParams(); }); // The `console.error` stub below would otherwise stay installed for the rest of // the file, silently disarming the one signal an unexpected failure would raise. afterEach(() => { vi.restoreAllMocks(); }); it("points the left button at the earlier dive and the right at the later one", async () => { // The whole point of the control, and the one thing it can get wrong while // still rendering perfectly: `getDives` lists newest first, so "next" is the // opposite direction there. vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors()); render(); const previous = await screen.findByRole("link", { name: /previous dive/i, }); expect(previous).toHaveAttribute("href", "/dives/older-uuid"); expect(previous).toHaveAccessibleName(/#11, Apr 3, 2021/); const next = screen.getByRole("link", { name: /next dive/i }); expect(next).toHaveAttribute("href", "/dives/newer-uuid"); expect(next).toHaveAccessibleName(/#13, Apr 5, 2021/); }); it("keeps the way back the dive on screen was opened with", async () => { at.searchParams = new URLSearchParams("from=/trips/trip-1"); vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors()); render(); expect( await screen.findByRole("link", { name: /previous dive/i }), ).toHaveAttribute("href", "/dives/older-uuid?from=%2Ftrips%2Ftrip-1"); }); it("shows a word that doesn't change with the destination behind it", async () => { // A label carrying the neighbour's date would empty and refill on every step, // resizing the button under the cursor of the diver clicking it. The words are // fixed, and each is the start of its own accessible name - so the visible // label stays part of the announced one. vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors()); render(); // On screen before the neighbours land, since neither word depends on them. expect(screen.getByText("Previous")).toBeInTheDocument(); expect(screen.getByText("Next")).toBeInTheDocument(); await screen.findByRole("link", { name: /previous dive:/i }); expect(screen.getByText("Previous")).toBeInTheDocument(); expect(screen.getByText("Next")).toBeInTheDocument(); }); it("names the pair, so the two links are heard as one control", async () => { vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors()); render(); expect( screen.getByRole("navigation", { name: "Adjacent dives" }), ).toBeInTheDocument(); await screen.findByRole("link", { name: /previous dive:/i }); }); it("leaves an end of the log dead rather than dropping its button", async () => { // The newest dive in the log. A dropped button would slide the pair sideways as // the diver steps onto it, and would say nothing about why stepping stopped. vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue( neighbors({ next: null }), ); render(); await screen.findByRole("link", { name: /previous dive/i }); // Still a link, and still named: `aria-disabled` rather than a `