Files
siftlode/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
T
peter 3438aeb695 fix(player): the player's own focus grabs leave a tabbed-to control alone
Turning ready, the pointer leaving the video, the window regaining focus, a wheel over it and
leaving fullscreen each put the focus back on the card unconditionally, so a viewer who had
tabbed to a control right after opening started over from the first button on the next Tab.
They now take it back only when it sits in the iframe or off the card.
2026-10-10 01:14:31 +02:00

199 lines
8.0 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 RefObject } from "react";
import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress, progressFlushes } 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 RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {},
setPlayerError: () => {},
setLiveData: () => {},
reapplyStored: () => {},
reclaimFocus: () => {},
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("hands its closing checkpoint out until it has landed and been patched in", async () => {
// A list refetched on the player's close waits for it: read before it landed, the list held the
// older position and, landing after the patch, put it back.
const qc = new QueryClient();
seedFeed(qc, video());
atPosition(830, 3118);
let land: (v: unknown) => void = () => {};
vi.spyOn(api, "saveProgress").mockReturnValue(new Promise((r) => (land = r)) as never);
const { unmount } = mountPlayer(qc, new Map());
await settle();
unmount();
const flushes = progressFlushes();
expect(flushes).toHaveLength(1);
land({ video_id: VID, position_seconds: 830 });
await flushes[0];
expect(cachedPos(qc)).toBe(830);
await settle();
expect(progressFlushes()).toHaveLength(0);
});
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);
});
});