Files
siftlode/frontend/src/modules/channels/WatchedChannels.dom.test.tsx
T
peter 33dd9ff054 fix(reader): the watched-channels list keeps its channels, its shares and its retries
- 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.
2026-10-24 01:10:29 +02:00

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();
});
});