Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed. - VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was written for (req had already reported the error); it now always shows. The cut list holds still while Create queues, and a retry continues the "(i/N)" series. - ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on hover. - listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot. - AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus back); the dead panel-ref check is gone. - AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames the message for an unreachable server. Pager comment points at scrollParent.
100 lines
3.8 KiB
TypeScript
100 lines
3.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
|
|
// button (round 3: the same row, missed) while their request runs. A
|
|
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
|
|
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
|
// still pressed only once.
|
|
import type { ReactNode } from "react";
|
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
}));
|
|
vi.mock("./NavigationProvider", () => ({
|
|
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
|
}));
|
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
|
vi.mock("./FeedFiltersProvider", () => ({
|
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
|
}));
|
|
vi.mock("./Feed", () => ({ default: () => null }));
|
|
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
|
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
|
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
import ChannelPage from "./ChannelPage";
|
|
import { api, type ChannelDetail, type Me } from "../lib/api";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
|
|
|
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
|
it.each([
|
|
["channel.block", "blockChannel", true],
|
|
["channel.hide", "updateChannel", true],
|
|
["channel.subscribe", "subscribeChannel", false],
|
|
["channel.subscribedState", "unsubscribeChannel", true],
|
|
] as const)(
|
|
"%s stays focusable and fires once while its request runs",
|
|
async (label, call, subscribed) => {
|
|
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
|
id: CH,
|
|
title: "LEMMiNO",
|
|
subscribed,
|
|
blocked: false,
|
|
hidden: false,
|
|
deep_requested: false,
|
|
backfill_done: true,
|
|
stored_videos: 10,
|
|
} as unknown as ChannelDetail);
|
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
|
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
|
render(
|
|
<QueryClientProvider
|
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
>
|
|
<ChannelPage
|
|
channelId={CH}
|
|
initialName="LEMMiNO"
|
|
me={{ id: 1, can_write: true } as unknown as Me}
|
|
view="cards"
|
|
setView={() => {}}
|
|
onShowInFeed={() => {}}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
const button = await screen.findByRole("button", { name: label });
|
|
act(() => button.focus());
|
|
fireEvent.click(button);
|
|
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
|
expect(button.hasAttribute("disabled")).toBe(false);
|
|
expect(document.activeElement).toBe(button);
|
|
fireEvent.click(button);
|
|
expect(pending).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
});
|