diff --git a/frontend/src/components/Feed.countStale.dom.test.tsx b/frontend/src/components/Feed.countStale.dom.test.tsx index c2ac8e76..c97ac578 100644 --- a/frontend/src/components/Feed.countStale.dom.test.tsx +++ b/frontend/src/components/Feed.countStale.dom.test.tsx @@ -95,4 +95,21 @@ describe("Feed — the count while a new search's count loads", () => { await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991")); expect(pill().getAttribute("aria-busy")).toBeNull(); }); + + it("says the count failed instead of showing the empty box of a first load", async () => { + vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never); + vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) => + f.q === "kitchen" + ? Promise.resolve({ count: 1937, facets: {} }) + : Promise.reject(new Error("boom"))) as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); + const pill = () => screen.getByTestId("item-count"); + + const view = render(feed("kitchen", qc)); + await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937")); + view.rerender(feed("a", qc)); + await waitFor(() => expect(pill().getAttribute("title")).toBe("common.countFailed")); + expect(pill().getAttribute("aria-busy")).toBeNull(); + }); }); diff --git a/frontend/src/components/Feed.nextPageFailed.dom.test.tsx b/frontend/src/components/Feed.nextPageFailed.dom.test.tsx new file mode 100644 index 00000000..ee75c06d --- /dev/null +++ b/frontend/src/components/Feed.nextPageFailed.dom.test.tsx @@ -0,0 +1,114 @@ +// @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()), + 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; + }) => ( +
+ {String(hasNextPage)} + +
+ ), +})); +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( + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + /> + , + ); + 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()); + }); +}); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 3f87416a..bd10d7e1 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -7,7 +7,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { NEXT_PAGE } from "../lib/nextPage"; +import { nextPageOnClick } from "../lib/nextPage"; import { ArrowLeft, Columns2, @@ -557,7 +557,9 @@ export default function Feed({
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 6b80d6df..1259f6b8 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -9,7 +9,7 @@ import { } from "@tanstack/react-query"; import { User, X } from "lucide-react"; import { qk } from "../lib/queryKeys"; -import { mayReadAhead, NEXT_PAGE } from "../lib/nextPage"; +import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../lib/nextPage"; import { healPlexField } from "../lib/plexSearch"; import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import type { SortState } from "../lib/columnSort"; @@ -317,7 +317,10 @@ export default function PlexBrowse() { // mount/unmount, so the observer always watches the live node; a plain ref would keep observing the // detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages). const [sentinelEl, setSentinelEl] = useState(null); - const { hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } = browseQ; + const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ; + // A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt + // on every settle and would re-request it once a round-trip while the sentinel is in view. + const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError; useEffect(() => { if (!sentinelEl) return; const io = new IntersectionObserver( @@ -753,6 +756,15 @@ export default function PlexBrowse() { {isFetchingNextPage && (

{t("plex.loading")}

)} + {isFetchNextPageError && !isFetchingNextPage && ( + void fetchNextPage(nextPageOnClick(isFetchingNextPage))} + > + {t("common.loadMoreFailed")} + + )} ); } diff --git a/frontend/src/components/mediaPicture.contract.test.ts b/frontend/src/components/mediaPicture.contract.test.ts index dc8755f1..385c2bcb 100644 --- a/frontend/src/components/mediaPicture.contract.test.ts +++ b/frontend/src/components/mediaPicture.contract.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import videoCard from "./VideoCard.tsx?raw"; import { + MEDIA_BAR, MEDIA_HOVER_LAYER, MEDIA_PICTURE_CONTAINER, MEDIA_RESUME_LAYER, @@ -24,6 +25,16 @@ describe("a media picture", () => { ["the Play", MEDIA_HOVER_LAYER], ["the Continue + Restart pair", MEDIA_RESUME_LAYER], ])("leaves the hover row its band when narrow: %s", (_, layer) => { - expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-10"); + expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-(--bar-room)"); + }); + + // E31 review 2: the lift applied to every narrow picture, and the feed's row and tile views (a + // 10rem thumbnail, no hover row) showed their Play 1.25rem above centre over an empty band. The band + // is now the row's own: set only by a box that holds one. + it("lifts it only where a hover row is there to clear", () => { + expect(MEDIA_BAR.split(" ")).toContain("media-bar"); + expect(MEDIA_PICTURE_CONTAINER.split(" ")).toContain("has-[.media-bar]:[--bar-room:2.5rem]"); + for (const layer of [MEDIA_HOVER_LAYER, MEDIA_RESUME_LAYER]) + expect(layer).not.toMatch(/pic:pb-\d/); }); }); diff --git a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx index 42942f37..a5dd2910 100644 --- a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx @@ -124,4 +124,32 @@ describe("DataTable — a long page mounts in steps", () => { draw({ key: "r90", seq: 1 }); expect(host.querySelector("tbody")?.textContent).toContain("r 90"); }); + + // E31 review 2: the reveal floor held for the page's lifetime. Channels never clears its `follow`, so + // after one priority pick every later sort or search mounted every row up to that channel at once. + it("lets a revealed row go once the reader moves to another view", () => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + const rows = many(200); + const draw = (reveal: { key: string; seq: number } | null, narrowedBy: string) => + flushSync(() => + root!.render( + x.id} + pageSize={0} + reveal={reveal} + narrowedBy={narrowedBy} + />, + ), + ); + draw(null, ""); + const follow = { key: "r150", seq: 1 }; + draw(follow, ""); + expect(host.querySelector("tbody")?.textContent).toContain("r 150"); + draw(follow, "s"); // a search: a new view, the same (never cleared) reveal + expect(bodyRows()).toBe(FIRST_ROWS); + }); }); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index e60a75b2..7a2eab8c 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -267,19 +267,28 @@ export default function DataTable({ // Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame // that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a // stale reveal must not move a page nobody asked to move. + const viewOf = (p: number) => + `${p}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`; const [revealSeen, setRevealSeen] = useState(reveal?.seq); + // The view the reveal was made for: the floor below holds only there. A caller keeps its reveal + // after the focus (Channels never clears `follow`), and a floor that outlived its view mounted every + // row up to that channel on each later sort or search (E31 review 2). + const [revealView, setRevealView] = useState(null); if (reveal && reveal.seq !== revealSeen) { setRevealSeen(reveal.seq); const at = sorted.findIndex((row) => rowKey(row) === reveal.key); - if (at >= 0 && !allRows) setPage(Math.floor(at / size)); + const target = at >= 0 && !allRows ? Math.floor(at / size) : safePage; + if (target !== safePage) setPage(target); + setRevealView(viewOf(target)); } + const view = viewOf(safePage); const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size); // A long page ("All", 100) mounts its first rows at once and the rest in the background — but // always through the revealed row, which the caller focuses as soon as this frame commits. const shown = useProgressiveRows( paged, - `${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`, - reveal ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1, + view, + reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1, ); function toggleSort(key: string) { diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx index 1de3c654..24675aad 100644 --- a/frontend/src/components/ui/ItemCount.dom.test.tsx +++ b/frontend/src/components/ui/ItemCount.dom.test.tsx @@ -70,4 +70,17 @@ describe("ItemCount", () => { expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true"); expect(screen.getByText("5 items").className).toContain("opacity-60"); }); + + // E31 review 2: the dim and aria-busy were the only stale cue, and a screen reader voices neither on + // plain text; a failed count looked like a first load. + it("tells a screen reader a stale count is updating", () => { + render(); + expect(text()).toBe("5 itemsupdating"); + }); + + it("says a count that failed, rather than looking like a first load", () => { + render(); + expect(text()).toBe("—Couldn't count the items"); + expect(screen.getByTestId("item-count").getAttribute("title")).toBe("Couldn't count the items"); + }); }); diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx index a7f0ee65..3a01acb6 100644 --- a/frontend/src/components/ui/ItemCount.tsx +++ b/frontend/src/components/ui/ItemCount.tsx @@ -14,15 +14,26 @@ const OVERFLOW_AT = 1_000_000; // ("999,999 items") and the content is centred within it. A count of a million or more collapses to // "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural) // so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's, -// kept while the new one loads (keepPreviousData) — dimmed and aria-busy, as AdminLogs dims its lines, -// so a broad search's slow count ("a": ~3 s) never reads as the new result. -export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) { +// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which +// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as +// the new result. `failed`: the count could not be read — a dash that says so, not the icon-only box +// of a first load (E31 review 2). +export default function ItemCount({ + count, + stale = false, + failed = false, +}: { + count?: number; + stale?: boolean; + failed?: boolean; +}) { const { t, i18n } = useTranslation(); const lang = i18n.language; - // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just - // show the icon until the number arrives. - const label = - count == null + // `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts), + // just show the icon until the number arrives. + const label = failed + ? "—" + : count == null ? "" : t("common.itemCount", { count, @@ -31,7 +42,9 @@ export default function ItemCount({ count, stale = false }: { count?: number; st return ( @@ -39,6 +52,8 @@ export default function ItemCount({ count, stale = false }: { count?: number; st {label} + {failed && {t("common.countFailed")}} + {stale && !failed && {t("common.countUpdating")}} ); } diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index 888ef7ce..948951c9 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -8,9 +8,11 @@ import CountBadge from "./CountBadge"; // the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up. // Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px: // the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there -// it is a horizontal row that scrolls. Callers persist the active tab with lib/storage's -// useTabPersistedState (per browser tab, E31 S4b): a reload keeps the user where they were, and a -// fresh tab opens on the first sub-tab rather than wherever the last visit ended. +// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with +// lib/storage's useTabPersistedState (per browser tab, E31 S4b: Admin Users, Config, Settings, Stats): +// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every +// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page +// and the player settings start on their first tab each time. // // An ARIA tab list (E28 review F3), not a row of toggle buttons: `tablist` / `tab` / `aria-selected`, // ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the diff --git a/frontend/src/components/ui/mediaCardStyles.ts b/frontend/src/components/ui/mediaCardStyles.ts index c641023e..e976424f 100644 --- a/frontend/src/components/ui/mediaCardStyles.ts +++ b/frontend/src/components/ui/mediaCardStyles.ts @@ -19,23 +19,28 @@ export const MEDIA_BADGE = "pointer-events-none absolute rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums"; /** The picture box as a size container (`pic`), so what sits on the picture can answer its width. - * Every picture box carries it: `mediaPictureBox` and the video card's own box. */ -export const MEDIA_PICTURE_CONTAINER = "@container/pic"; + * Every picture box carries it: `mediaPictureBox` and the video card's own box. A box that holds a + * hover row (MEDIA_BAR) also sets `--bar-room`, the band the row needs: the narrow-picture rule + * below lifts the Play by it, and a box with no row (the feed's row and tile views, a Plex card + * without actions) lifts it by nothing (E31 review 2: it sat off-centre there). */ +export const MEDIA_PICTURE_CONTAINER = "@container/pic has-[.media-bar]:[--bar-room:2.5rem]"; /** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row). * On a narrow picture the Play centres in the band ABOVE the hover row instead of on the whole * picture: the picture's height follows the card's width, but the Play and the row grow with the * text size, so at 1.3 a 242px thumbnail put the 63px Play 13px into the 37px row (E31 S5, - * measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. */ + * measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. + * Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */ export const MEDIA_HOVER_LAYER = - "reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-10"; + "reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-(--bar-room)"; /** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the * picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */ -export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-10`; +export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-(--bar-room)`; -/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. */ -export const MEDIA_BAR = `${HOVER_REVEAL} absolute bottom-2.5 right-1.5 z-base`; +/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. + * `media-bar` marks it for the picture box's `--bar-room` (MEDIA_PICTURE_CONTAINER). */ +export const MEDIA_BAR = `${HOVER_REVEAL} media-bar absolute bottom-2.5 right-1.5 z-base`; /** The title's type, one size per density. On a SHELF (any horizontal strip: Home, the Plex rails, * a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index c1ca9c55..50212267 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -10,6 +10,9 @@ "undo": "Undo", "redo": "Redo", "loading": "Loading…", + "loadMoreFailed": "Couldn't load more", + "countFailed": "Couldn't count the items", + "countUpdating": "updating", "somethingWrong": "Something went wrong", "itemCount_one": "{{formattedCount}} item", "itemCount_other": "{{formattedCount}} items", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 93c2ef68..e50e5c1d 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -10,6 +10,9 @@ "undo": "Visszavonás", "redo": "Újra", "loading": "Betöltés…", + "loadMoreFailed": "Nem sikerült betölteni a továbbiakat", + "countFailed": "Nem sikerült megszámolni az elemeket", + "countUpdating": "frissül", "somethingWrong": "Hiba történt", "itemCount_one": "{{formattedCount}} elem", "itemCount_other": "{{formattedCount}} elem", diff --git a/frontend/src/lib/nextPage.wiring.contract.test.ts b/frontend/src/lib/nextPage.wiring.contract.test.ts index a12aa837..581cfb56 100644 --- a/frontend/src/lib/nextPage.wiring.contract.test.ts +++ b/frontend/src/lib/nextPage.wiring.contract.test.ts @@ -29,4 +29,34 @@ describe("an infinite list's read-ahead", () => { it("the feed hands the grid the query's isFetching", () => { expect(source("Feed.tsx")).toMatch(/isFetching=\{query\.isFetching\}/); }); + + // Retry is off, so a failed page settles at once; a read-ahead that re-runs on the settle would + // request it again once a round-trip while the end is in view (E31 review 2). + it("stands down at a failed next page", () => { + expect(source("Feed.tsx")).toMatch( + /hasNextPage=\{!!hasNextPage && !query\.isFetchNextPageError\}/, + ); + expect(source("PlexBrowse.tsx")).toMatch( + /const hasNextPage = browseQ\.hasNextPage && !isFetchNextPageError;/, + ); + }); +}); + +// A click joins an in-flight next page but cancels a background refetch (nextPageOnClick): through +// NEXT_PAGE it would join the refetch and load nothing. NEXT_PAGE is only for the read-aheads, so a +// click site that goes back to it fails here (E31 review 2: the YouTube "Load more" was missed). +describe("a click that loads the next page", () => { + const READ_AHEADS = new Set(["ui/VirtualGrid.tsx", "PlexBrowse.tsx"]); + it("uses nextPageOnClick; NEXT_PAGE only in the read-aheads", () => { + const users = Object.entries(files) + .filter(([, src]) => /fetchNextPage\(NEXT_PAGE\)/.test(src as string)) + .map(([path]) => path.replace("../components/", "")); + expect(users.sort()).toEqual([...READ_AHEADS].sort()); + for (const [file, sites] of [ + ["AdminLogs.tsx", 2], + ["Feed.tsx", 2], + ["PlexBrowse.tsx", 1], + ] as const) + expect(source(file).match(/fetchNextPage\(nextPageOnClick\(/g)?.length, file).toBe(sites); + }); }); diff --git a/frontend/src/lib/plexImage.contract.test.ts b/frontend/src/lib/plexImage.contract.test.ts index 9315f0d2..ea996bce 100644 --- a/frontend/src/lib/plexImage.contract.test.ts +++ b/frontend/src/lib/plexImage.contract.test.ts @@ -47,8 +47,10 @@ it("names each backdrop cut by the width the proxy cuts it at", () => { // Every small Plex picture asks for its card-size cut through cardImage (E31 S4e, swept in S5: the // playlist rows, the search box and the collection picker still fetched the 400px poster for a 32px -// picture). A bare `src={…thumb}` is allowed only where the full cut is the point or the URL is not -// the proxy's; the SET is pinned, so a new bare one fails here. +// picture). A plain `src={…}` is allowed only where the full cut is the point or the URL is not the +// proxy's; the SET is pinned, so a new one fails here. Every 's src counts, wherever it sits +// among the attributes and whatever its variable is called: the first cut of this test read only a +// leading `src={…thumb…}` and missed two of the three sites it was written for (E31 review 2). const plexFiles = import.meta.glob( [ "../components/{Plex,plex}*.tsx", @@ -58,7 +60,10 @@ const plexFiles = import.meta.glob( { query: "?raw", import: "default", eager: true }, ); const ALLOWED = new Set([ + "plexCards.tsx pic.src", // cardImage's own src (srcSet/sizes beside it): the card cut "plexCards.tsx c.thumb", // cast photos: the person-image route, not the image proxy + "PlexBrowse.tsx p.photo", // the person filter chip's headshot: the person-image route + "plexViews.tsx d.photo", // the person page's headshot: the same "PlexInfo.tsx c.thumb", // the same cast photos on the detail page "PlexInfo.tsx detail.thumb", // the detail page's own poster: the full cut is the point "plexDetailUi.tsx thumb", // the show/season header poster: the same @@ -69,7 +74,7 @@ it("leaves no small Plex picture on the full cut", () => { expect(Object.keys(plexFiles).length).toBeGreaterThan(5); const bare = new Set(); for (const [path, src] of Object.entries(plexFiles)) - for (const m of (src as string).matchAll(/]*?\bsrc=\{([^}]*)\}/g)) bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`); expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]); });