- The browser tab names the place and the object: PageHeading reports the view's own title (lib/documentTitle), so a channel, playlist or any Plex view reads "<object> · <module> · Siftlode" however it was opened; the Feed is named too; a season adds its show (tabTitle). - The channel page's tab + action row wraps and lets the tag lane fold into "+N": at 1024 it ran past the edge and the page scrolled sideways. - The YouTube player card's width is capped from the window height less the chrome measured under the video: at 133% zoom (685px) it cut its channel row. - Plex certificate words (Not Rated, Approved, Passed) are named through the vocabulary on the info badge, show page, collection, filter and chips. - DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
// @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, render as rtlRender, 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<typeof import("react-i18next")>();
|
|
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 { PageHeading } from "./ui/Headings";
|
|
|
|
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 }) => (
|
|
<QueryClientProvider client={qc}>
|
|
<NavigationProvider>{children}</NavigationProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
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);
|
|
});
|
|
|
|
// E31 S1: the Feed's tab read a bare "Siftlode" — the one page that named no place.
|
|
it("names the Feed like every other page", async () => {
|
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
|
render();
|
|
await settle();
|
|
expect(document.title).toBe("header.account.feed · Siftlode");
|
|
});
|
|
|
|
// E31 S1: a channel opened from a link was "Channels · Siftlode", the same channel opened in the
|
|
// app was named; a Plex title, person or collection only "Plex". The object is the view's heading.
|
|
it("puts the open view's own heading before the place, and drops it when the view closes", async () => {
|
|
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
|
|
function View({ name }: { name: string | null }) {
|
|
return name === null ? null : <PageHeading>{name}</PageHeading>;
|
|
}
|
|
const view = rtlRender(<View name="" />, { wrapper });
|
|
await settle();
|
|
expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
|
|
|
|
view.rerender(<View name="Nicolas Cage" />);
|
|
await settle();
|
|
expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
|
|
|
|
view.rerender(<View name={null} />);
|
|
await settle();
|
|
expect(document.title).toBe("plex.navLabel · Siftlode");
|
|
});
|
|
|
|
it("takes a heading's tabTitle over its text", async () => {
|
|
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
|
|
rtlRender(<PageHeading tabTitle="Voyager · Season 1">Season 1</PageHeading>, { wrapper });
|
|
await settle();
|
|
expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode");
|
|
});
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|