fix(player): the resume position reaches the playlist page and live search, not just the feed

Deep-review R4. 1630a3df wrote the YT player's stored position into the react-query cache so a
reopen could not rewind it — but only the feed actually read it back:

- Playlists renders from its own undoable copy of the list, re-seeded only when the SET of ids
  changes, so the patched position never reached it: close a video at 830 s, reopen it from the
  playlist, and it resumed at the old spot and the next checkpoint wrote that over the 830. A detail
  with the same membership now rebases its fresh rows into every snapshot, keeping order and undo
  history. (The ids-only guard is older than this round; the write that depended on it was new.)
- Live YouTube search results carry the same per-user position and open the same player, but their
  cache was never patched. It is now — patched only, never invalidated, since refetching an
  API-sourced search page spends 100 quota units.

Tests for what had none: a Playlists harness (PlayerModal stubbed to record its launch props) and a
PlayerModal harness with a fake IFrame API that records where each item is started — the latter pins
that stepping back to an item watched earlier in the same session resumes at THIS session's position.
3 mutations, each red on its assertion.
This commit is contained in:
2026-09-11 01:19:14 +02:00
parent d20317e0f1
commit 4869d6c108
5 changed files with 265 additions and 1 deletions
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
//
// The Playlists page as the player sees it: which row it is launched with, and what it is told to
// do when the viewer removes that row from inside it. PlayerModal itself is replaced by a stand-in
// that records its props — its own behaviour is pinned in PlayerModal.dom.test.tsx.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
selectedId: 1 as number | null,
player: null as null | {
video: { id: string; position_seconds: number };
onClose: () => void;
onRemoveFromList?: (v: unknown) => Promise<boolean>;
},
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
i18n: { language: "en" },
}),
};
});
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
vi.mock("./PlayerModal", () => ({
default: (props: NonNullable<typeof h.player>) => {
h.player = props;
return null;
},
}));
import Playlists from "./Playlists";
import { api, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { patchCachedProgress } from "../lib/progressCache";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const vid = (id: string, position_seconds: number): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const list = (id: number, items: Video[]) => ({
id,
name: `List ${id}`,
kind: "user",
yt_playlist_id: null,
item_count: items.length,
items,
});
let qc: QueryClient;
function mount(lists: Record<number, Video[]>) {
vi.spyOn(api, "playlists").mockResolvedValue(
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
);
vi.spyOn(api, "playlist").mockImplementation(
async (id: number) => list(id, lists[id] ?? []) as never,
);
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
return render(
<QueryClientProvider client={qc}>
<Playlists />
</QueryClientProvider>,
);
}
const play = async (title: string) =>
fireEvent.click(await screen.findByRole("button", { name: `card.play — ${title}` }));
beforeEach(() => {
h.selectedId = 1;
h.player = null;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("Playlists — the row the player is launched with", () => {
it("carries the position the player just stored, not the one the list first loaded with", async () => {
// The page renders from its own undoable copy of the list, re-seeded only when the SET of ids
// changes — so the position the player wrote into the cache never reached it: reopen a video
// closed at 830 s and it resumed at the old spot, and the next checkpoint wrote that over 830.
mount({ 1: [vid("V", 748)] });
await play("Video V");
await waitFor(() => expect(h.player?.video.position_seconds).toBe(748));
act(() => h.player!.onClose());
h.player = null;
// What the player's cleanup does with the position the server confirmed.
act(() => patchCachedProgress(qc, new Map([["V", 830]])));
await play("Video V");
await waitFor(() => expect(h.player?.video.position_seconds).toBe(830));
});
});