Files
siftlode/frontend/src/components/NavigationProvider.dom.test.tsx
T
peter 078194731b feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
2026-10-11 19:37:13 +02:00

328 lines
14 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 Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
// 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);
});
// 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");
});
// 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 a review caught here: a
// bare URL with cleared storage lands on "home" (navigationInit's 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 · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history
// entries still name that page, and Back lands on one of them.
it("lands on the feed when the entry names a page this build does not have", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "messages" }, "", "/?page=messages&keep=1");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "messages" } }));
});
expect(result.current.page).toBe("feed");
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.location.search).toBe("?keep=1");
});
it("lands where a retired page's content went, and the entry names that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "downloads" }, "", "/?page=downloads&keep=1");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "downloads" } }));
});
expect(result.current.page).toBe("files");
expect(window.history.state).toMatchObject({ sfPage: "files" });
expect(window.location.search).toBe("?page=files&keep=1");
});
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
it("does not put back the focus stamp the landing spent", () => {
render();
const st = { sfPage: "messages", sfNav: { list: 0, lists: 1 } };
act(() => {
window.history.replaceState(st, "", "/?page=messages");
window.dispatchEvent(new PopStateEvent("popstate", { state: st }));
});
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.history.state).not.toHaveProperty("sfNav");
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
it("closes the channel page in place", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.closeChannel());
expect(back).not.toHaveBeenCalled();
expect(result.current.channelView).toBeNull();
expect(window.location.search).not.toContain("chan=");
});
it("leaves the YouTube search in place", () => {
const { result } = render();
act(() => result.current.enterYtSearch("cats"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).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" });
});
});