The banner is retried on error; each failed attempt showed the browser's broken-image glyph for as long as the retry waited. The <img> now stays invisible until its source has actually loaded.
150 lines
5.7 KiB
TypeScript
150 lines
5.7 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, onTestFinished } 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("./navigationContext", () => ({
|
|
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
|
}));
|
|
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
|
vi.mock("./feedFiltersContext", () => ({
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
|
|
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
|
|
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
|
|
// invisible until a source has actually loaded.
|
|
describe("ChannelPage — the banner", () => {
|
|
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
onTestFinished(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
|
id: CH,
|
|
title: "LEMMiNO",
|
|
subscribed: true,
|
|
blocked: false,
|
|
hidden: false,
|
|
deep_requested: false,
|
|
backfill_done: true,
|
|
stored_videos: 10,
|
|
banner_url: "https://yt3.example/banner",
|
|
} as unknown as ChannelDetail);
|
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
|
const { container } = 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 banner = () =>
|
|
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
|
|
await waitFor(() => expect(banner()).toBeTruthy());
|
|
expect(banner().classList.contains("opacity-0")).toBe(true);
|
|
fireEvent.error(banner());
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
});
|
|
expect(banner().getAttribute("src")).toContain("?r=1");
|
|
expect(banner().classList.contains("opacity-0")).toBe(true);
|
|
fireEvent.load(banner());
|
|
expect(banner().classList.contains("opacity-0")).toBe(false);
|
|
});
|
|
});
|