// @vitest-environment jsdom // // A subscribed channel's ONE action list: what the manager row's "…" (and its right-click, // long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history, // hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and // Priority submenus. import { describe, it, expect, vi, afterEach } from "vitest"; import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true), role: "user", canWrite: true, })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), }; }); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify: (...a: unknown[]) => h.notify(...a), })); vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) })); import { useChannelActions } from "./useChannelActions"; import { api, type ManagedChannel, type Tag } from "../lib/api"; import type { ActionMenuItem } from "./ui/ActionMenu"; afterEach(() => { cleanup(); vi.restoreAllMocks(); h.notify.mockReset(); h.confirm.mockReset(); h.confirm.mockImplementation(async () => true); h.role = "user"; h.canWrite = true; tags = []; tagsState = "ready"; onToggleTag.mockReset(); onPickPriority.mockReset(); }); const ch = (over: Partial = {}): ManagedChannel => ({ id: "UC1", title: "Chan", handle: "chan", thumbnail_url: null, subscriber_count: null, video_count: null, description: null, stored_videos: 0, last_video_at: null, total_duration_seconds: 0, priority: 0, hidden: false, deep_requested: false, deep_in_queue: false, tag_ids: [], details_synced: false, recent_synced: false, backfill_done: false, ...over, }); const onView = vi.fn(); const onToggleTag = vi.fn(); const onPickPriority = vi.fn(); let tags: Tag[] = []; let tagsState: "loading" | "error" | "ready" = "ready"; const tag = (id: number, name: string) => ({ id, name }) as Tag; function mount() { const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); return renderHook( () => useChannelActions({ onView, onOpenWizard: () => {}, tags, tagsState, onToggleTag, onPickPriority, }), { wrapper }, ); } const pin = [{ id: "pin", label: "pin", onSelect: () => {} }]; const ids = (items: { id: string }[]) => items.map((i) => i.id); const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin }); const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!; describe("useChannelActions — the menu", () => { it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => { const items = rowsFor(ch()); expect(ids(items)).toEqual([ "open", "pin", "tags", "priority", "open-youtube", "copy-text", "copy-link", "backfill", "hide", "unsubscribe", ]); // Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone. expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([ "tags", "open-youtube", "backfill", "unsubscribe", ]); expect(items.find((i) => i.id === "unsubscribe")).toMatchObject({ danger: true }); expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name"); }); it("has no unsubscribe without the YouTube write scope", () => { h.canWrite = false; expect(ids(rowsFor(ch()))).not.toContain("unsubscribe"); }); it("opens the channel page, and YouTube in a new tab inside the click", () => { const open = vi.spyOn(window, "open").mockReturnValue(null); row(ch(), "open").onSelect!(); expect(onView).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" })); const yt = row(ch(), "open-youtube"); expect("inGesture" in yt && yt.inGesture).toBe(true); yt.onSelect!(); expect(open).toHaveBeenCalledWith( "https://www.youtube.com/@chan", "_blank", "noopener,noreferrer", ); }); }); describe("useChannelActions — full history", () => { it("asks for it, and takes a pending ask back", async () => { const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never); const ask = row(ch(), "backfill"); expect(ask.label).toBe("channels.row.backfillThis"); act(() => ask.onSelect!()); await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: true })); const cancel = row(ch({ deep_requested: true }), "backfill"); expect(cancel.label).toBe("channels.row.cancelBackfill"); act(() => cancel.onSelect!()); await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: false })); }); it("stays in place, off, with the reason, once it is in or on its way for everyone", () => { expect(row(ch({ backfill_done: true }), "backfill")).toMatchObject({ disabled: true, note: "channels.row.fullHint", }); expect(row(ch({ deep_in_queue: true }), "backfill")).toMatchObject({ disabled: true, note: "channels.row.queuedByOtherHint", }); }); it("gives an admin the reset, behind a confirm that names the reset", async () => { h.role = "admin"; const reset = vi.spyOn(api, "resetChannelBackfill").mockResolvedValue({} as never); const it_ = row(ch({ backfill_done: true }), "backfill"); expect(it_).toMatchObject({ label: "channels.row.resetBackfill" }); expect(it_.disabled).toBeFalsy(); await act(async () => it_.onSelect!()); expect(h.confirm).toHaveBeenCalledWith( expect.objectContaining({ title: "channels.row.resetBackfill", confirmLabel: "channels.row.resetBackfill", }), ); await waitFor(() => expect(reset).toHaveBeenCalledWith("UC1")); }); }); describe("useChannelActions — hide and unsubscribe", () => { it("hides without a confirm, and the toast's Undo shows the channel again", async () => { const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never); const { result } = mount(); act(() => result.current.items(ch(), { pin }).find((i) => i.id === "hide")!.onSelect!()); await waitFor(() => expect(h.notify).toHaveBeenCalled()); expect(h.confirm).not.toHaveBeenCalled(); expect(upd).toHaveBeenCalledWith("UC1", { hidden: true }); const toast = h.notify.mock.calls[0]![0] as { message: string; action: { onClick: () => void }; }; expect(toast.message).toBe("channels.notify.hidden"); act(() => toast.action.onClick()); await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false })); }); it("unsubscribes only after the danger confirm says yes", async () => { const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never); const { result } = mount(); const unsubRow = () => result.current.items(ch(), { pin }).find((i) => i.id === "unsubscribe")!; h.confirm.mockImplementation(async () => false); await act(async () => unsubRow().onSelect!()); expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); expect(unsub).not.toHaveBeenCalled(); h.confirm.mockImplementation(async () => true); await act(async () => unsubRow().onSelect!()); await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1")); }); }); const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[]; describe("useChannelActions — the Tags submenu", () => { it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => { tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")]; const items = sub(row(ch({ tag_ids: [2] }), "tags")); expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]); expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([ { kind: "many", checked: false }, { kind: "many", checked: true }, { kind: "many", checked: false }, ]); items[1]!.onSelect!(); expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2); }); // A failed /tags read said "no tags yet" — an error dressed as an empty list. it("says the tags are loading, or failed to load, instead of 'no tags yet'", () => { tagsState = "loading"; expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loading" }); tagsState = "error"; expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loadError" }); }); it("stays in place, off, saying where tags are made, with no tag yet", () => { expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" }); expect(row(ch(), "tags").items).toBeUndefined(); }); }); describe("useChannelActions — the Priority submenu", () => { it("offers the fixed steps with the current one ticked, and picks a new one", () => { const items = sub(row(ch({ priority: 3 }), "priority")); expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]); expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([ "3", ]); expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true); items[0]!.onSelect!(); expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10); }); it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => { const items = sub(row(ch({ priority: 7 }), "priority")); expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]); items[1]!.onSelect!(); expect(onPickPriority).not.toHaveBeenCalled(); }); });