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.
210 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|