Files
siftlode/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx
T
peter c910cf2f20 fix: review round 1 — row press after a rename, related reset, dead items on removal, honest Prev/Next
- 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.
2026-11-01 05:36:59 +01:00

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