- 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.
321 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|