fix(ui): the card's channel name is a real link too; call sites the bug hunt left untested now have tests
Deep-review R6 — the small items: - The video card's channel name was still a <button> (pre-existing): ctrl/middle-click did nothing there while the title above it and the player's channel name honoured them. It renders through ChannelNameLink now, which grew a forwarded ref (the clamp measurement), pass-through attributes, a `tooltip`, and an opt-in `stopPropagation` so no click reaches the card's row. - ChannelNameLink rendered an empty, still-tabbable <a> for a channel with no title yet (a stub first seen through a live search; pre-existing at this site). It falls back to the translated "Channel". - The feed's SortKey→SortOption mapping existed twice in Feed.tsx; one `feedSortOptions` in feedSort.ts serves both modes (DIRECTIONLESS is module-private again). - The backfill refresh's comment claimed a one-page cost. It refetches every loaded page of the grid, and that is the right price: backfilled videos are older, so they land below the head. Tests for call sites that could be deleted with every lane green: the channel page running the backfill refresh (knip had been blinded by the hook test's import), Feed handing the grid the live results in the chosen key AND direction, the card using the link. Plus the alt-click arm of the shared click predicate, and a plex filter-chip test that claimed it made no request while firing a real one. Verified on localdev: the feed's channel names are anchors (same 14 px muted look), a plain click opens the channel in-app. 7 mutations, each red on its assertion.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's video grid follows a running full-history backfill (bug hunt 2026-09, item 6).
|
||||
// The rule itself — when a poll reading should refresh the grid — is pinned on the hook in
|
||||
// lib/channelDetailPoll.backfill.dom.test.tsx. This pins that the PAGE runs it: deleting the hook's
|
||||
// one call site left every lane green, knip included (the hook test's import kept the export "used").
|
||||
//
|
||||
// The page's own poll is what feeds the hook; a newer reading is handed to it here through the same
|
||||
// query key the poll writes, rather than waiting out the 4 s interval. The Feed itself is stubbed —
|
||||
// what matters is whether its queries are told they are stale.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, waitFor, cleanup, act } 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("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type FeedFilters, type Me } 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 CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
const reading = (stored_videos: number): ChannelDetail =>
|
||||
({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed: true,
|
||||
deep_requested: true,
|
||||
backfill_done: false,
|
||||
stored_videos,
|
||||
}) as unknown as ChannelDetail;
|
||||
const feedKey = qk.feed({ q: "", channel: CH } as unknown as FeedFilters);
|
||||
|
||||
describe("ChannelPage — the grid follows a running backfill", () => {
|
||||
it("marks the channel's video grid stale when the poll sees more stored videos", async () => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue(reading(19));
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(feedKey, { pages: [{ items: [], next_cursor: null }], pageParams: [null] });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(api.channelDetail).toHaveBeenCalled());
|
||||
await waitFor(() => expect(qc.getQueryData(qk.channel(CH))).toBeTruthy());
|
||||
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
|
||||
|
||||
// The next poll tick: the background job has stored 60 more.
|
||||
act(() => qc.setQueryData(qk.channel(CH), reading(79)));
|
||||
|
||||
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user