Files
siftlode/frontend/src/components/Feed.ytsort.dom.test.tsx
T
peter efd95fca01 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.
2026-09-11 01:38:31 +02:00

119 lines
4.1 KiB
TypeScript

// @vitest-environment jsdom
//
// The live-YouTube results' Sort control, wired end to end: the pref it writes, the key and
// direction Feed hands to sortYtResults, and the order that reaches the grid. sortYtResults itself
// is pinned in feedSort.yt.test.ts; a deep-review of the 2026-09 bug hunt found that the CALL SITE
// could ignore the chosen direction (or skip the sort entirely) with every lane green.
//
// The virtualised grid lays out nothing in jsdom, so it is replaced by a plain list that shows the
// order Feed gave it — which is exactly the thing under test.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, 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("./WizardProvider", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({ items }: { items: { id: string; title: string }[] }) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>{v.title}</li>
))}
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } 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",
};
const hit = (id: string, duration_seconds: number): Video =>
({
id,
title: `hit ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
duration_seconds,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const order = () =>
Array.from(screen.getByRole("list", { name: "results" }).querySelectorAll("li")).map(
(li) => li.textContent,
);
describe("Feed — sorting the live YouTube results", () => {
it("hands the grid the results in the key AND direction the viewer picked", async () => {
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a", 300), hit("b", 100), hit("c", 200)],
next_cursor: null,
source: "scrape",
} 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="lemmino"
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
// Relevance is the provider's own ranking — the order the results arrived in.
await waitFor(() => expect(order()).toEqual(["hit a", "hit b", "hit c"]));
fireEvent.click(screen.getByRole("button", { name: /^feed\.sortLabel:/ }));
const duration = (await screen.findAllByRole("menuitemradio", { hidden: true })).find((o) =>
o.textContent?.includes("feed.sortKey.duration"),
);
fireEvent.click(duration!);
fireEvent.click(await screen.findByText("feed.dirAsc"));
await waitFor(() => expect(order()).toEqual(["hit b", "hit c", "hit a"]));
fireEvent.click(screen.getByText("feed.dirDesc"));
await waitFor(() => expect(order()).toEqual(["hit a", "hit c", "hit b"]));
});
});