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,115 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
|
||||
// had tests; what the modal DOES with them had none — a deep-review of the 2026-09 bug hunt found
|
||||
// that deleting the lines that apply them left every lane green.
|
||||
//
|
||||
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
|
||||
// its promise in a module-level singleton, so this file must not share a module instance with a
|
||||
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
|
||||
// fake records what it was asked for — that is how these tests see where playback starts.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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("./NavigationProvider", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
|
||||
// Their own queries and popovers are not what is under test here.
|
||||
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
|
||||
vi.mock("./DownloadButton", () => ({ default: () => null }));
|
||||
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import { api, type Video } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
// --- the fake IFrame API ------------------------------------------------------------------------
|
||||
const clock = { current: 0, duration: 3000 };
|
||||
const started: { videoId: string; start: number | undefined }[] = [];
|
||||
const base = {
|
||||
getCurrentTime: () => clock.current,
|
||||
getDuration: () => clock.duration,
|
||||
getVideoData: () => null,
|
||||
};
|
||||
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
|
||||
// a thenable would be swallowed by the first `await` that touched it.
|
||||
const fakePlayer = new Proxy(base, {
|
||||
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
|
||||
});
|
||||
window.YT = {
|
||||
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) {
|
||||
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||
return fakePlayer;
|
||||
} as never,
|
||||
};
|
||||
|
||||
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,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
function renderModal(ui: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
started.length = 0;
|
||||
});
|
||||
|
||||
const lastStart = () => started[started.length - 1];
|
||||
|
||||
describe("PlayerModal — where each item starts", () => {
|
||||
it("resumes an item played earlier in the SAME session where this session left it", async () => {
|
||||
// The launch queue is frozen at mount, so its rows keep the position they were snapshotted
|
||||
// with (748). Stepping next→prev back to an item already watched to 830 used to start it at
|
||||
// 748 — and the next checkpoint then wrote 748 over the 830 the server held.
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: id === "A" ? 830 : 0,
|
||||
}));
|
||||
const A = vid("A", 748);
|
||||
const B = vid("B", 0);
|
||||
clock.current = 830;
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
expect(lastStart()?.start).toBe(830);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user