Files
siftlode/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
T
peter 4869d6c108 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.
2026-09-11 01:19:14 +02:00

178 lines
7.2 KiB
TypeScript

// @vitest-environment jsdom
//
// The YT player's progress checkpoint must reach the CACHE, not just the server. Before this, the
// modal's cleanup only marked `qk.feed()` stale with `refetchType: "none"` — and nothing refetches
// the feed until it remounts (staleTime 30s, refetchOnWindowFocus off), so reopening the same card
// in the same session resumed from the position the cache had been fetched with, and the next
// checkpoint then persisted THAT older value over the newer one. Measured on localdev before the
// fix: played to 830s → closed (server had 830) → reopened → the row was back to 751s.
//
// Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton,
// so a test that installs `window.YT` cannot share a module instance with tests that do not.
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { createRef, type MutableRefObject } from "react";
import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys";
import { api, type Video } from "./api";
import type { YTPlayer } from "./youtube";
const VID = "EC5oFYy9z9g";
const video = (over: Partial<Video> = {}): Video =>
({ id: VID, title: "Melyik szállás a Drágább?", position_seconds: 748, ...over }) as Video;
/** A feed cache shaped like the real infinite query: pages of `{ items }`. */
const seedFeed = (qc: QueryClient, v: Video) =>
qc.setQueryData(qk.feed({ q: "" } as never), { pages: [{ items: [v] }], pageParams: [null] });
const cachedPos = (qc: QueryClient): number | undefined => {
const data = qc.getQueryData(qk.feed({ q: "" } as never)) as
| { pages: { items: Video[] }[] }
| undefined;
return data?.pages[0]?.items[0]?.position_seconds;
};
// ONE fake namespace with a MUTABLE clock, installed once. `loadYouTubeApi` memoises the API
// promise in a module-level singleton, so the namespace the first mount resolves is the one every
// later mount gets — re-installing a fresh `window.YT` per test would silently have no effect (an
// earlier draft of this file did exactly that, and its "near the end" case was really still
// reading the first test's clock).
const clock = { current: 0, duration: 0 };
const fakePlayer = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
destroy: () => {},
} as unknown as YTPlayer;
window.YT = {
Player: function () {
return fakePlayer;
} as never,
};
/** Point the shared fake player at a position. */
function atPosition(currentTime: number, duration: number) {
clock.current = currentTime;
clock.duration = duration;
}
/** Let the API promise, the player construction and the checkpoint POST all settle. */
const settle = async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 6; i++) await Promise.resolve();
};
function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
const mount = document.createElement("div");
document.body.appendChild(mount);
return renderHook(() =>
useYtProgress({
activeId: VID,
resumeAt: 748,
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as MutableRefObject<YTPlayer | null>,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {},
setPlayerError: () => {},
setLiveData: () => {},
reapplyStored: () => {},
focusModal: () => {},
advanceOnEnd: () => {},
sessionPosRef: { current: sessionPos },
qc,
}),
);
}
describe("useYtProgress — the checkpoint reaches the cache", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("writes the position the player reached into the cached feed on close", async () => {
const qc = new QueryClient();
seedFeed(qc, video());
atPosition(830, 3118);
const save = vi
.spyOn(api, "saveProgress")
.mockResolvedValue({ video_id: VID, position_seconds: 830 });
const { unmount } = mountPlayer(qc, new Map());
await settle();
unmount();
await settle();
expect(save).toHaveBeenCalled();
// Without the patch this stays 748 — the value a reopen would resume from, and then persist
// back over the 830 the server already holds.
expect(cachedPos(qc)).toBe(830);
});
it("mirrors what the SERVER stored, not what the player read", async () => {
const qc = new QueryClient();
seedFeed(qc, video({ position_seconds: 748 }));
// 3s in — under the endpoint's 5s floor, so it stores 0 and answers 0. (The OTHER clearing
// rule, near-the-end, is unreachable from here on purpose: the player auto-marks watched and
// deliberately skips that checkpoint, so it never asks the server at all.)
atPosition(3, 3118);
vi.spyOn(api, "saveProgress").mockResolvedValue({ video_id: VID, position_seconds: 0 });
const sessionPos = new Map<string, number>();
const { unmount } = mountPlayer(qc, sessionPos);
await settle();
unmount();
await settle();
// Mirroring `getCurrentTime()` would put 3 here — a resume bar and a resume point the server
// does not have, which the next checkpoint would then keep re-asserting.
expect(sessionPos.get(VID)).toBe(0);
expect(cachedPos(qc)).toBe(0);
});
});
describe("patchCachedProgress", () => {
it("patches a playlist detail and leaves untouched lists identical", () => {
const qc = new QueryClient();
const other = video({ id: "other", position_seconds: 12 });
qc.setQueryData(qk.playlist(3), { id: 3, items: [video(), other] });
const before = qc.getQueryData(qk.playlist(3)) as { items: Video[] };
patchCachedProgress(qc, new Map([[VID, 830]]));
const after = qc.getQueryData(qk.playlist(3)) as { items: Video[] };
expect(after.items[0]?.position_seconds).toBe(830);
// Same object identity for the row that did not change — so an open player's checkpoint does
// not re-render every card in the list behind it.
expect(after.items[1]).toBe(before.items[1]);
expect(after.items[1]?.position_seconds).toBe(12);
});
it("patches a live YouTube search too — its rows open the same player", () => {
// Left out, reopening a search result resumed from the position the search was fetched with
// (0 for a fresh find) and the next checkpoint wrote that over the stored one.
const qc = new QueryClient();
const key = qk.ytSearch("melyik szállás", 20);
qc.setQueryData(key, { pages: [{ items: [video()], next_cursor: null }], pageParams: [null] });
patchCachedProgress(qc, new Map([[VID, 830]]));
const after = qc.getQueryData(key) as { pages: { items: Video[] }[] };
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
});
it("is a no-op when nothing was stored", () => {
const qc = new QueryClient();
seedFeed(qc, video());
const before = qc.getQueryData(qk.feed({ q: "" } as never));
patchCachedProgress(qc, new Map());
expect(qc.getQueryData(qk.feed({ q: "" } as never))).toBe(before);
});
});