// @vitest-environment jsdom // // The live YouTube search in the address: sent from the search box it gets its own entry naming the // term (and its count), a reload or a link opens it again, Back/Forward read it from the address the // browser restored — and only a search asked for in this document counts as asked (an API search // reopened any other way waits for a press, Feed's part). 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"; 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 { qk } from "../../lib/queryKeys"; import { claimLocalData } from "../../lib/storage"; let qc = new QueryClient(); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const render = () => renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); const search = () => new URLSearchParams(window.location.search); /** No entry of this document under the current one (a bookmark opened in a new tab). */ function stubNoEntryBelow() { Object.defineProperty(window, "navigation", { configurable: true, value: { currentEntry: { index: 1 }, entries: () => [{ sameDocument: false }, { sameDocument: true }], }, }); } beforeEach(() => { qc = new QueryClient(); qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true }); localStorage.clear(); claimLocalData(1); window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); Reflect.deleteProperty(window, "navigation"); }); describe("NavigationProvider · the YouTube search in the address", () => { it("names a search sent from the box on an entry of its own, over the feed's", () => { const { result } = render(); const depth = window.history.length; act(() => result.current.enterYtSearch("lofi")); expect(window.history.length).toBe(depth + 1); expect(search().get("yt")).toBe("lofi"); expect(search().get("q")).toBe("lofi"); // the feed's own keys stay expect(search().get("ytn")).toBeNull(); // the default count is left out expect(result.current.ytSearch).toBe("lofi"); expect(result.current.ytSearchAsked).toBe(true); }); it("refines the search on screen in place, at the count it shows", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.setYtCount(60)); expect(search().get("ytn")).toBe("60"); const depth = window.history.length; act(() => result.current.enterYtSearch("lofi beats")); expect(window.history.length).toBe(depth); expect(search().get("yt")).toBe("lofi beats"); expect(search().get("ytn")).toBe("60"); expect(result.current.ytCount).toBe(60); }); it("a new search starts at the default count", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.setYtCount(40)); stubNoEntryBelow(); // left in place, so the next search is a new entry vi.spyOn(window.history, "back").mockImplementation(() => {}); act(() => result.current.exitYtSearch()); act(() => result.current.enterYtSearch("jazz")); expect(result.current.ytCount).toBe(20); expect(search().get("ytn")).toBeNull(); }); it("writes the count, and clears it back at the default", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.setYtCount(100)); expect(search().get("ytn")).toBe("100"); act(() => result.current.setYtCount(20)); expect(search().get("ytn")).toBeNull(); }); it("reopens the search a reload or a link names, not as asked", () => { window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); const { result } = render(); expect(result.current.ytSearch).toBe("lofi"); expect(result.current.ytCount).toBe(40); expect(result.current.ytSearchAsked).toBe(false); expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it act(() => result.current.askYtSearch()); expect(result.current.ytSearchAsked).toBe(true); }); it("refines a search a link opened in place too, at the count it shows", () => { window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); const { result } = render(); const depth = window.history.length; act(() => result.current.enterYtSearch("jazz")); expect(window.history.length).toBe(depth); expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]); }); it("Back onto an entry whose address alone names the search reopens it", () => { const { result } = render(); window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearch).toBe("lofi"); }); it("keeps the search mark of an entry the reload started from", () => { window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); render(); expect(window.history.state?._yt).toBe("lofi"); }); it("marks the entry of a search the address opened (a reload, a link)", () => { window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); render(); expect(window.history.state?._yt).toBe("lofi"); }); it("drops an older build's search mark the address does not name", () => { window.history.replaceState( { sfPage: "feed", _yt: "lofi", _ytScrape: true }, "", "/?page=feed", ); const { result } = render(); expect(result.current.ytSearch).toBeNull(); expect(window.history.state?._yt).toBeUndefined(); expect(window.history.state?._ytScrape).toBeUndefined(); }); it("names no search under another page", () => { window.history.replaceState(null, "", "/?page=plex&yt=lofi"); const { result } = render(); expect(result.current.page).toBe("plex"); expect(result.current.ytSearch).toBeNull(); expect(search().get("yt")).toBeNull(); }); it("Back/Forward read the search from the address the browser restored, not as asked", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearch).toBe("jazz"); expect(result.current.ytCount).toBe(60); expect(result.current.ytSearchAsked).toBe(false); window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearch).toBeNull(); }); it("leaving a search with no entry of this document below clears it from the address", () => { window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); const { result } = render(); stubNoEntryBelow(); const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); act(() => result.current.exitYtSearch()); expect(back).not.toHaveBeenCalled(); expect(result.current.ytSearch).toBeNull(); expect(search().get("yt")).toBeNull(); expect(search().get("ytn")).toBeNull(); }); it("a search left by Back and reached again by Forward is not asked for", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); expect(result.current.ytSearchAsked).toBe(true); window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearch).toBe("lofi"); expect(result.current.ytSearchAsked).toBe(false); }); it("a search left in place is not asked for when it is opened again by a link", () => { window.history.replaceState(null, "", "/?page=feed&yt=lofi"); const { result } = render(); act(() => result.current.askYtSearch()); stubNoEntryBelow(); act(() => result.current.exitYtSearch()); window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearchAsked).toBe(false); }); it("the Feed press over a channel opened from the search opens the plain feed", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.openChannel("UC1")); act(() => result.current.setPage("feed")); expect(result.current.channelView).toBeNull(); expect(result.current.ytSearch).toBeNull(); expect(search().get("yt")).toBeNull(); }); it("the Feed press on the search itself opens the plain feed, as over a channel", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); const depth = window.history.length; act(() => result.current.setPage("feed")); expect(result.current.ytSearch).toBeNull(); expect(search().get("yt")).toBeNull(); expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search }); it("the Feed press on the search keeps the feed's own view in the address", () => { window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest"); const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.setPage("feed")); expect(search().get("yt")).toBeNull(); expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]); }); it("a search sent from the box is left by a step back off its own entry", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); act(() => result.current.exitYtSearch()); expect(back).toHaveBeenCalledTimes(1); }); it("a search a link opened is left in place, not by a step back off the document", () => { window.history.replaceState(null, "", "/?page=feed&yt=lofi"); const { result } = render(); const back = vi.spyOn(window.history, "back"); act(() => result.current.exitYtSearch()); expect(back).not.toHaveBeenCalled(); expect(result.current.ytSearch).toBeNull(); expect(search().get("yt")).toBeNull(); }); it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.openChannel("UC1")); // A player over the channel closed: the step back lands on the channel's own entry. act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearch).toBe("lofi"); expect(result.current.ytSearchAsked).toBe(true); // Back off the channel: the search it stood over is still the one asked for. window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearchAsked).toBe(true); }); it("a landing on a channel opened from a search the address opened keeps the search", () => { window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); const { result } = render(); act(() => result.current.openChannel("UC1")); act(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); expect(result.current.ytSearch).toBe("lofi"); }); it("a channel closed in place over the search names the search again", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.setYtCount(40)); act(() => result.current.openChannel("UC1")); expect(search().get("yt")).toBeNull(); stubNoEntryBelow(); act(() => result.current.closeChannel()); expect(result.current.ytSearch).toBe("lofi"); expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]); }); it("a page switch takes the search off the address", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); act(() => result.current.setPage("home")); expect(result.current.ytSearch).toBeNull(); expect(search().get("yt")).toBeNull(); }); });