feat(channels): a "Discover from your history" tab for the channels watched but not followed
The Channel manager lists the channels the Takeout history shows watched often but not followed, the most watched first: twenty at a time, the channel's About on hover, its name to its page, Follow, and Not interested with an Undo. A row's count opens the videos it counts, ready to play one or all. Before a history is uploaded it points to the upload; while the import still asks YouTube it says more channels may join.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// @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 { 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("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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user