Files
siftlode/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx
T
peter 02906d0acb fix(nav): the address keeps what the app rewrites around it (review round 3)
- The Feed press over a live search ends only the search: the feed's own view stays named.
- Leaving a search steps back only off an entry the app pushed (_ytOwn); one a link or a reload
  opened is cleared in place, also where the Navigation API is missing.
- useUrlMirror rewrites a changed view through replaceView, so a reload's overlay marks go; its
  first write and its landing writes keep them for an overlay that reopens.
- Files and Playlists: a box emptied and filled again settles at once, never naming the ended search.
- Playlists: a linked player is scoped to the playlist the store started with, and only the rail's
  own move to the first playlist is no pick.
- Contract: writer objects are scanned to their matching brace, a computed or shorthand key is
  refused, and every useHistorySubview call hands a named definition.
- Lint: playwright/no-skipped-test also refuses fixme (gate snapshot regenerated).
- FEED_DEFAULT_SORT at the three remaining sites; linkedNotListed says to search by name.
2026-10-27 23:33:22 +01:00

321 lines
13 KiB
TypeScript

// @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("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();
});
});