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:
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user