From efd95fca01ef00934bae11006151345aa2c421fc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 11 Sep 2026 01:38:31 +0200 Subject: [PATCH] fix(ui): the card's channel name is a real link too; call sites the bug hunt left untested now have tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review R6 — the small items: - The video card's channel name was still a + /> ); const actions = ( {title} - {fits(ROW_FITS.channel) &&
{channelButton}
} + {fits(ROW_FITS.channel) &&
{channelLink}
} {fits(ROW_FITS.meta) && } {/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px holds the widest bare form ("101.7K" at 48) in either language — dropping the word @@ -578,7 +582,7 @@ function VideoCard({ const textBlock = ( <> {title} -
{channelButton}
+
{channelLink}
{views} {views && " · "} diff --git a/frontend/src/components/usePlexActiveFilters.dom.test.tsx b/frontend/src/components/usePlexActiveFilters.dom.test.tsx index 85da3b95..8bfd1b09 100644 --- a/frontend/src/components/usePlexActiveFilters.dom.test.tsx +++ b/frontend/src/components/usePlexActiveFilters.dom.test.tsx @@ -4,11 +4,11 @@ // for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip — // sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and // the provider is mocked so the hook can be driven with a non-default sort present. -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement, type ReactNode } from "react"; -import { EMPTY_PLEX_FILTERS } from "../lib/api"; +import { api, EMPTY_PLEX_FILTERS } from "../lib/api"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -40,8 +40,15 @@ import { usePlexActiveFilters } from "./usePlexActiveFilters"; import { qk } from "../lib/queryKeys"; // The hook resolves collection titles it does not already have through react-query, so it needs a -// client. Every id here already HAS a title, so the query stays disabled and nothing is requested. +// client. "B" has no title in the state, so the hook DOES ask for it — stubbed here, so no test issues +// a real request (an unmocked one used to fire from the first test and retry after it had finished). const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +beforeEach(() => { + vi.spyOn(api, "plexCollections").mockResolvedValue({ collections: [] } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); const wrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: qc }, children); @@ -59,7 +66,7 @@ describe("usePlexActiveFilters", () => { it("labels a link-supplied collection with its resolved title, not the bare id", () => { // Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label) - // rather than the network. Nothing is mocked. + // rather than the network. qc.setQueryData(qk.plexCollections("titles", "B"), { collections: [{ id: "B", title: "Pirates of the Caribbean Collection" }], }); diff --git a/frontend/src/lib/channelDetailPoll.ts b/frontend/src/lib/channelDetailPoll.ts index f3057de1..ef191a17 100644 --- a/frontend/src/lib/channelDetailPoll.ts +++ b/frontend/src/lib/channelDetailPoll.ts @@ -37,10 +37,13 @@ const backfillChangedCatalog = ( * background job has written a row — which is why the list stayed on the ~19 cards the channel had * at subscribe time while the count above it climbed into the thousands. * - * Cost is bounded: the predicate fires only on a tick where the count actually moved, the poll - * itself pauses while the tab is unfocused, and a channel grid is normally a single 60-item page - * whose newest-first head the (older) backfilled videos don't disturb — so the refetch usually - * comes back structurally identical and re-renders nothing. */ + * What it costs, honestly: an invalidation refetches EVERY loaded page of the grid's infinite + * query — one request per page the viewer has scrolled through, on each 4 s tick where the count + * moved, for as long as the backfill runs (the poll pauses while the tab is hidden, and stops when + * the back-catalogue is in). That is the right price, not an accident: backfilled videos are OLDER + * than what the grid opened with, so under the newest-first order they land in the pages BELOW the + * head — refreshing only the first page would leave out exactly the rows the backfill wrote. The + * head itself comes back structurally identical and re-renders nothing. */ export function useBackfillFeedRefresh(detail: ChannelDetail | undefined, qc: QueryClient) { const seen = useRef(undefined); useEffect(() => { diff --git a/frontend/src/lib/feedSort.ts b/frontend/src/lib/feedSort.ts index 65ba12ed..2257c8fc 100644 --- a/frontend/src/lib/feedSort.ts +++ b/frontend/src/lib/feedSort.ts @@ -2,6 +2,7 @@ // …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter // chips can label a sort without re-deriving the mapping. import type { Video } from "./api"; +import type { SortOption } from "./columnSort"; export type SortKey = | "date" @@ -24,7 +25,7 @@ export const SORT_KEYS: SortKey[] = [ ]; /** Sorts with no meaningful asc/desc flip. */ -export const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"]; +const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"]; const SORT_MAP: Record, { asc: string; desc: string }> = { date: { desc: "newest", asc: "oldest" }, @@ -58,6 +59,20 @@ export function buildSort(key: SortKey, dir: "asc" | "desc"): string { return SORT_MAP[key][dir]; } +/** Feed sort keys as options for the shared SortControl. ONE mapping for the library feed and the + * live search results, which share the vocabulary: two copies could hand the same key a different + * direction default, or lose its directionless flag, depending on which mode you were in. */ +export function feedSortOptions(keys: SortKey[], label: (k: SortKey) => string): SortOption[] { + return keys.map((k) => ({ + key: k, + label: label(k), + directionless: DIRECTIONLESS.includes(k), + defaultDir: DIRECTIONLESS.includes(k) + ? undefined + : SORT_DEFAULT_DIR[k as Exclude], + })); +} + // --- Live YouTube search results ---------------------------------------------------------------- // // The results view had NO sort control at all — the order was the provider's and that was that. It is diff --git a/frontend/src/lib/feedSort.yt.test.ts b/frontend/src/lib/feedSort.yt.test.ts index 5f4a921d..ca8c178e 100644 --- a/frontend/src/lib/feedSort.yt.test.ts +++ b/frontend/src/lib/feedSort.yt.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { sortYtResults, YT_SORT_KEYS, SORT_KEYS } from "./feedSort"; +import { + feedSortOptions, + sortYtResults, + SORT_DEFAULT_DIR, + YT_SORT_KEYS, + SORT_KEYS, +} from "./feedSort"; import type { Video } from "./api"; // The live-YouTube results view shipped with no sort control at all: the provider's order was the @@ -111,3 +117,17 @@ describe("sortYtResults", () => { expect(titles(input)).toEqual(titles(RESULTS)); }); }); + +describe("feedSortOptions", () => { + // The library feed and the live results used to build their options from two copies of this + // mapping; one helper means a key cannot get a different default direction in one of the modes. + it("gives directional keys their default direction and marks shuffle/relevance directionless", () => { + const opts = feedSortOptions(["relevance", "date", "duration", "shuffle"], (k) => `label:${k}`); + expect(opts.map((o) => [o.key, o.label, !!o.directionless, o.defaultDir])).toEqual([ + ["relevance", "label:relevance", true, undefined], + ["date", "label:date", false, SORT_DEFAULT_DIR.date], + ["duration", "label:duration", false, SORT_DEFAULT_DIR.duration], + ["shuffle", "label:shuffle", true, undefined], + ]); + }); +});