Files
siftlode/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
T
peter 686c673fb2 feat(player): a video's related videos, on demand beside it
The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
2026-10-24 16:57:52 +02:00

210 lines
8.5 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("patches a related list too — a row played from it resumes where it was left", () => {
const qc = new QueryClient();
const key = qk.related("SRC", "topical");
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);
});
});