- Playlists: a link over a playlist the account does not hold plays alone; the rail's own fallback no longer counts as a pick that gives the player up. - The linked opener opens or clears nothing once the address names another player, and stops looking (and fetching its row) once it settled. - Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a notice of its own (EN/HU). - History: an in-place view rewrite (search refine, count, channel to channel, sub-view) drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a search keeps the search; the Feed press on a search opens the plain feed. - Files/Playlists: an emptied search is written at once. - Client log: plf/cf (column text filters) are typed keys; the shell has none of its own. - Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark net reads page tags. - Feed defaults in one constant; one live-search queue helper; Scheduler writes through the Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
182 lines
8.1 KiB
TypeScript
182 lines
8.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
|
|
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
|
|
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
|
}));
|
|
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
|
|
// The player itself is not what is under test: it shows what it was opened on.
|
|
vi.mock("./PlayerModal", () => ({
|
|
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
|
|
<p>
|
|
playing {video.id} {queue ? "in a list" : "alone"}
|
|
</p>
|
|
),
|
|
}));
|
|
|
|
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
|
import { LinkedPlayer } from "./LinkedPlayer";
|
|
import { api, type Video } from "../../lib/api";
|
|
import { notifyActionError } from "../../lib/actionErrors";
|
|
import { qk } from "../../lib/queryKeys";
|
|
|
|
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
|
|
|
function wrapper({ children }: { children: ReactNode }) {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.mocked(notifyActionError).mockClear();
|
|
window.history.replaceState(null, "", "/");
|
|
});
|
|
|
|
describe("useOpenLinkedPlayer", () => {
|
|
it("opens the item from the host's list once the list is in, and only once", async () => {
|
|
window.history.replaceState(null, "", "/?watch=B");
|
|
const row = vi.spyOn(api, "videoRow");
|
|
const open = vi.fn();
|
|
const list = [vid("A"), vid("B")];
|
|
const { rerender } = renderHook(
|
|
({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open),
|
|
{ wrapper, initialProps: { ready: false, rows: list } },
|
|
);
|
|
expect(open).not.toHaveBeenCalled();
|
|
rerender({ ready: true, rows: list });
|
|
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
|
|
// The list refetched (new rows for the same videos): the player it opened is not opened again.
|
|
rerender({ ready: true, rows: [vid("A"), vid("B")] });
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
expect(row).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens an item the list does not hold alone, its row from the catalog", async () => {
|
|
window.history.replaceState(null, "", "/?watch=Z");
|
|
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
|
const open = vi.fn();
|
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
|
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
|
|
});
|
|
|
|
it("says so when the catalog no longer has it", async () => {
|
|
window.history.replaceState(null, "", "/?watch=Z");
|
|
const gone = new Error("not found");
|
|
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
|
|
const open = vi.fn();
|
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
|
await waitFor(() =>
|
|
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
|
);
|
|
expect(open).not.toHaveBeenCalled();
|
|
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
|
|
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
|
|
});
|
|
|
|
it("leaves a player opened while the row was on its way alone, and its address", async () => {
|
|
window.history.replaceState(null, "", "/?watch=Z");
|
|
let answer: (v: Video) => void = () => {};
|
|
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)));
|
|
const open = vi.fn();
|
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
|
// A card clicked meanwhile: its player names itself in the address.
|
|
window.history.replaceState(null, "", "/?watch=Y");
|
|
answer(vid("Z"));
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(open).not.toHaveBeenCalled();
|
|
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
|
});
|
|
|
|
it("does not clear the address of a player opened since when its catalog read fails", async () => {
|
|
window.history.replaceState(null, "", "/?watch=Z");
|
|
let refuse: (e: Error) => void = () => {};
|
|
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r)));
|
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper });
|
|
window.history.replaceState(null, "", "/?watch=Y");
|
|
refuse(new Error("not found"));
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
|
});
|
|
|
|
it("given up over a moved list, keeps the address of a player opened since", async () => {
|
|
window.history.replaceState(null, "", "/?watch=Z");
|
|
const { rerender } = renderHook(
|
|
({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope),
|
|
{ wrapper, initialProps: { scope: 1 } },
|
|
);
|
|
window.history.replaceState(null, "", "/?watch=Y");
|
|
rerender({ scope: 2 });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
|
});
|
|
|
|
it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => {
|
|
window.history.replaceState(null, "", "/?watch=B");
|
|
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B"));
|
|
const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined));
|
|
const { rerender } = renderHook(
|
|
({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()),
|
|
{ wrapper, initialProps: { rows: true } },
|
|
);
|
|
await waitFor(() => expect(find).toHaveBeenCalled());
|
|
find.mockClear();
|
|
rerender({ rows: false });
|
|
rerender({ rows: true });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(find).not.toHaveBeenCalled();
|
|
expect(row).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
|
|
window.history.replaceState(null, "", "/?q=talks");
|
|
const row = vi.spyOn(api, "videoRow");
|
|
const open = vi.fn();
|
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(open).not.toHaveBeenCalled();
|
|
expect(row).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("the shell's LinkedPlayer", () => {
|
|
it("plays the video alone over a page that hosts no player", async () => {
|
|
window.history.replaceState(null, "", "/?page=channels&watch=Z");
|
|
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
|
render(<LinkedPlayer hosted={false} />, { wrapper });
|
|
expect(await screen.findByText("playing Z alone")).toBeTruthy();
|
|
});
|
|
|
|
it("leaves it to a page that hosts its own, even once that page has fetched the row", async () => {
|
|
// The page plays it alone, from the catalog row it asked for: the same cache entry this one reads.
|
|
window.history.replaceState(null, "", "/?page=home&watch=Z");
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
qc.setQueryData(qk.videoRow("Z"), vid("Z"));
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<LinkedPlayer hosted />
|
|
</QueryClientProvider>,
|
|
);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(screen.queryByText(/playing/)).toBeNull();
|
|
});
|
|
|
|
it("leaves it to a page that hosts its own", async () => {
|
|
window.history.replaceState(null, "", "/?watch=Z");
|
|
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
|
render(<LinkedPlayer hosted />, { wrapper });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(row).not.toHaveBeenCalled();
|
|
expect(screen.queryByText(/playing/)).toBeNull();
|
|
});
|
|
});
|