// @vitest-environment jsdom // // The navigation actions against the overlay history stack: `setPage` and `openChannel` never push // under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and // `setPage`'s same-page guard, which decides whether anything is pushed at all. // // The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the // palette, the player's channel link) and could be forgotten at any of them; it now lives in the two // actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player, // then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as // bookkeeping. import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { renderHook, act, cleanup } from "@testing-library/react"; import type { ReactNode } from "react"; // Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the // import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a // wholesale mock throws before any test runs. vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), }; }); import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider"; import { useBackToClose } from "../lib/history"; const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const render = () => renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); /** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to * land its popstate at once. */ async function openOverlay() { const back = vi.spyOn(window.history, "back").mockImplementation(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); const holder: { unmount?: () => void } = {}; const onClose = vi.fn(() => holder.unmount?.()); const layer = renderHook(() => useBackToClose(onClose)); holder.unmount = layer.unmount; await settle(); return { back, onClose }; } beforeEach(() => { localStorage.clear(); sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory window.history.replaceState({ sfPage: "feed" }, "", "/"); }); // Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a // provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does. afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("NavigationProvider · setPage's same-page guard", () => { it("pushes an entry for a real page change", () => { const { result } = render(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage("plex")); expect(push).toHaveBeenCalledTimes(1); expect(result.current.page).toBe("plex"); }); it("pushes nothing when it is already that page", () => { const { result } = render(); act(() => result.current.setPage("plex")); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage("plex")); expect(push).not.toHaveBeenCalled(); }); it("still navigates to the current page when a channel page is over it", () => { // The nav rail's own module while a channel page is open over it is a real navigation: it closes // the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one. const { result } = render(); // The page under test must be the one that is ALREADY current, or the guard's first conjunct is // false and this never reaches the clause it is named for — the mistake round 4 caught here: a // bare URL with cleared storage lands on "home" (navigationInit's E24 default), not "feed", so // asking for "feed" was just an ordinary page change and deleting the clause left it green. const current = result.current.page; expect(current).toBe("home"); act(() => result.current.openChannel("UC1", "A channel")); expect(result.current.channelView).not.toBeNull(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage(current)); expect(push).toHaveBeenCalledTimes(1); expect(result.current.channelView).toBeNull(); }); }); describe("NavigationProvider · never under an open overlay", () => { it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => { const { result } = render(); const { back, onClose } = await openOverlay(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage("plex")); expect(onClose).toHaveBeenCalledTimes(1); expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current await settle(); await settle(); expect(back).toHaveBeenCalledTimes(1); expect(push).toHaveBeenCalledTimes(1); expect(result.current.page).toBe("plex"); expect(window.history.state).toMatchObject({ sfPage: "plex" }); }); // Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player // closes, and its entry is reclaimed rather than orphaned under a push that never happened. it("setPage to the current page closes the overlay and pushes nothing", async () => { const { result } = render(); const { back, onClose } = await openOverlay(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage(result.current.page)); await settle(); await settle(); expect(onClose).toHaveBeenCalledTimes(1); expect(back).toHaveBeenCalledTimes(1); expect(push).not.toHaveBeenCalled(); }); // The player's channel link and the palette's channel rows. it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => { const { result } = render(); const { back, onClose } = await openOverlay(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.openChannel("UC1", "A channel")); expect(onClose).toHaveBeenCalledTimes(1); expect(push).not.toHaveBeenCalled(); await settle(); await settle(); expect(back).toHaveBeenCalledTimes(1); expect(push).toHaveBeenCalledTimes(1); expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" }); expect(window.history.state).toMatchObject({ _chan: "UC1" }); }); });