// @vitest-environment jsdom // // A video marked watched in the player (by hand, or by the player at its end) left an Unwatched view // at once, then came back as "new" when the player closed, until a reload (owner, on a channel page). // The player's closing position patch writes into the cached feed, which bumped `dataUpdatedAt`; the // feed took that for fresh server data and dropped its optimistic overrides, and nothing refetched. // The grid and the player are stubs that show what Feed hands them. import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); vi.mock("../../features/wizard/wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }), })); // What the grid was handed for its card menu's channel-filter row. const grid: { channelFilter?: { ids: string[]; set: (ids: string[]) => void } } = {}; vi.mock("./VirtualFeed", () => ({ default: ({ items, onOpen, channelFilter, }: { items: { id: string }[]; onOpen: (v: unknown) => void; channelFilter?: { ids: string[]; set: (ids: string[]) => void }; }) => ( (grid.channelFilter = channelFilter), (
    {items.map((v) => (
  1. {v.id}
  2. ))}
) ), })); // The player's progress hook marks a clip closed near its end watched from its unmount cleanup: // `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks. const player = vi.hoisted(() => ({ watchOnUnmount: false, flush: null as Promise | null, })); vi.mock("../../features/player/PlayerModal", async () => { const { useEffect } = await import("react"); const { trackProgressFlush } = await import("../../lib/progressCache"); return { default: function Player({ video, queue, onState, onWrite, onClose, }: { video: { id: string }; queue?: { id: string }[]; onState: (id: string, status: string, v?: unknown) => void; onWrite?: (write: Promise) => void; onClose: () => void; }) { useEffect( () => () => { if (player.watchOnUnmount) onState(video.id, "watched"); if (player.flush) trackProgressFlush(player.flush); }, [video.id, onState], ); return (

playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}

); }, }; }); import Feed from "./Feed"; import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; import { patchCachedProgress } from "../../lib/progressCache"; import { clearAll, getNotifications } from "../../lib/notifications"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); player.watchOnUnmount = false; player.flush = null; clearAll(); window.history.replaceState(null, "", "/"); }); // A cached related list (the player's, kept for the session) holding these videos. const seedRelated = (qc: QueryClient, ...ids: string[]) => qc.setQueryData(qk.related("SRC", "topical"), { pages: [ { items: ids.map((id) => ({ id, status: "new" })), has_more: false, next_cursor: null }, ], pageParams: [null], }); const relatedStatus = (qc: QueryClient, id: string) => ( qc.getQueryData(qk.related("SRC", "topical")) as { pages: { items: { id: string; status: string }[] }[]; } ).pages[0]!.items.find((v) => v.id === id)?.status; const FILTERS: FeedFilters = { tags: [], tagModes: DEFAULT_TAG_MODES, channelIds: ["UCx"], q: "", sort: "newest", scope: "my", includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", }; const video = (status: string, position = 0) => ({ id: "v1", title: "v1", channel_id: "UCx", channel_title: "Channel", status, position_seconds: position, duration_seconds: 600, published_at: "2026-01-01T00:00:00Z", watch_url: "https://www.youtube.com/watch?v=v1", }) as unknown as Video; function mount({ channelScoped = true, setFilters = () => {}, }: { channelScoped?: boolean; setFilters?: (f: FeedFilters) => void } = {}) { const feed = vi .spyOn(api, "feed") .mockResolvedValueOnce({ items: [video("new")], next_cursor: null } as never) // The server after the mark: watched, so an Unwatched view leaves it out. .mockResolvedValue({ items: [], next_cursor: null } as never); const summary = vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never); vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); render( {}} canRead ytSearch={null} onYtSearch={() => {}} onExitYtSearch={() => {}} channelScoped={channelScoped} /> , ); return { qc, feed, summary }; } // The URL is the view: a reload or a bookmark of the player reopens it once the list is in. describe("Feed — a player the address names", () => { it("reopens on its video, the list as its queue", async () => { window.history.replaceState(null, "", "/?watch=v1"); mount(); expect(await screen.findByText("playing v1 in v1")).toBeTruthy(); }); it("plays a video the loaded list does not hold alone, from the catalog", async () => { window.history.replaceState(null, "", "/?watch=far"); const row = vi.spyOn(api, "videoRow").mockResolvedValue({ ...video("new"), id: "far" }); mount(); expect(await screen.findByText("playing far in no list")).toBeTruthy(); expect(row).toHaveBeenCalledWith("far"); }); }); describe("Feed — a video marked watched in the player", () => { it("stays out of an Unwatched view when the player writes its position into the cache", async () => { const { qc } = mount(); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("mark")); await waitFor(() => expect(screen.queryByText("open v1")).toBeNull()); // What the player does as it closes: the server's stored position, written into the cached list. act(() => patchCachedProgress(qc, new Map([["v1", 10]]))); await new Promise((r) => setTimeout(r, 20)); expect(screen.queryByText("open v1")).toBeNull(); }); it("refetches the list and its count by the view's filters when the player closes", async () => { const { feed, summary } = mount(); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("mark")); const before = { feed: feed.mock.calls.length, summary: summary.mock.calls.length }; fireEvent.click(screen.getByText("close")); await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before.feed)); await waitFor(() => expect(summary.mock.calls.length).toBeGreaterThan(before.summary)); // The refetch is the channel's Unwatched list, and the server's answer keeps the card out. expect(feed.mock.calls[feed.mock.calls.length - 1]?.[0]).toMatchObject({ channelIds: ["UCx"], show: "unwatched", }); await new Promise((r) => setTimeout(r, 20)); expect(screen.queryByText("open v1")).toBeNull(); }); it("refetches when the player marks the clip watched as it closes", async () => { const { feed } = mount(); fireEvent.click(await screen.findByText("open v1")); player.watchOnUnmount = true; const before = feed.mock.calls.length; fireEvent.click(await screen.findByText("close")); await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before)); }); it("waits for the session's writes to land before it refetches", async () => { const { feed } = mount(); let land: (v: unknown) => void = () => {}; vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("mark")); const before = feed.mock.calls.length; fireEvent.click(screen.getByText("close")); await act(() => new Promise((r) => setTimeout(r, 30))); // A refetch now would ask a server that has not taken the mark yet. expect(feed.mock.calls.length).toBe(before); await act(async () => land({ ok: true })); await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before)); }); it("waits for the player's closing checkpoint before it refetches", async () => { // Refetched before that write landed, the list read the older position and, landing after the // player's patch, put it back: a reopen resumed early. const { feed } = mount(); let land: (v: unknown) => void = () => {}; player.flush = new Promise((r) => (land = r)); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("mark")); const before = feed.mock.calls.length; fireEvent.click(screen.getByText("close")); await act(() => new Promise((r) => setTimeout(r, 30))); expect(feed.mock.calls.length).toBe(before); await act(async () => land(undefined)); await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before)); }); it("refetches when the player's related list wrote", async () => { const { feed } = mount(); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("related list wrote")); const before = feed.mock.calls.length; fireEvent.click(screen.getByText("close")); await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before)); }); it("names a video that is none of its own in the notice", async () => { mount(); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("mark related")); await waitFor(() => expect(getNotifications().map((n) => n.meta)).toContainEqual({ kind: "video-watched", videoId: "rel1", title: "Related one", channelId: "UCr", channelName: "Rel channel", }), ); }); it("puts the mark into the player's related lists, which are never refetched", async () => { const { qc } = mount(); seedRelated(qc, "v1", "other"); fireEvent.click(await screen.findByText("open v1")); fireEvent.click(await screen.findByText("mark")); await waitFor(() => expect(relatedStatus(qc, "v1")).toBe("watched")); expect(relatedStatus(qc, "other")).toBe("new"); }); it("does not refetch on a close with nothing changed", async () => { const { feed } = mount(); fireEvent.click(await screen.findByText("open v1")); const before = feed.mock.calls.length; fireEvent.click(await screen.findByText("close")); await new Promise((r) => setTimeout(r, 30)); expect(feed.mock.calls.length).toBe(before); }); }); describe("Feed — the card menu's channel filter", () => { it("is offered on the feed, as its own channel list, and sets it", async () => { const setFilters = vi.fn(); mount({ channelScoped: false, setFilters }); await screen.findByText("open v1"); expect(grid.channelFilter?.ids).toEqual(["UCx"]); grid.channelFilter?.set(["UCx", "UCy"]); expect(setFilters).toHaveBeenCalledWith({ ...FILTERS, channelIds: ["UCx", "UCy"] }); }); it("is not offered on a channel page, whose view is that channel already", async () => { mount({ channelScoped: true }); await screen.findByText("open v1"); expect(grid.channelFilter).toBeUndefined(); }); });