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(); return { ...actual, gearAPI: { ...actual.gearAPI, getGearItems: vi.fn(), getGearSets: vi.fn(), updateGearItem: vi.fn(), deleteGearItem: vi.fn(), }, }; }); const gearItem = (overrides: Partial = {}): 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 => ({ 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 = (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(); 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(); 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(); 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(); 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(); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); }); });