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
This commit is contained in:
@@ -95,4 +95,21 @@ describe("Feed — the count while a new search's count loads", () => {
|
|||||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
||||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { NEXT_PAGE } from "../lib/nextPage";
|
import { nextPageOnClick } from "../lib/nextPage";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Columns2,
|
Columns2,
|
||||||
@@ -557,7 +557,9 @@ export default function Feed({
|
|||||||
<div className="text-center pt-4">
|
<div className="text-center pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void ytQuery.fetchNextPage(NEXT_PAGE)}
|
onClick={() =>
|
||||||
|
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
|
||||||
|
}
|
||||||
loading={ytQuery.isFetchingNextPage}
|
loading={ytQuery.isFetchingNextPage}
|
||||||
>
|
>
|
||||||
{ytQuery.isFetchingNextPage
|
{ytQuery.isFetchingNextPage
|
||||||
@@ -710,7 +712,11 @@ export default function Feed({
|
|||||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||||
never shoves the controls beside it (see ItemCount). */}
|
never shoves the controls beside it (see ItemCount). */}
|
||||||
<ItemCount count={summaryQuery.data?.count} stale={summaryQuery.isPlaceholderData} />
|
<ItemCount
|
||||||
|
count={summaryQuery.data?.count}
|
||||||
|
stale={summaryQuery.isPlaceholderData}
|
||||||
|
failed={summaryQuery.isError && !summaryQuery.data}
|
||||||
|
/>
|
||||||
<FilterFoldButton
|
<FilterFoldButton
|
||||||
fold={fold}
|
fold={fold}
|
||||||
label={t("sidebar.filters")}
|
label={t("sidebar.filters")}
|
||||||
@@ -891,7 +897,10 @@ export default function Feed({
|
|||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
hasNextPage={!!hasNextPage}
|
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
||||||
|
// re-request it once a round-trip for as long as the end is in view (E31 review 2). The row
|
||||||
|
// below says so and retries on a click.
|
||||||
|
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
|
||||||
isFetching={query.isFetching}
|
isFetching={query.isFetching}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
/>
|
/>
|
||||||
@@ -910,6 +919,15 @@ export default function Feed({
|
|||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
||||||
)}
|
)}
|
||||||
|
{query.isFetchNextPageError && !isFetchingNextPage && (
|
||||||
|
<StateMessage
|
||||||
|
tone="error"
|
||||||
|
compact
|
||||||
|
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
||||||
|
>
|
||||||
|
{t("common.loadMoreFailed")}
|
||||||
|
</StateMessage>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,6 +563,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
expect(detail).toHaveBeenCalledTimes(2);
|
expect(detail).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E31 review 2: the popover below 1280 px said it the same way only by a copy nobody tested.
|
||||||
|
it("says it in the description popover below 1280 px too", async () => {
|
||||||
|
wideWindow(false);
|
||||||
|
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "player.description" }));
|
||||||
|
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("player.noDescription")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the details under the video below 1280 px", () => {
|
it("keeps the details under the video below 1280 px", () => {
|
||||||
wideWindow(false);
|
wideWindow(false);
|
||||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -606,6 +607,30 @@ export default function PlayerModal({
|
|||||||
qc,
|
qc,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The description, said the same way in its two places (the popover under 1280 px, the panel
|
||||||
|
// beside the video above it): loading, failed with a retry, the text, or none. One ladder, so the
|
||||||
|
// two cannot drift (E31 review 2: the failed state was tested in the panel only). `wrap` is each
|
||||||
|
// place's own box for the text.
|
||||||
|
const description = (wrap: (body: ReactNode) => ReactNode) =>
|
||||||
|
detail.isLoading ? (
|
||||||
|
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||||
|
) : detail.isError && !detail.data ? (
|
||||||
|
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||||
|
{t("player.descriptionFailed")}
|
||||||
|
</StateMessage>
|
||||||
|
) : detail.data?.description ? (
|
||||||
|
wrap(
|
||||||
|
renderDescription(detail.data.description, {
|
||||||
|
currentId: currentVideoId,
|
||||||
|
onSeek: seekTo,
|
||||||
|
onLoadVideo: loadVideo,
|
||||||
|
t,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||||
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
|
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
|
||||||
@@ -887,28 +912,15 @@ export default function PlayerModal({
|
|||||||
<SectionLabel level={3} className="mb-2">
|
<SectionLabel level={3} className="mb-2">
|
||||||
{t("player.description")}
|
{t("player.description")}
|
||||||
</SectionLabel>
|
</SectionLabel>
|
||||||
{detail.isLoading ? (
|
{description((body) => (
|
||||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
|
||||||
) : detail.isError && !detail.data ? (
|
|
||||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
|
||||||
{t("player.descriptionFailed")}
|
|
||||||
</StateMessage>
|
|
||||||
) : detail.data?.description ? (
|
|
||||||
<div
|
<div
|
||||||
ref={descFade.ref}
|
ref={descFade.ref}
|
||||||
style={descFade.style}
|
style={descFade.style}
|
||||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||||
>
|
>
|
||||||
{renderDescription(detail.data.description, {
|
{body}
|
||||||
currentId: currentVideoId,
|
|
||||||
onSeek: seekTo,
|
|
||||||
onLoadVideo: loadVideo,
|
|
||||||
t,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
|
||||||
)}
|
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1089,24 +1101,11 @@ export default function PlayerModal({
|
|||||||
<SectionLabel level={3} className="mb-2">
|
<SectionLabel level={3} className="mb-2">
|
||||||
{t("player.description")}
|
{t("player.description")}
|
||||||
</SectionLabel>
|
</SectionLabel>
|
||||||
{detail.isLoading ? (
|
{description((body) => (
|
||||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
|
||||||
) : detail.isError && !detail.data ? (
|
|
||||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
|
||||||
{t("player.descriptionFailed")}
|
|
||||||
</StateMessage>
|
|
||||||
) : detail.data?.description ? (
|
|
||||||
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||||
{renderDescription(detail.data.description, {
|
{body}
|
||||||
currentId: currentVideoId,
|
|
||||||
onSeek: seekTo,
|
|
||||||
onLoadVideo: loadVideo,
|
|
||||||
t,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { User, X } from "lucide-react";
|
import { User, X } from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 { healPlexField } from "../lib/plexSearch";
|
||||||
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
import type { SortState } from "../lib/columnSort";
|
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
|
// 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).
|
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
|
||||||
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
|
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!sentinelEl) return;
|
if (!sentinelEl) return;
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
@@ -753,6 +756,15 @@ export default function PlexBrowse() {
|
|||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
||||||
)}
|
)}
|
||||||
|
{isFetchNextPageError && !isFetchingNextPage && (
|
||||||
|
<StateMessage
|
||||||
|
tone="error"
|
||||||
|
compact
|
||||||
|
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
||||||
|
>
|
||||||
|
{t("common.loadMoreFailed")}
|
||||||
|
</StateMessage>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import videoCard from "./VideoCard.tsx?raw";
|
import videoCard from "./VideoCard.tsx?raw";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
MEDIA_BAR,
|
||||||
MEDIA_HOVER_LAYER,
|
MEDIA_HOVER_LAYER,
|
||||||
MEDIA_PICTURE_CONTAINER,
|
MEDIA_PICTURE_CONTAINER,
|
||||||
MEDIA_RESUME_LAYER,
|
MEDIA_RESUME_LAYER,
|
||||||
@@ -24,6 +25,16 @@ describe("a media picture", () => {
|
|||||||
["the Play", MEDIA_HOVER_LAYER],
|
["the Play", MEDIA_HOVER_LAYER],
|
||||||
["the Continue + Restart pair", MEDIA_RESUME_LAYER],
|
["the Continue + Restart pair", MEDIA_RESUME_LAYER],
|
||||||
])("leaves the hover row its band when narrow: %s", (_, 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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,4 +124,32 @@ describe("DataTable — a long page mounts in steps", () => {
|
|||||||
draw({ key: "r90", seq: 1 });
|
draw({ key: "r90", seq: 1 });
|
||||||
expect(host.querySelector("tbody")?.textContent).toContain("r 90");
|
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(
|
||||||
|
<DataTable
|
||||||
|
rows={rows}
|
||||||
|
columns={plain}
|
||||||
|
rowKey={(x) => 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -267,19 +267,28 @@ export default function DataTable<T>({
|
|||||||
// Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame
|
// 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
|
// 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.
|
// 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);
|
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<string | null>(null);
|
||||||
if (reveal && reveal.seq !== revealSeen) {
|
if (reveal && reveal.seq !== revealSeen) {
|
||||||
setRevealSeen(reveal.seq);
|
setRevealSeen(reveal.seq);
|
||||||
const at = sorted.findIndex((row) => rowKey(row) === reveal.key);
|
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);
|
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
|
// 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.
|
// always through the revealed row, which the caller focuses as soon as this frame commits.
|
||||||
const shown = useProgressiveRows(
|
const shown = useProgressiveRows(
|
||||||
paged,
|
paged,
|
||||||
`${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`,
|
view,
|
||||||
reveal ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
|
reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
|
||||||
);
|
);
|
||||||
|
|
||||||
function toggleSort(key: string) {
|
function toggleSort(key: string) {
|
||||||
|
|||||||
@@ -70,4 +70,17 @@ describe("ItemCount", () => {
|
|||||||
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
|
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
|
||||||
expect(screen.getByText("5 items").className).toContain("opacity-60");
|
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(<ItemCount count={5} stale />);
|
||||||
|
expect(text()).toBe("5 itemsupdating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a count that failed, rather than looking like a first load", () => {
|
||||||
|
render(<ItemCount failed />);
|
||||||
|
expect(text()).toBe("—Couldn't count the items");
|
||||||
|
expect(screen.getByTestId("item-count").getAttribute("title")).toBe("Couldn't count the items");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// ("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)
|
// "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,
|
// 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,
|
// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which
|
||||||
// so a broad search's slow count ("a": ~3 s) never reads as the new result.
|
// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as
|
||||||
export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) {
|
// 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 { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
// `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts),
|
||||||
// show the icon until the number arrives.
|
// just show the icon until the number arrives.
|
||||||
const label =
|
const label = failed
|
||||||
count == null
|
? "—"
|
||||||
|
: count == null
|
||||||
? ""
|
? ""
|
||||||
: t("common.itemCount", {
|
: t("common.itemCount", {
|
||||||
count,
|
count,
|
||||||
@@ -31,7 +42,9 @@ export default function ItemCount({ count, stale = false }: { count?: number; st
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid="item-count"
|
data-testid="item-count"
|
||||||
title={count == null ? undefined : formatNumber(count, lang)}
|
title={
|
||||||
|
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
|
||||||
|
}
|
||||||
aria-busy={stale || undefined}
|
aria-busy={stale || undefined}
|
||||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||||
>
|
>
|
||||||
@@ -39,6 +52,8 @@ export default function ItemCount({ count, stale = false }: { count?: number; st
|
|||||||
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
{failed && <span className="sr-only">{t("common.countFailed")}</span>}
|
||||||
|
{stale && !failed && <span className="sr-only">{t("common.countUpdating")}</span>}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import CountBadge from "./CountBadge";
|
|||||||
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
|
// 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:
|
// 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
|
// 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
|
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
||||||
// useTabPersistedState (per browser tab, E31 S4b): a reload keeps the user where they were, and a
|
// lib/storage's useTabPersistedState (per browser tab, E31 S4b: Admin Users, Config, Settings, Stats):
|
||||||
// fresh tab opens on the first sub-tab rather than wherever the last visit ended.
|
// 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`,
|
// 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
|
// ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the
|
||||||
|
|||||||
@@ -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";
|
"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.
|
/** 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. */
|
* Every picture box carries it: `mediaPictureBox` and the video card's own box. A box that holds a
|
||||||
export const MEDIA_PICTURE_CONTAINER = "@container/pic";
|
* 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).
|
/** 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
|
* 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
|
* 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,
|
* 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 =
|
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
|
/** 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. */
|
* 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. */
|
/** 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`;
|
* `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,
|
/** 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
|
* a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
|
"loadMoreFailed": "Couldn't load more",
|
||||||
|
"countFailed": "Couldn't count the items",
|
||||||
|
"countUpdating": "updating",
|
||||||
"somethingWrong": "Something went wrong",
|
"somethingWrong": "Something went wrong",
|
||||||
"itemCount_one": "{{formattedCount}} item",
|
"itemCount_one": "{{formattedCount}} item",
|
||||||
"itemCount_other": "{{formattedCount}} items",
|
"itemCount_other": "{{formattedCount}} items",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"undo": "Visszavonás",
|
"undo": "Visszavonás",
|
||||||
"redo": "Újra",
|
"redo": "Újra",
|
||||||
"loading": "Betöltés…",
|
"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",
|
"somethingWrong": "Hiba történt",
|
||||||
"itemCount_one": "{{formattedCount}} elem",
|
"itemCount_one": "{{formattedCount}} elem",
|
||||||
"itemCount_other": "{{formattedCount}} elem",
|
"itemCount_other": "{{formattedCount}} elem",
|
||||||
|
|||||||
@@ -29,4 +29,34 @@ describe("an infinite list's read-ahead", () => {
|
|||||||
it("the feed hands the grid the query's isFetching", () => {
|
it("the feed hands the grid the query's isFetching", () => {
|
||||||
expect(source("Feed.tsx")).toMatch(/isFetching=\{query\.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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// picture). A plain `src={…}` is allowed only where the full cut is the point or the URL is not the
|
||||||
// the proxy's; the SET is pinned, so a new bare one fails here.
|
// proxy's; the SET is pinned, so a new one fails here. Every <img>'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(
|
const plexFiles = import.meta.glob(
|
||||||
[
|
[
|
||||||
"../components/{Plex,plex}*.tsx",
|
"../components/{Plex,plex}*.tsx",
|
||||||
@@ -58,7 +60,10 @@ const plexFiles = import.meta.glob(
|
|||||||
{ query: "?raw", import: "default", eager: true },
|
{ query: "?raw", import: "default", eager: true },
|
||||||
);
|
);
|
||||||
const ALLOWED = new Set([
|
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
|
"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 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
|
"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
|
"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);
|
expect(Object.keys(plexFiles).length).toBeGreaterThan(5);
|
||||||
const bare = new Set<string>();
|
const bare = new Set<string>();
|
||||||
for (const [path, src] of Object.entries(plexFiles))
|
for (const [path, src] of Object.entries(plexFiles))
|
||||||
for (const m of (src as string).matchAll(/<img\s+src=\{([^}]*thumb[^}]*)\}/g))
|
for (const m of (src as string).matchAll(/<img\b[^>]*?\bsrc=\{([^}]*)\}/g))
|
||||||
bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`);
|
bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`);
|
||||||
expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]);
|
expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user