Files
siftlode/frontend/src/components/Feed.nextPageFailed.dom.test.tsx
T
peter b40440b825 fix(ui): review round 2 — reveal floor, failed next page, count states, row Play
- DataTable: the reveal floor holds only in the view it was made for (Channels
  never clears its follow, so every later sort mounted rows up to it)
- Feed + Plex browse: a failed next page stops the read-ahead and shows a row
  that retries, instead of re-requesting it once a round-trip
- the YouTube Load more click uses nextPageOnClick; a contract pins NEXT_PAGE
  to the read-aheads and the click sites to nextPageOnClick
- ItemCount: a failed count says so; a stale one says updating to a screen reader
- the narrow-picture Play lift applies only where a hover row is there to clear
- PlayerModal: one description ladder for the popover and the panel, both tested
- plexImage contract reads every img src, any attribute order
- Tabs comment names who persists the sub-tab
2026-10-03 02:25:15 +02:00

115 lines
4.0 KiB
TypeScript

// @vitest-environment jsdom
//
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
// page was requested again once a round-trip, forever, while the end of the list was in view, and
// nothing said it had failed (E31 review 2). The read-ahead now stops at a failed page, and a row says
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
// button standing in for the read-ahead itself.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } 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("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({
hasNextPage,
fetchNextPage,
}: {
hasNextPage: boolean;
fetchNextPage: (o: { cancelRefetch: boolean }) => unknown;
}) => (
<div>
<span data-testid="has-next">{String(hasNextPage)}</span>
<button onClick={() => void fetchNextPage({ cancelRefetch: false })}>read ahead</button>
</div>
),
}));
vi.mock("./SelectedViewChip", () => ({ default: () => null }));
vi.mock("./ActiveFilterChips", () => ({ default: () => null }));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
import { qk } from "../lib/queryKeys";
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",
};
describe("Feed — a next page that fails", () => {
it("stops the read-ahead, says so, and loads the page on a retry", async () => {
const page = (n: number, next: string | null) => ({
items: [
{ id: `v${n}`, title: `v${n}`, channel_id: "UCx", published_at: "2026-01-01T00:00:00Z" },
],
next_cursor: next,
});
let failNext = true;
const feed = vi
.spyOn(api, "feed")
.mockImplementation(((_f: FeedFilters, cursor: string | null) =>
cursor === null
? Promise.resolve(page(1, "c2"))
: failNext
? Promise.reject(new Error("boom"))
: Promise.resolve(page(2, null))) as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 2, 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}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId("has-next").textContent).toBe("true"));
fireEvent.click(screen.getByText("read ahead"));
await waitFor(() => expect(screen.getByText("common.loadMoreFailed")).toBeTruthy());
expect(screen.getByTestId("has-next").textContent).toBe("false"); // the read-ahead stands down
const calls = feed.mock.calls.length;
failNext = false;
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => expect(feed.mock.calls.length).toBe(calls + 1));
await waitFor(() => expect(screen.queryByText("common.loadMoreFailed")).toBeNull());
});
});