feat(nav): the live YouTube search and Home's budget are named in the address
A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page scrape, a search asked for in this document, or results still cached. An API search reopened any other way shows its term and waits for "Search YouTube again". Home's time budget is `hb`; the question's draw stays on the history entry. An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and docs/development.md name the module URL codec as the place for view state.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// @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<typeof import("react-i18next")>();
|
||||
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 }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
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("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 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user