import type { ReactNode } from "react"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { catalogKeys } from "@/hooks/queries/keys"; import { SIDEBAR_DETAILS_REVEAL_DEADLINE_MS } from "./sidebarItemNavigation"; const mocks = vi.hoisted(() => ({ location: { pathname: "/", search: "", key: "home" }, navigate: vi.fn(), prefetchQuery: vi.fn(), renderSurface: true, beginResult: undefined as boolean | undefined, })); vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); return { ...actual, useLocation: () => mocks.location, useNavigate: () => mocks.navigate, }; }); vi.mock("@tanstack/react-query", async () => { const actual = await vi.importActual("@tanstack/react-query"); return { ...actual, useQueryClient: () => ({ prefetchQuery: mocks.prefetchQuery }) }; }); vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { username: "Admin" } }) })); vi.mock("@/hooks/useCurrentProfile", () => ({ useCurrentProfile: () => ({ profile: { name: "Admin" } }), })); vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => false })); vi.mock("@/playback/watchPlaybackContext", () => ({ useWatchPlaybackController: () => ({ isBackgroundBarVisible: false }), })); vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({ useAudiobookPlaybackController: () => null, })); vi.mock("@/hooks/queries/catalogRead", () => ({ fetchCatalogItemDetail: vi.fn() })); vi.mock("@/components/GlobalSearch", () => ({ GlobalSearch: () => null })); vi.mock("@/components/ServerActivity", () => ({ default: () => null })); vi.mock("@/components/SiloBrand", () => ({ SiloBrand: () => Silo })); vi.mock("@/components/ViewTransitionLink", () => ({ default: ({ children }: { children: ReactNode }) => {children}, })); vi.mock("@/components/ui/sheet", () => ({ Sheet: () => null, SheetContent: ({ children }: { children: ReactNode }) => <>{children}, SheetHeader: ({ children }: { children: ReactNode }) => <>{children}, SheetTitle: ({ children }: { children: ReactNode }) => <>{children}, })); vi.mock("@/components/AppSidebar", () => ({ default: ({ collapsed }: { collapsed?: boolean }) => mocks.renderSurface ? ( ) : null, })); import Layout from "./Layout"; import { useSidebarItemDetailsReady, useSidebarItemNavigation, } from "./sidebarItemNavigationContext"; function Harness() { const begin = useSidebarItemNavigation(); const ready = useSidebarItemDetailsReady(); return ( <> {String(ready)} ); } function renderLayout() { return render( , ); } function setRoute(pathname: string, key: string) { mocks.location = { pathname, search: "", key }; } beforeEach(() => { vi.useFakeTimers(); mocks.location = { pathname: "/", search: "", key: "home" }; mocks.navigate.mockReset(); mocks.prefetchQuery.mockReset(); mocks.renderSurface = true; mocks.beginResult = undefined; vi.stubGlobal("matchMedia", (query: string) => ({ matches: query === "(min-width: 64rem)", media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), })); vi.stubGlobal( "requestAnimationFrame", vi.fn(() => 1), ); vi.stubGlobal("cancelAnimationFrame", vi.fn()); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("Layout item navigation", () => { it("declines interception on an item route, for a non-item href, and below lg", () => { const view = renderLayout(); const begin = screen.getByRole("button", { name: "begin item" }); setRoute("/item/already-there", "item"); view.rerender( , ); fireEvent.click(begin); expect(mocks.beginResult).toBe(false); expect(mocks.navigate).not.toHaveBeenCalled(); setRoute("/", "home-2"); view.rerender( , ); fireEvent.click(screen.getByRole("button", { name: "begin library" })); expect(mocks.beginResult).toBe(false); expect(mocks.navigate).not.toHaveBeenCalled(); const contextBegin = screen.getByRole("button", { name: "begin item" }); fireEvent.click(contextBegin); expect(mocks.beginResult).toBe(true); expect(mocks.navigate).toHaveBeenCalledOnce(); mocks.navigate.mockReset(); vi.stubGlobal("matchMedia", () => ({ matches: false, media: "(min-width: 64rem)", addEventListener: vi.fn(), removeEventListener: vi.fn(), })); view.rerender( , ); fireEvent.click(screen.getByRole("button", { name: "begin item" })); expect(mocks.beginResult).toBe(false); expect(mocks.navigate).not.toHaveBeenCalled(); }); it("prefetches and forwards navigation options on a successful intercept", () => { renderLayout(); fireEvent.click(screen.getByRole("button", { name: "begin item" })); expect(mocks.prefetchQuery).toHaveBeenCalledWith( expect.objectContaining({ queryKey: catalogKeys.itemDetail("movie-1", 2) }), ); expect(mocks.navigate).toHaveBeenCalledWith("/item/movie-1?libraryId=2", { replace: true, state: { source: "home" }, viewTransition: true, }); }); }); describe("Layout detail reveal", () => { it("reveals immediately when the sidebar surface is absent", () => { mocks.renderSurface = false; const view = renderLayout(); setRoute("/item/movie-1", "item"); act(() => view.rerender( , ), ); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true"); }); it("reveals as soon as the surface settles", () => { const view = renderLayout(); setRoute("/item/movie-1", "item"); act(() => view.rerender( , ), ); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("false"); screen.getByTestId("sidebar-surface").setAttribute("data-collapsed", "true"); act(() => vi.advanceTimersByTime(50)); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true"); }); it("reveals by the deadline while hover expansion holds the surface open", () => { const view = renderLayout(); setRoute("/item/movie-1", "item"); act(() => view.rerender( , ), ); act(() => vi.advanceTimersByTime(SIDEBAR_DETAILS_REVEAL_DEADLINE_MS)); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true"); }); it("reveals by the deadline when requestAnimationFrame never fires", () => { const view = renderLayout(); setRoute("/item/movie-1", "item"); act(() => view.rerender( , ), ); act(() => vi.advanceTimersByTime(SIDEBAR_DETAILS_REVEAL_DEADLINE_MS)); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true"); }); it("cancels reveal polling on unmount", () => { const view = renderLayout(); setRoute("/item/movie-1", "item"); act(() => view.rerender( , ), ); expect(vi.getTimerCount()).toBeGreaterThan(0); view.unmount(); expect(vi.getTimerCount()).toBe(0); }); it("reveals only for a collapsed surface transform transition", () => { const view = renderLayout(); setRoute("/item/movie-1", "item"); act(() => view.rerender( , ), ); const surface = screen.getByTestId("sidebar-surface"); surface.setAttribute("data-collapsed", "true"); fireEvent.transitionEnd(screen.getByTestId("sidebar-inner"), { propertyName: "transform" }); fireEvent.transitionEnd(screen.getByTestId("sidebar-row"), { propertyName: "transform" }); fireEvent.transitionEnd(surface, { propertyName: "opacity" }); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("false"); fireEvent.transitionEnd(surface, { propertyName: "transform" }); expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true"); }); });