// @vitest-environment jsdom // // The feed's optimistic layer: a card shows what was just written to it until the server's own answer // lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written // before it set out), a filter change; never an append of the next page. A mark made while a refetch // was in flight is newer than its answer, and dropping it put a card marked watched back into an // Unwatched view. The grid is a stub that shows what Feed hands it. 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("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) })); vi.mock("./VirtualFeed", () => ({ default: ({ items, onState, fetchNextPage, }: { items: { id: string; status: string }[]; onState: (id: string, status: string) => void; fetchNextPage: (o: object) => unknown; }) => (
    {items.map((v) => (
  1. {`${v.id}:${v.status}`}
  2. ))}
), })); import Feed from "./Feed"; import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api"; import { qk } from "../lib/queryKeys"; 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(); }); const FILTERS: FeedFilters = { tags: [], tagModes: DEFAULT_TAG_MODES, channelIds: [], q: "", sort: "newest", scope: "my", includeNormal: true, includeShorts: false, includeLive: false, show: "all", }; const video = (id: string) => ({ id, title: id, channel_id: "UCx", channel_title: "Channel", status: "new", position_seconds: 0, duration_seconds: 600, published_at: "2026-01-01T00:00:00Z", watch_url: `https://www.youtube.com/watch?v=${id}`, }) as unknown as Video; // The server never takes the mark in these tests: whatever a list shows as "watched" is the override. const page = (ids: string[], next: string | null = null) => ({ items: ids.map(video), next_cursor: next }) as never; function mount(feed: ReturnType, setState?: () => Promise) { vi.spyOn(api, "feed").mockImplementation(feed as never); vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never); if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never); else 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: {} }); const ui = (filters: FeedFilters) => ( {}} view="cards" setView={() => {}} canRead ytSearch={null} onYtSearch={() => {}} onExitYtSearch={() => {}} /> ); const r = render(ui(FILTERS)); return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) }; } const settle = () => act(() => new Promise((r) => setTimeout(r, 20))); describe("Feed — optimistic overrides", () => { it("give way to a refetch that lands after them", async () => { let answer: (v: unknown) => void = () => {}; const feed = vi .fn() .mockResolvedValueOnce(page(["v1"])) .mockImplementationOnce(() => new Promise((r) => (answer = r))); const { qc } = mount(feed); fireEvent.click(await screen.findByText("watch v1")); await screen.findByText("v1:watched"); act(() => void qc.invalidateQueries({ queryKey: qk.feed() })); await waitFor(() => expect(feed).toHaveBeenCalledTimes(2)); await settle(); await act(async () => answer(page(["v1"]))); await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy()); }); it("keep a mark made while the refetch was in flight", async () => { let answer: (v: unknown) => void = () => {}; const feed = vi .fn() .mockResolvedValueOnce(page(["v1"])) .mockImplementationOnce(() => new Promise((r) => (answer = r))); const { qc } = mount(feed); await screen.findByText("v1:new"); act(() => void qc.invalidateQueries({ queryKey: qk.feed() })); await waitFor(() => expect(feed).toHaveBeenCalledTimes(2)); await settle(); fireEvent.click(screen.getByText("watch v1")); await screen.findByText("v1:watched"); // The answer the server gave before it heard of the mark. await act(async () => answer(page(["v1"]))); await settle(); expect(screen.getByText("v1:watched")).toBeTruthy(); }); it("keep a mark sent before the refetch set out but not answered by then", async () => { // The server may read the list before it commits the mark: that answer is older than the mark. let answer: (v: unknown) => void = () => {}; let commit: (v: unknown) => void = () => {}; const feed = vi .fn() .mockResolvedValueOnce(page(["v1"])) .mockImplementationOnce(() => new Promise((r) => (answer = r))); const { qc } = mount(feed, () => new Promise((r) => (commit = r))); fireEvent.click(await screen.findByText("watch v1")); await screen.findByText("v1:watched"); act(() => void qc.invalidateQueries({ queryKey: qk.feed() })); await waitFor(() => expect(feed).toHaveBeenCalledTimes(2)); await settle(); await act(async () => answer(page(["v1"]))); await settle(); expect(screen.getByText("v1:watched")).toBeTruthy(); await act(async () => commit({ ok: true })); }); it("are taken back by a failed write only while no later write stands", async () => { // Marked watched, then hidden before the first answer came back: the refused mark must not take // the hide with it and put the card back in the view. const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = []; mount( vi.fn().mockResolvedValue(page(["v1", "v2"])), () => new Promise((resolve, reject) => writes.push({ resolve, reject })), ); fireEvent.click(await screen.findByText("watch v1")); fireEvent.click(await screen.findByText("hide v1")); await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull()); await act(async () => writes[0]?.reject(new Error("refused"))); await settle(); expect(screen.queryByText(/^v1:/)).toBeNull(); await act(async () => writes[1]?.reject(new Error("refused"))); await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy()); }); it("survive an append of the next page", async () => { let answer: (v: unknown) => void = () => {}; const feed = vi .fn() .mockResolvedValueOnce(page(["v1"], "c1")) .mockImplementationOnce(() => new Promise((r) => (answer = r))); mount(feed); fireEvent.click(await screen.findByText("watch v1")); await screen.findByText("v1:watched"); fireEvent.click(screen.getByText("more")); await waitFor(() => expect(feed).toHaveBeenCalledTimes(2)); await settle(); await act(async () => answer(page(["v2"]))); await screen.findByText("v2:new"); await settle(); expect(screen.getByText("v1:watched")).toBeTruthy(); }); it("are dropped by a change of the filters", async () => { const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"]))); fireEvent.click(await screen.findByText("watch v1")); await screen.findByText("v1:watched"); rerender({ ...FILTERS }); await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy()); }); });