Files
siftlode/frontend/src/moduleSearch.url.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

127 lines
4.8 KiB
TypeScript

// @vitest-environment jsdom
//
// The URL is the view: a page's name search is named in the address, so a reload or a bookmark opens
// the list searched (Channels, Files and Playlists kept it in memory only, Plex in this browser only).
// The real navigation and the real stores, read through their hooks; only the network is absent.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
import { NavigationProvider } from "./features/navigation/NavigationProvider";
import {
useNavigationActions,
type NavigationActions,
} from "./features/navigation/navigationContext";
import { ModuleStores } from "./features/navigation/ModuleStores";
import { useChannels } from "./modules/channels/channelsContext";
import { useFiles, useFilesActions } from "./modules/files/filesContext";
import { usePlaylists, usePlaylistsActions } from "./modules/playlists/playlistsContext";
import { usePlex } from "./modules/plex/plexContext";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage";
const seen: { current: Record<string, string> } = { current: {} };
const nav: { current: NavigationActions | null } = { current: null };
const typeIn: { current: Record<string, (q: string) => void> } = { current: {} };
function Probe() {
const channels = useChannels();
const files = useFiles();
const playlists = usePlaylists();
const plex = usePlex();
const actions = useNavigationActions();
const filesActions = useFilesActions();
const playlistsActions = usePlaylistsActions();
useEffect(() => {
nav.current = actions;
typeIn.current = { files: filesActions.setSearch, playlists: playlistsActions.setSearch };
seen.current = {
channels: channels.search,
files: files.search,
playlists: playlists.search,
plex: plex.q,
plexField: plex.field,
};
});
return null;
}
beforeEach(() => {
localStorage.clear();
claimLocalData(3);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
function mountAt(url: string, page: string) {
window.history.replaceState({ sfPage: page }, "", url);
render(
<QueryClientProvider client={new QueryClient()}>
<NavigationProvider>
<ModuleStores>
<Probe />
</ModuleStores>
</NavigationProvider>
</QueryClientProvider>,
);
}
const search = () => new URLSearchParams(window.location.search);
describe.each([
["channels", "cq", "tom"],
["files", "fq", "notes"],
["playlists", "plq", "jazz"],
["plex", "pq", "poirot"],
])("%s's name search", (page, key, value) => {
it("opens from the address, and stays in it", async () => {
mountAt(`/?page=${page}&${key}=${value}`, page);
await waitFor(() => expect(seen.current[page]).toBe(value));
expect(search().get(key)).toBe(value);
});
});
describe("Plex's search", () => {
it("is named in full, an empty one too: this browser remembers one a link must not inherit", async () => {
setAccountRaw(LS.plexQ, "poirot");
mountAt("/?page=plex&pq=", "plex");
await waitFor(() => expect(seen.current.plex).toBe(""));
expect(search().get("pq")).toBe("");
});
it("takes the field a link names, and remembers the link's search", async () => {
mountAt("/?page=plex&pq=hastings&pqf=people", "plex");
await waitFor(() => expect(seen.current.plexField).toBe("people"));
expect(seen.current.plex).toBe("hastings");
await waitFor(() => expect(getAccountRaw(LS.plexQ)).toBe("hastings"));
expect(search().get("pqf")).toBe("people");
});
});
// The search is the page's while it is on screen: leaving the page ends it, so a trip back through
// the rail opens the page unsearched (and the address names none).
describe.each([
["files", "fq", "notes"],
["playlists", "plq", "jazz"],
])("%s's name search, once the page is left", (page, key, value) => {
it("is gone when the page is opened again", async () => {
mountAt(`/?page=${page}&${key}=${value}`, page);
await waitFor(() => expect(seen.current[page]).toBe(value));
act(() => nav.current!.setPage("feed"));
act(() => nav.current!.setPage(page as "files" | "playlists"));
await waitFor(() => expect(seen.current[page]).toBe(""));
// At once, not after the typing pause: a reload right after the return opens it unsearched.
expect(search().get(key)).toBeNull();
// Typing again within the pause names the new text or nothing, never the ended search.
act(() => typeIn.current[page]!("x"));
expect(search().get(key)).not.toBe(value);
});
});