- A press that begins while a field in the row is edited only ends the editing (rowOpen latches it at pointerdown); the card layout's row press is now tested. - A related video reset in the player forgets the session's stored position. - Removing the playing video passes over known-dead items and steps the way it went. - Prev/Next off only because of dead items stay focusable and say why (HU/EN). - The docked bar's scroller leaves room for focus rings. - A focus-opened About card is not closed by its own scroll into view. - EN "Clear watch progress" for the video reset, distinct from a page's "Reset view". - The signed-out title read goes to the page yt-dlp read; the retitle writes are conditional. - Tests: the cap of three errors, the bar's own Fullscreen, the group glyph, the backfill's reads.
130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The feed's toolbar, on the feed and on a channel page. The filter pills name their group by a glyph
|
|
// (the word moved to the tooltip and the accessible name), Sort + View end the first row instead of
|
|
// taking a row of their own, and a channel page drops the count pill: its header already says how
|
|
// many videos the channel has.
|
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
import { render, screen, cleanup } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
}));
|
|
vi.mock("../../features/wizard/wizardContext", () => ({
|
|
useWizard: () => ({ openWizard: vi.fn() }),
|
|
}));
|
|
vi.mock("./VirtualFeed", () => ({ default: () => null }));
|
|
vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null }));
|
|
vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null }));
|
|
|
|
import Feed from "./Feed";
|
|
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
|
import { qk } from "../../lib/queryKeys";
|
|
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const FILTERS: FeedFilters = {
|
|
tags: [],
|
|
tagModes: DEFAULT_TAG_MODES,
|
|
channelIds: [],
|
|
q: "",
|
|
sort: "newest",
|
|
scope: "my",
|
|
includeNormal: true,
|
|
includeShorts: false,
|
|
includeLive: false,
|
|
show: "unwatched",
|
|
};
|
|
|
|
const slot = document.body.appendChild(document.createElement("div"));
|
|
|
|
async function renderFeed(channelScoped = false) {
|
|
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
|
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 12, facets: {} } as never);
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<PageToolbarSlot.Provider value={slot}>
|
|
<Feed
|
|
filters={FILTERS}
|
|
setFilters={() => {}}
|
|
view="cards"
|
|
setView={() => {}}
|
|
canRead
|
|
ytSearch=""
|
|
onYtSearch={() => {}}
|
|
onExitYtSearch={() => {}}
|
|
channelScoped={channelScoped}
|
|
/>
|
|
</PageToolbarSlot.Provider>
|
|
</QueryClientProvider>,
|
|
);
|
|
// The toolbar renders once the first page has answered.
|
|
await screen.findByTestId("feed-show");
|
|
}
|
|
|
|
const trigger = (name: string) =>
|
|
screen.getAllByRole("button").find((b) => b.getAttribute("aria-label")?.startsWith(`${name}:`))!;
|
|
const sortTrigger = () => trigger("feed.sortLabel");
|
|
// The group's glyph, not the trigger's own chevron: the glyph sits in its aria-hidden wrapper.
|
|
const glyph = (el: HTMLElement) => el.querySelector('[aria-hidden="true"] > svg');
|
|
|
|
describe("Feed toolbar", () => {
|
|
it.each([
|
|
["feed-show", "feed.showLabel"],
|
|
["feed-content", "feed.contentLabel"],
|
|
])("the %s pill shows a glyph, not its group's word, and names itself", async (id, word) => {
|
|
await renderFeed();
|
|
const pill = screen.getByTestId(id);
|
|
expect(pill.textContent).not.toContain(word);
|
|
expect(glyph(pill)).not.toBeNull();
|
|
expect(pill.getAttribute("title")).toBe(word);
|
|
expect(pill.getAttribute("aria-label")).toMatch(new RegExp(`^${word}: `));
|
|
});
|
|
|
|
it("the sort pill shows a glyph, not the word Sort, and names itself", async () => {
|
|
await renderFeed();
|
|
const sort = sortTrigger();
|
|
expect(sort.textContent).not.toContain("feed.sortLabel");
|
|
expect(glyph(sort)).not.toBeNull();
|
|
expect(sort.getAttribute("title")).toBe("feed.sortLabel");
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"Sort and View end the filters' row instead of a row of their own (channel page: %s)",
|
|
async (channelScoped) => {
|
|
await renderFeed(channelScoped);
|
|
const row = screen.getByTestId("feed-show").parentElement!;
|
|
expect(row.contains(sortTrigger())).toBe(true);
|
|
expect(row.contains(trigger("feed.viewLabel"))).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("a channel page shows no count pill; the feed keeps it", async () => {
|
|
await renderFeed(true);
|
|
expect(screen.queryByTestId("item-count")).toBeNull();
|
|
cleanup();
|
|
await renderFeed(false);
|
|
expect(screen.getByTestId("item-count")).toBeTruthy();
|
|
});
|
|
});
|