Files
siftlode/frontend/src/components/PlexPlaylistReorder.dom.test.tsx
T
peter 632e39d458 feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)
The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
2026-09-29 02:12:32 +02:00

131 lines
5.7 KiB
TypeScript

// @vitest-environment jsdom
//
// One accepted write, one cache entry that matches it — the Plex half.
//
// `Playlists.tsx` (the YouTube list) got this rule in round 5: the detail cache is what a rollback
// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted
// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got
// the rollback half of that round's fix and not this half (round 6).
//
// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly —
// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture).
import { it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const { dragEnd } = vi.hoisted(() => ({ dragEnd: { fire: (_e: unknown) => {} } }));
vi.mock("@dnd-kit/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
return {
...actual,
DndContext: ({
children,
onDragEnd,
}: {
children: React.ReactNode;
onDragEnd: (e: unknown) => void;
}) => {
dragEnd.fire = onDragEnd;
return <>{children}</>;
},
};
});
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } 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 movie = (id: string) =>
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
it("writes the order the server accepted into the detail cache, without waiting for a refetch", async () => {
// The refetch that same handler schedules (round 7) must not be what makes the cache right: the
// point of the write is that a re-entry inside the 30 s staleTime renders the accepted order
// immediately, with no request at all. So the refetch is HELD OPEN here — if this assertion
// needed it to land, deleting the write would still pass and round 6's fix would be unguarded.
let calls = 0;
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
++calls === 1
? Promise.resolve({ id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] })
: new Promise(() => {}),
);
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Movie 1");
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
// The list key was always invalidated; the DETAIL key — the one `putBack` reads and the one a
// re-entry renders from — kept the pre-reorder order, so leaving and coming back inside the
// staleTime showed the reorder undone, and the next drag persisted that.
await waitFor(() =>
expect(
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id),
).toEqual(["2", "1", "3"]),
);
});
it("refetches the detail too, so a membership change that landed meanwhile is not clobbered", async () => {
// `next` is the membership of the render that started the drag, and `reorder_playlist` only
// touches the rating_keys it is handed — it never ratifies that list. So writing `next` into the
// detail cache silently drops anything that landed in between (a row removed from another
// surface, an item added elsewhere), and with only the LIST key invalidated nothing corrected it:
// staleTime is 30 s and refetchOnWindowFocus is off, so the phantom row stayed on screen for as
// long as the view was mounted and the next drag persisted an order built from it. The YouTube
// sibling invalidates the detail key right after writing it; this half did not (round 7).
let calls = 0;
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
Promise.resolve(
++calls === 1
? { id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] }
: { id: 5, title: "Films", items: [movie("2"), movie("1")] }, // "3" is gone server-side
),
);
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Movie 3");
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
// The refetch the accepted write schedules brings the real membership back.
await waitFor(() => expect(screen.queryByText("Movie 3")).toBeNull());
await waitFor(() =>
expect(
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id),
).toEqual(["2", "1"]),
);
});