src/components/dives/dive-neighbor-nav.render.test.tsx
Rama main · réplica de lectura del repositorio original.
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<typeof import("@/lib/api/dives")>()),
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> = {}): 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(<DiveNeighborNav diveUuid="current-uuid" />);
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(<DiveNeighborNav diveUuid="current-uuid" />);
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(<DiveNeighborNav diveUuid="current-uuid" />);
// 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(<DiveNeighborNav diveUuid="current-uuid" />);
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(<DiveNeighborNav diveUuid="current-uuid" />);
await screen.findByRole("link", { name: /previous dive/i });
// Still a link, and still named: `aria-disabled` rather than a `<button
// disabled>`, so the node survives the step and keeps its accessible name.
const next = screen.getByRole("link", { name: "Next dive" });
expect(next).toHaveAttribute("aria-disabled", "true");
expect(next).not.toHaveAttribute("href");
// The end of the log, not a pending fetch - the one distinction a screen
// reader has between "you've reached the oldest dive" and "hang on".
expect(next).toHaveAttribute("aria-busy", "false");
});
it("keeps the same link node - and the focus on it - when the dive changes", async () => {
// The reason this component doesn't use `next/link`. A keyboard diver tabs to
// "Next", presses Enter, and lands on the next dive; if the link's DOM node is
// replaced on the way - which alternating `<Link>` with anything else does -
// the browser drops focus to `<body>` and they tab back for every dive in the
// trip. The replacement this reaches is the one inside a mounted component:
// the link goes dead and comes back once per dive, and has to stay one node
// through it. The route change around that is verified in a browser.
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors());
const { rerender } = render(<DiveNeighborNav diveUuid="current-uuid" />);
const before = await screen.findByRole("link", { name: /next dive/i });
before.focus();
expect(document.activeElement).toBe(before);
// What a step leaves behind here: same component, new dive, neighbours not
// yet known - the window in which the button has nothing to point at.
rerender(<DiveNeighborNav diveUuid="newer-uuid" />);
const during = screen.getByRole("link", { name: "Next dive" });
expect(during).toBe(before);
expect(during).toHaveAttribute("aria-disabled", "true");
// Dead because the neighbours aren't known yet, which is a different thing
// from having run out of dives.
expect(during).toHaveAttribute("aria-busy", "true");
expect(document.activeElement).toBe(before);
// And still the same node once the new neighbours arrive.
await waitFor(() => {
expect(screen.getByRole("link", { name: /next dive:/i })).toBe(before);
});
expect(document.activeElement).toBe(before);
});
it("navigates on a plain click and leaves a modified one to the browser", async () => {
// Both halves of what `<Link>` was doing before the node-identity problem
// above pushed it out: a plain click is a client-side push, and cmd-click
// still opens the dive in a new tab.
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors());
render(<DiveNeighborNav diveUuid="current-uuid" />);
const next = await screen.findByRole("link", { name: /next dive/i });
next.click();
expect(push).toHaveBeenCalledWith("/dives/newer-uuid");
push.mockClear();
next.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
metaKey: true,
}),
);
expect(push).not.toHaveBeenCalled();
});
it("leaves both buttons dead when the neighbours can't be loaded", async () => {
// A failure here is not the diver's problem - the dive they came for rendered
// fine - so it costs them the shortcut and nothing else.
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
vi.mocked(divesAPI.getDiveNeighbors).mockRejectedValue(new Error("boom"));
render(<DiveNeighborNav diveUuid="current-uuid" />);
// Waiting on the log line, not on the call: the call is made synchronously in
// the effect, so waiting for it would resolve on the first check and assert a
// state indistinguishable from "still in flight".
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to load neighbouring dives:",
expect.any(Error),
);
});
for (const name of ["Previous dive", "Next dive"]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("aria-disabled", "true");
expect(link).not.toHaveAttribute("href");
}
});
it("re-reads when the page bumps its reload token, with the uuid unchanged", async () => {
// A merge this dive survived soft-deletes the dive it absorbed, and the
// absorbed dive is one of the two this pager is pointing at - but the uuid
// on screen does not change, so nothing about the dive can tell this
// component to look again. Without the token the left arrow stays live and
// aimed at a dive that no longer resolves: one click, a failed load, and
// the diver is bounced to `/dives` with an error about a dive they never
// asked for.
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValueOnce(neighbors());
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(
neighbors({
previous: {
uuid: "older-still-uuid",
dive_number: 10,
start_time: "2021-04-02T09:00:00+02:00",
},
}),
);
const { rerender } = render(
<DiveNeighborNav diveUuid="current-uuid" reloadToken={0} />,
);
await screen.findByRole("link", { name: /previous dive: #11/i });
rerender(<DiveNeighborNav diveUuid="current-uuid" reloadToken={1} />);
await waitFor(() =>
expect(
screen.getByRole("link", { name: /previous dive: #10/i }),
).toHaveAttribute("href", "/dives/older-still-uuid"),
);
expect(divesAPI.getDiveNeighbors).toHaveBeenCalledTimes(2);
});
it("never aims the pager at the dive that was just navigated away from", async () => {
// Following one of these links swaps the uuid on a component that stays
// mounted. Held as plain state, the old dive's neighbours would still be on
// screen for the length of the new fetch - long enough to click, and the click
// would land two dives from where it looked like it went.
vi.mocked(divesAPI.getDiveNeighbors).mockResolvedValue(neighbors());
const { rerender } = render(<DiveNeighborNav diveUuid="current-uuid" />);
await screen.findByRole("link", { name: /previous dive/i });
let resolveSecond: (value: DiveNeighbors) => void = () => {};
vi.mocked(divesAPI.getDiveNeighbors).mockReturnValue(
new Promise<DiveNeighbors>((resolve) => {
resolveSecond = resolve;
}),
);
rerender(<DiveNeighborNav diveUuid="newer-uuid" />);
// Dead, rather than still carrying the previous dive's destination.
expect(
screen.getByRole("link", { name: "Previous dive" }),
).not.toHaveAttribute("href");
resolveSecond(
neighbors({
previous: {
uuid: "current-uuid",
dive_number: 12,
start_time: "2021-04-04T10:04:47+02:00",
},
}),
);
await waitFor(() => {
expect(
screen.getByRole("link", { name: /previous dive:/i }),
).toHaveAttribute("href", "/dives/current-uuid");
});
});
});