// @vitest-environment jsdom // // The Channel manager's "Discover from your history" tab: what it says before a history was // uploaded and while the list is still being made, its top twenty and "show more", taking a channel // off the list with an Undo, and a row's count opening the channel's watched videos to play. import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true), setPage: vi.fn(), focusSettingsTab: vi.fn(), player: vi.fn(), })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string, o?: Record) => o && "count" in o ? `${key}:${String(o.count)}` : key, }), }; }); vi.mock("../../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify: (...a: unknown[]) => h.notify(...a), })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); vi.mock("../../features/navigation/navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }), })); vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab })); vi.mock("../../features/player/PlayerModal", () => ({ default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => { h.player( p.video.id, p.queue.map((v) => v.id), p.startIndex, ); return
; }, })); import WatchedChannels from "./WatchedChannels"; import { qk } from "../../lib/queryKeys"; import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api"; afterEach(() => { cleanup(); vi.restoreAllMocks(); for (const fn of Object.values(h)) fn.mockReset(); h.confirm.mockImplementation(async () => true); }); const channel = (n: number, over: Partial = {}): WatchedChannel => ({ id: `UC${n}`, title: `Channel ${n}`, handle: null, thumbnail_url: null, subscriber_count: 1000, video_count: 50, description: null, watched: 30 - n, last_watched_at: "2026-09-24T10:00:00Z", ...over, }); const video = (id: string): Video & { takeout_watched_at: string } => ({ id, title: `Video ${id}`, channel_id: "UC1", channel_title: "Channel 1", channel_thumbnail: null, channel_url: "", published_at: null, thumbnail_url: null, duration_seconds: 600, view_count: null, is_short: false, live_status: "none", status: "new", position_seconds: 0, saved: false, watch_url: "", takeout_watched_at: "2026-09-24T10:00:00Z", }); function mount(list: List) { vi.spyOn(api, "watchedChannels").mockResolvedValue(list); const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); render( {}} onViewChannel={() => {}} /> , ); return qc; } describe("WatchedChannels", () => { it("sends a user with no history uploaded to the upload, not to an empty list", async () => { mount({ imported: false, pending: 0, channels: [] }); fireEvent.click(await screen.findByRole("button", { name: "channels.watched.openTakeout" })); expect(h.focusSettingsTab).toHaveBeenCalledWith("account"); expect(h.setPage).toHaveBeenCalledWith("settings"); expect(screen.queryByText("channels.watched.empty")).toBeNull(); }); it("says when more channels may still join, and when there is nothing to suggest", async () => { mount({ imported: true, pending: 3, channels: [] }); expect(await screen.findByText("channels.watched.empty")).toBeTruthy(); expect(screen.getByRole("status").textContent).toBe("channels.watched.pending:3"); }); it("shows the top twenty, the most watched first, and twenty more on asking", async () => { mount({ imported: true, pending: 0, channels: Array.from({ length: 27 }, (_, i) => channel(i)), }); await screen.findByText("Channel 0"); expect(screen.getByText("Channel 19")).toBeTruthy(); expect(screen.queryByText("Channel 20")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "channels.watched.more:7" })); expect(screen.getByText("Channel 26")).toBeTruthy(); expect(screen.queryByRole("button", { name: /channels\.watched\.more/ })).toBeNull(); }); it("takes a channel off the list with an Undo that puts it back", async () => { const dismiss = vi.spyOn(api, "dismissWatchedChannel").mockResolvedValue({ ok: true }); const restore = vi.spyOn(api, "restoreWatchedChannel").mockResolvedValue({ ok: true }); mount({ imported: true, pending: 0, channels: [channel(1)] }); fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" })); await waitFor(() => expect(h.notify).toHaveBeenCalled()); expect(dismiss).toHaveBeenCalledWith("UC1"); const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } }; expect(toast.action.label).toBe("common.undo"); act(() => toast.action.onClick()); await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1")); }); it("opens a row's watched videos from its count, plays one and plays them all", async () => { const videos = vi .spyOn(api, "watchedChannelVideos") .mockResolvedValue({ ok: true, items: [video("a"), video("b"), video("c")], gone: 2 }); mount({ imported: true, pending: 0, channels: [channel(1)] }); fireEvent.click(await screen.findByRole("button", { name: "29" })); const dialog = await screen.findByRole("dialog"); await within(dialog).findByText("Video b"); expect(videos).toHaveBeenCalledWith("UC1"); expect(dialog.textContent).toContain("channels.watched.videos.gone:2"); fireEvent.click(within(dialog).getByRole("button", { name: "card.play — Video b" })); await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("b", ["a", "b", "c"], 1)); fireEvent.click(within(dialog).getByRole("button", { name: "playlists.playAll" })); await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0)); }); it("asks for a channel's videos once per opening, whatever the list does meanwhile", async () => { // The ask explores the channel and may spend quota: the list's own refetch (an Undo, a follow) // must not repeat it under an open dialog, nor swap the queue of a video playing. const videos = vi .spyOn(api, "watchedChannelVideos") .mockResolvedValue({ ok: true, items: [video("a")], gone: 0 }); const qc = mount({ imported: true, pending: 0, channels: [channel(1)] }); fireEvent.click(await screen.findByRole("button", { name: "29" })); await within(await screen.findByRole("dialog")).findByText("Video a"); await act(() => qc.invalidateQueries({ queryKey: qk.watchedChannels() })); await waitFor(() => expect(api.watchedChannels).toHaveBeenCalledTimes(2)); expect(videos).toHaveBeenCalledTimes(1); }); it("follows a channel from its videos after the confirm, and closes", async () => { vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({ ok: true, items: [video("a")], gone: 0, }); const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true }); mount({ imported: true, pending: 0, channels: [channel(1)] }); fireEvent.click(await screen.findByRole("button", { name: "29" })); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" })); await waitFor(() => expect(subscribe).toHaveBeenCalledWith("UC1")); expect(h.confirm).toHaveBeenCalled(); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("follows nothing and stays open when the confirm is declined", async () => { vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({ ok: true, items: [video("a")], gone: 0, }); const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true }); h.confirm.mockImplementation(async () => false); mount({ imported: true, pending: 0, channels: [channel(1)] }); fireEvent.click(await screen.findByRole("button", { name: "29" })); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" })); await waitFor(() => expect(h.confirm).toHaveBeenCalled()); expect(subscribe).not.toHaveBeenCalled(); expect(screen.getByRole("dialog")).toBeTruthy(); }); });