- A channel someone follows: the videos its exploration brings in (the list's dialog, the channel page's explore) come in as the catalog's own, not flagged via_explore, which subscribing would never clear and which kept their watched marks waiting for good. - The explore cleanup keeps the row of a channel a Takeout history shows watched (it still reclaims the videos), so a looked-at channel no longer drops off the list. - The Shorts job takes up only users whose list still has lengths to ask or channels to name, by each user's own follows, blocks and dismissals; a naming that failed is tried again, and the list says it is not complete. - The length lookups have a daily share of their own: a large history no longer leaves the export's Watch later file waiting a day. - Only a channel of the user's own history can be put out of the list. - The dialog's videos have a query root of their own: the list's invalidations no longer re-ask (and re-explore) under an open dialog.
211 lines
8.8 KiB
TypeScript
211 lines
8.8 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
i18n: { language: "en" },
|
|
t: (key: string, o?: Record<string, unknown>) =>
|
|
o && "count" in o ? `${key}:${String(o.count)}` : key,
|
|
}),
|
|
};
|
|
});
|
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
|
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 <div data-testid="player" />;
|
|
},
|
|
}));
|
|
|
|
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> = {}): 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(
|
|
<QueryClientProvider client={qc}>
|
|
<WatchedChannels canWrite search="" onOpenWizard={() => {}} onViewChannel={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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();
|
|
});
|
|
});
|