import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createStore, Provider } from "jotai";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { recentPagesAtom, type RecentPage } from "../../../atoms";
import { configureI18n } from "../../../i18n";
import { SidebarMenu, SidebarProvider } from "../../../shadcnui";
import { RecentPagesNavigator } from "../RecentPagesNavigator";
// usePathname is globally mocked to "/" in vitest.setup.ts. Override it here so
// the current-page filter can be exercised. A spec-level vi.mock takes
// precedence over the setup-file mock for this module.
const pathnameRef: { current: string | null } = { current: "/" };
vi.mock("next/navigation", () => ({
usePathname: () => pathnameRef.current,
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
redirect: vi.fn(),
notFound: vi.fn(),
}));
beforeEach(() => {
pathnameRef.current = "/";
});
beforeAll(() => {
// The package Link resolves its inner component at runtime and throws if i18n
// was never configured (src/i18n/config.ts).
configureI18n({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
useTranslations: () => (key: string) => key,
usePathname: () => "/",
Link: ({ href, children, ...rest }: { href: string; children: React.ReactNode; [key: string]: any }) => (
{children}
),
});
});
const PAGES: RecentPage[] = [
{ url: "/npcs/npc-1", title: "Charlie", moduleType: "npcs", timestamp: 2 },
{ url: "/campaigns/camp-1", title: "Venezia Obscura", moduleType: "campaigns", timestamp: 1 },
];
function renderNavigator({
pages = PAGES,
open = true,
pathname = "/",
}: { pages?: RecentPage[]; open?: boolean; pathname?: string | null } = {}) {
pathnameRef.current = pathname;
const store = createStore();
store.set(recentPagesAtom, pages);
return render(
,
);
}
describe("RecentPagesNavigator", () => {
it("renders nothing when no pages have been recorded", () => {
renderNavigator({ pages: [] });
expect(screen.queryByTestId("sidebar-recent-pages")).toBeNull();
});
it("renders exactly one trigger button", () => {
// Regression: four apps wrapped this component in their own
// SidebarMenuButton and rendered a second HistoryIcon beside it. The
// wrapper had no handler, so the visible icon was dead in the collapsed
// rail. Exactly one button, and it must be the trigger.
renderNavigator();
const triggers = screen.getAllByTestId("sidebar-recent-pages");
expect(triggers).toHaveLength(1);
expect(triggers[0].tagName).toBe("BUTTON");
});
it("opens the menu when the trigger is clicked in the collapsed rail", async () => {
renderNavigator({ open: false });
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Charlie")).toBeInTheDocument();
});
it("opens the menu when the trigger is clicked while expanded", async () => {
renderNavigator({ open: true });
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Venezia Obscura")).toBeInTheDocument();
});
it("shows the entity name over its translated singular type", async () => {
renderNavigator();
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Charlie")).toBeInTheDocument();
// The i18n mock echoes the key back, so the second line is the raw key.
expect(screen.getByText("entities.npcs")).toBeInTheDocument();
expect(screen.getByText("entities.campaigns")).toBeInTheDocument();
});
it("makes each entry an anchor pointing at the recorded url", async () => {
renderNavigator();
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
const link = (await screen.findByText("Charlie")).closest("a");
expect(link).not.toBeNull();
expect(link).toHaveAttribute("href", "/npcs/npc-1");
});
it("hides the entry for the page the user is currently on", async () => {
renderNavigator({ pathname: "/npcs/npc-1" });
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Venezia Obscura")).toBeInTheDocument();
expect(screen.queryByText("Charlie")).toBeNull();
});
it("hides the entry for the entity whose sub-page the user is on", async () => {
renderNavigator({ pathname: "/campaigns/camp-1/canvas" });
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Charlie")).toBeInTheDocument();
expect(screen.queryByText("Venezia Obscura")).toBeNull();
});
it("does not hide an entry whose url is merely a string prefix of the current path", async () => {
// "/npcs/npc-1" must not be hidden while on "/npcs/npc-10".
renderNavigator({
pages: [{ url: "/npcs/npc-1", title: "Charlie", moduleType: "npcs", timestamp: 1 }],
pathname: "/npcs/npc-10",
});
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Charlie")).toBeInTheDocument();
});
it("renders nothing when the only recorded page is the one being viewed", () => {
renderNavigator({
pages: [{ url: "/npcs/npc-1", title: "Charlie", moduleType: "npcs", timestamp: 1 }],
pathname: "/npcs/npc-1",
});
expect(screen.queryByTestId("sidebar-recent-pages")).toBeNull();
});
it("hides nothing when the pathname is unavailable", async () => {
// usePathname is typed non-nullable but can return null in practice; the
// sidebar must degrade rather than throw.
renderNavigator({ pathname: null as unknown as string });
await userEvent.click(screen.getByTestId("sidebar-recent-pages"));
expect(await screen.findByText("Charlie")).toBeInTheDocument();
expect(screen.getByText("Venezia Obscura")).toBeInTheDocument();
});
});