// @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 { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./navigationContext"; import { useBackToClose } from "../lib/history"; import { qk } from "../lib/queryKeys"; // The provider reads the signed-in account (the tab title) from the `me` cache. let qc = new QueryClient(); 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(() => { qc = new QueryClient(); qc.setQueryData(qk.me(), { id: 1 }); 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 · the tab title", () => { // Prod UAT 2026-09-19: signed out, the tab read the Home page's question over the sign-in form. it("is the brand alone while nobody is signed in, the page's name once someone is", async () => { window.history.replaceState({ sfPage: "home" }, "", "/?page=home"); qc.setQueryData(qk.me(), null); render(); await settle(); expect(document.title).toBe("Siftlode"); act(() => qc.setQueryData(qk.me(), { id: 1 })); await settle(); expect(document.title).not.toBe("Siftlode"); expect(document.title.endsWith(" · Siftlode")).toBe(true); }); }); 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("leaves every page's own URL keys behind, the log console's included", () => { window.history.replaceState( { sfPage: "logs" }, "", "/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1", ); const { result } = render(); act(() => result.current.setPage("plex")); expect(window.location.search).toBe("?page=plex&keep=1"); }); 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" }); }); // `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so // over an open player it must wait for the push, and with nothing to push it still runs. it("runs setPage's `then` after the push, on the entry the push made", async () => { const { result } = render(); await openOverlay(); const seen: unknown[] = []; expect(result.current.page).not.toBe("plex"); act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage))); expect(seen).toEqual([]); await settle(); await settle(); expect(seen).toEqual(["plex"]); act(() => result.current.setPage("plex", () => seen.push("again"))); expect(seen).toEqual(["plex", "again"]); }); // 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" }); }); });