src/app/gear/page.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { reveal } from "@/test/intersection";
import userEvent from "@testing-library/user-event";
import GearPage from "./page";
import { gearAPI, type GearItem, type GearSet } from "@/lib/api/gear";
import { announceSavedElsewhere } from "@/lib/saved-elsewhere";
// 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 two fetch callbacks list `user`
// in their dependencies. A mock handing back a fresh `user` per render gives
// `useInfiniteResource` a new `fetchFn` every time, and its fetch-on-mount effect
// re-runs on every render the fetch itself causes. See "The new-dive render test's
// mocks are identity-stable, so the prefill effect runs once" in DECISIONS.md, and
// "reads the gear list once" below.
//
// `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(() => ({
auth: {
user: { uuid: "user-1" },
isAuthenticated: true,
isLoading: false,
},
router: { replace: vi.fn(), push: vi.fn() },
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => stable.auth,
}));
vi.mock("next/navigation", () => ({
useRouter: () => stable.router,
}));
// Lives in `AppShell`, above the page rendered here.
vi.mock("@/components/layout/quick-create", () => ({
useQuickCreate: () => vi.fn(),
}));
vi.mock("@/lib/api/gear", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/gear")>();
return {
...actual,
gearAPI: {
...actual.gearAPI,
getGearItems: vi.fn(),
getGearSets: vi.fn(),
updateGearItem: vi.fn(),
deleteGearItem: vi.fn(),
},
};
});
const gearItem = (overrides: Partial<GearItem> = {}): GearItem => ({
uuid: "item-1",
name: "MK25 EVO",
brand: "Scubapro",
type: "regulator",
rented: false,
is_archived: false,
dive_count: 12,
dive_time: 43200,
user_uuid: "user-1",
created_at: "2026-01-01T00:00:00+00:00",
...overrides,
});
const gearSet = (overrides: Partial<GearSet> = {}): GearSet => ({
uuid: "set-1",
name: "Warm water rig",
gear_items: [],
user_uuid: "user-1",
created_at: "2026-01-01T00:00:00+00:00",
...overrides,
});
const page = <T,>(items: T[]) => ({
data: items,
total_count: items.length,
has_more: false,
page: 1,
items_per_page: 10,
});
const getGearItems = vi.mocked(gearAPI.getGearItems);
const updateGearItem = vi.mocked(gearAPI.updateGearItem);
const deleteGearItem = vi.mocked(gearAPI.deleteGearItem);
beforeEach(() => {
vi.clearAllMocks();
getGearItems.mockResolvedValue(page([gearItem()]));
vi.mocked(gearAPI.getGearSets).mockResolvedValue(page([]));
updateGearItem.mockResolvedValue({ message: "Gear updated" });
deleteGearItem.mockResolvedValue({ message: "Gear deleted" });
});
// Opens a card's actions menu by its name and picks one of its items.
const pick = async (menu: string, item: string) => {
await userEvent.click(await screen.findByRole("button", { name: menu }));
await userEvent.click(await screen.findByRole("menuitem", { name: item }));
};
// Renders the page and opens the delete confirmation for its first card.
const openDeleteDialog = async () => {
render(<GearPage />);
await screen.findByText("MK25 EVO");
await pick("Actions for Scubapro MK25 EVO", "Delete");
return screen.findByRole("dialog");
};
// The delete confirmation offers a way out of deleting, and the way out has to
// actually archive - a wire-up mistake here reads as "Archive instead" on a button
// that deletes, which is the failure the copy is meant to prevent.
describe("gear page delete confirmation", () => {
it("tells the diver what deleting actually does", async () => {
const dialog = await openDeleteDialog();
expect(dialog).toHaveTextContent(
"Deleting removes this gear from your dives and gear sets",
);
});
it("puts the reminders on both paths, not just the delete", async () => {
// Archiving silences a schedule as surely as deleting does. Attached to the
// delete alone, the sentence reads as a reason to archive that isn't true.
const dialog = await openDeleteDialog();
expect(dialog).toHaveTextContent("Either way, its service reminders stop.");
});
it("archives rather than deletes when the diver takes the way out", async () => {
await openDeleteDialog();
await userEvent.click(
screen.getByRole("button", { name: "Archive instead" }),
);
await waitFor(() =>
expect(updateGearItem).toHaveBeenCalledWith("item-1", {
is_archived: true,
}),
);
expect(deleteGearItem).not.toHaveBeenCalled();
});
it("leaves the delete dialog closed once the diver archives instead", async () => {
await openDeleteDialog();
await userEvent.click(
screen.getByRole("button", { name: "Archive instead" }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
});
it("doesn't offer to archive gear that is archived already", async () => {
// `toggleArchived` on an archived item unarchives it, so the offer would do the
// opposite of what its label says. Reachable via the list's "Show archived".
getGearItems.mockResolvedValue(page([gearItem({ is_archived: true })]));
const dialog = await openDeleteDialog();
expect(dialog).not.toHaveTextContent("Archive instead");
});
});
// A screen reader's controls list is flat: ten menus of "Actions" name nothing, so
// each card's menu carries the item it acts on. See DECISIONS.md, "Ten rows of 'Edit'
// name nothing".
describe("gear card menus name their card", () => {
// The items of the menu that is open.
const menuItems = async () =>
(await screen.findAllByRole("menuitem")).map((item) => item.textContent);
it("names each gear item's menu after the item", async () => {
render(<GearPage />);
await userEvent.click(
await screen.findByRole("button", {
name: "Actions for Scubapro MK25 EVO",
}),
);
expect(await menuItems()).toEqual(["Edit", "Archive", "Delete"]);
});
it("offers to unarchive gear that is archived", async () => {
getGearItems.mockResolvedValue(page([gearItem({ is_archived: true })]));
render(<GearPage />);
await userEvent.click(
await screen.findByRole("button", {
name: "Actions for Scubapro MK25 EVO",
}),
);
expect(await menuItems()).toEqual(["Edit", "Unarchive", "Delete"]);
});
it("tells two cards apart by name rather than by position", async () => {
getGearItems.mockResolvedValue(
page([
gearItem(),
gearItem({ uuid: "item-2", name: "R195", brand: "Scubapro" }),
]),
);
render(<GearPage />);
await screen.findByText("R195");
const menus = screen.getAllByRole("button", { name: /^Actions for / });
expect(menus.map((button) => button.getAttribute("aria-label"))).toEqual([
"Actions for Scubapro MK25 EVO",
"Actions for Scubapro R195",
]);
});
it("names each gear set's menu after the set", async () => {
vi.mocked(gearAPI.getGearSets).mockResolvedValue(page([gearSet()]));
render(<GearPage />);
// The sets card asks for nothing until the reader is near it - it sits below
// the gear list and reads as its continuation. Nothing here is on screen in
// jsdom, so the scroll has to be stated.
await act(async () => reveal());
await userEvent.click(
await screen.findByRole("button", { name: "Actions for Warm water rig" }),
);
expect(await menuItems()).toEqual(["Edit", "Duplicate", "Delete"]);
});
it("opens a duplicated set as a new one, named after the original", async () => {
vi.mocked(gearAPI.getGearSets).mockResolvedValue(page([gearSet()]));
render(<GearPage />);
await act(async () => reveal());
await pick("Actions for Warm water rig", "Duplicate");
const dialog = within(await screen.findByRole("dialog"));
expect(dialog.getByText("New Gear Set")).toBeInTheDocument();
expect(dialog.getByLabelText("Set name *")).toHaveValue(
"Warm water rig copy",
);
});
});
// An empty card drops its header, as a one-list page's does: a count of nothing and a
// "New set" beside the empty state's own button are furniture around the one sentence
// worth reading. The gear list's header also holds the Show archived switch, so a list
// that is only *active*-empty keeps it - there, the switch is the way to the rest.
describe("an empty gear card keeps only its hidden heading", () => {
// By prefix: a header that is showing carries its switch, count and button
// inside the heading, and they are part of its name.
const headingOf = (name: string) =>
screen.getByRole("heading", { level: 2, name: new RegExp(`^${name}`) });
it("drops the gear list's header when there is no gear at all", async () => {
getGearItems.mockImplementation(async () => page([]));
render(<GearPage />);
await screen.findByText("No gear yet");
const heading = headingOf("Your Gear");
expect(heading).toHaveClass("sr-only");
expect([...heading.parentElement!.children]).toEqual([heading]);
expect(screen.queryByLabelText("Show archived")).not.toBeInTheDocument();
});
it("keeps it, switch and all, when the only gear is archived", async () => {
getGearItems.mockImplementation(async (_page, _perPage, filters) =>
page(filters?.includeArchived ? [gearItem({ is_archived: true })] : []),
);
render(<GearPage />);
await screen.findByText("No active gear");
expect(headingOf("Your Gear")).not.toHaveClass("sr-only");
expect(screen.getByLabelText("Show archived")).toBeInTheDocument();
});
it("drops the sets card's header when there are no sets", async () => {
render(<GearPage />);
await act(async () => reveal());
await screen.findByText("No gear sets yet");
const heading = headingOf("Gear Sets");
expect(heading).toHaveClass("sr-only");
expect([...heading.parentElement!.children]).toEqual([heading]);
expect(
screen.queryByRole("button", { name: "New set" }),
).not.toBeInTheDocument();
});
it("keeps both headers while the lists are still loading", () => {
getGearItems.mockReturnValue(new Promise(() => {}));
render(<GearPage />);
expect(headingOf("Your Gear")).not.toHaveClass("sr-only");
expect(headingOf("Gear Sets")).not.toHaveClass("sr-only");
});
});
// The page's fetch callbacks close over `user`, and `useInfiniteResource` fetches
// from an effect keyed on the callback - so anything that gives `user` a new identity
// per render puts the effect in a loop with the fetch it started.
//
// `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 `setItems` receives the array it already holds, React bails
// out of the re-render, and the loop stalls after a handful of passes. Measured, the
// same mount goes from 6 fetches a second to ~400 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 gear list is read once, not once per render", () => {
it("reads the gear list once", async () => {
getGearItems.mockImplementation(async () => page([gearItem()]));
render(<GearPage />);
await screen.findByText("MK25 EVO");
expect(getGearItems).toHaveBeenCalledTimes(1);
});
});
describe("a service logged from the header's bell", () => {
it("re-reads the gear list in place, keeping the rows past page one", async () => {
// The bell opens the service log over this page, and the diver may be a page or
// more down the list. Reading from page one again would drop them back to ten rows.
const all = Array.from({ length: 11 }, (_, index) =>
gearItem({ uuid: `item-${index}`, name: `Item ${index}` }),
);
getGearItems.mockImplementation(async (pageNo = 1, perPage = 10) => ({
data: all.slice((pageNo - 1) * perPage, pageNo * perPage),
total_count: all.length,
has_more: pageNo * perPage < all.length,
page: pageNo,
items_per_page: perPage,
}));
render(<GearPage />);
await screen.findByText("Item 0");
await act(async () => reveal());
await screen.findByText("Item 10");
getGearItems.mockClear();
act(() =>
announceSavedElsewhere("gear-service", { gearItemUuid: "item-3" }),
);
await waitFor(() =>
expect(getGearItems).toHaveBeenCalledWith(1, 11, {
includeArchived: false,
}),
);
expect(screen.getByText("Item 10")).toBeInTheDocument();
});
});