Deep-review R6 — the small items: - The video card's channel name was still a <button> (pre-existing): ctrl/middle-click did nothing there while the title above it and the player's channel name honoured them. It renders through ChannelNameLink now, which grew a forwarded ref (the clamp measurement), pass-through attributes, a `tooltip`, and an opt-in `stopPropagation` so no click reaches the card's row. - ChannelNameLink rendered an empty, still-tabbable <a> for a channel with no title yet (a stub first seen through a live search; pre-existing at this site). It falls back to the translated "Channel". - The feed's SortKey→SortOption mapping existed twice in Feed.tsx; one `feedSortOptions` in feedSort.ts serves both modes (DIRECTIONLESS is module-private again). - The backfill refresh's comment claimed a one-page cost. It refetches every loaded page of the grid, and that is the right price: backfilled videos are older, so they land below the head. Tests for call sites that could be deleted with every lane green: the channel page running the backfill refresh (knip had been blinded by the hook test's import), Feed handing the grid the live results in the chosen key AND direction, the card using the link. Plus the alt-click arm of the shared click predicate, and a plex filter-chip test that claimed it made no request while firing a real one. Verified on localdev: the feed's channel names are anchors (same 14 px muted look), a plain click opens the channel in-app. 7 mutations, each red on its assertion.
110 lines
5.0 KiB
TypeScript
110 lines
5.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Reported from daily use: middle-clicking the channel name in the YT player did nothing. It looked
|
|
// like a link — the YouTube icon beside it IS one — but it was a `<button>`, so the browser had no
|
|
// href to open a tab with. These pin the two halves the fix has to keep true at once: there is a real
|
|
// href pointing where the in-app navigation would go, AND a plain left click still takes the in-app
|
|
// path (which does more than navigate — the player has to close and reclaim its history entry first).
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, cleanup } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
import ChannelNameLink from "./ChannelNameLink";
|
|
|
|
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
|
|
|
beforeEach(() => {
|
|
// No global auto-cleanup in this project's vitest setup, and several cases render more than once.
|
|
cleanup();
|
|
window.history.replaceState({}, "", "/?page=feed&show=unwatched");
|
|
});
|
|
|
|
const renderLink = () => {
|
|
const onOpenInApp = vi.fn();
|
|
render(<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={onOpenInApp} />);
|
|
return { link: screen.getByText("LEMMiNO") as HTMLAnchorElement, onOpenInApp };
|
|
};
|
|
|
|
/** Dispatch a click and report whether the component prevented it (i.e. handled it in-app).
|
|
* The document-level guard runs AFTER React's handler and swallows the default, so an
|
|
* un-prevented click on a real href does not make jsdom log "Not implemented: navigation". */
|
|
const clickWasHandled = (el: HTMLElement, init: MouseEventInit = {}): boolean => {
|
|
let prevented = false;
|
|
const guard = (e: Event) => {
|
|
prevented = e.defaultPrevented;
|
|
e.preventDefault();
|
|
};
|
|
document.addEventListener("click", guard);
|
|
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
|
|
document.removeEventListener("click", guard);
|
|
return prevented;
|
|
};
|
|
|
|
describe("ChannelNameLink", () => {
|
|
it("is an anchor whose href opens this channel on the current page", () => {
|
|
const { link } = renderLink();
|
|
expect(link.tagName).toBe("A");
|
|
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
|
|
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
|
|
});
|
|
|
|
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
|
|
const { link, onOpenInApp } = renderLink();
|
|
expect(clickWasHandled(link, { button: 0 })).toBe(true);
|
|
expect(onOpenInApp).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
["middle-click", { button: 1 }], // the reported gesture
|
|
["ctrl-click", { ctrlKey: true }],
|
|
["cmd-click", { metaKey: true }],
|
|
["shift-click", { shiftKey: true }],
|
|
// Alt/Option-click on a link is "download the target" — the one arm no test dispatched, so
|
|
// dropping it from the shared predicate hijacked it on every card thumbnail too.
|
|
["alt-click", { altKey: true }],
|
|
])("leaves %s to the browser", (_name, mod) => {
|
|
const { link, onOpenInApp } = renderLink();
|
|
// Neither hijacked in-app nor prevented — the href is left to do its job.
|
|
expect(clickWasHandled(link, mod)).toBe(false);
|
|
expect(onOpenInApp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not lose the rest of the address bar", () => {
|
|
window.history.replaceState({}, "", "/?page=channels&q=abc");
|
|
const { link } = renderLink();
|
|
expect(link.getAttribute("href")).toContain("page=channels");
|
|
expect(link.getAttribute("href")).toContain("q=abc");
|
|
});
|
|
|
|
it("gives a channel with no title yet an accessible name", () => {
|
|
// A channel first seen through a live search can be a stub with no title. The anchor used to
|
|
// render empty — still in the tab order, announced by nothing but its href.
|
|
render(<ChannelNameLink channelId={CH} title={null} onOpenInApp={() => {}} />);
|
|
expect(screen.getByRole("link", { name: "player.channel" })).toBeTruthy();
|
|
});
|
|
|
|
it("keeps every click from a clickable ancestor when asked to — the browser's gestures too", () => {
|
|
// The video card's row reacts to clicks; its channel name must not trigger it, whichever way it
|
|
// is clicked.
|
|
const row = vi.fn();
|
|
render(
|
|
<div onClick={row}>
|
|
<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={() => {}} stopPropagation />
|
|
</div>,
|
|
);
|
|
const link = screen.getByText("LEMMiNO");
|
|
// The bubbling guard in clickWasHandled never sees these (that is the point), so stop the
|
|
// browser's navigation in the capture phase instead.
|
|
const noNav = (e: Event) => e.preventDefault();
|
|
document.addEventListener("click", noNav, true);
|
|
for (const init of [{ button: 0 }, { ctrlKey: true }, { button: 1 }])
|
|
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
|
|
document.removeEventListener("click", noNav, true);
|
|
expect(row).not.toHaveBeenCalled();
|
|
});
|
|
});
|