Files
silo-server/web/src/components/Layout.test.tsx
T

329 lines
9.7 KiB
TypeScript

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<typeof import("react-router")>("react-router");
return {
...actual,
useLocation: () => mocks.location,
useNavigate: () => mocks.navigate,
};
});
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>("@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: () => <span>Silo</span> }));
vi.mock("@/components/ViewTransitionLink", () => ({
default: ({ children }: { children: ReactNode }) => <a href="/">{children}</a>,
}));
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 ? (
<aside
className="sidebar-surface"
data-collapsed={collapsed ? "true" : undefined}
data-testid="sidebar-surface"
>
<div className="sidebar-inner" data-testid="sidebar-inner" />
<div className="sidebar-row-shift" data-testid="sidebar-row" />
</aside>
) : null,
}));
import Layout from "./Layout";
import {
useSidebarItemDetailsReady,
useSidebarItemNavigation,
} from "./sidebarItemNavigationContext";
function Harness() {
const begin = useSidebarItemNavigation();
const ready = useSidebarItemDetailsReady();
return (
<>
<output aria-label="details-ready">{String(ready)}</output>
<button
onClick={() => {
mocks.beginResult = begin?.({
href: "/item/movie-1?libraryId=2",
replace: true,
state: { source: "home" },
});
}}
>
begin item
</button>
<button
onClick={() => {
mocks.beginResult = begin?.({ href: "/library/1" });
}}
>
begin library
</button>
</>
);
}
function renderLayout() {
return render(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
}
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
fireEvent.click(begin);
expect(mocks.beginResult).toBe(false);
expect(mocks.navigate).not.toHaveBeenCalled();
setRoute("/", "home-2");
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
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(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
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");
});
});