Files
siftlode/frontend/src/components/ChannelNameLink.dom.test.tsx
T
peter f76a2afbf1 feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting
Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same
kind of thing: an action that exists somewhere else in the app and is missing where you actually
want it.

**5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and
Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS
an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL
`openChannel` pushes, with a plain left click still taking the in-app path — that path does more
than navigate, since the player must close and reclaim its own Back-to-close history entry first.
The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which
VideoCard's card link also uses, so a second such link cannot forget the middle-click arm.

**2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row
menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the
player owns what happens next, because it is the only side that knows which item is playing — its
launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list
runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every
render, so anything that dropped the launch row yanked the player off the screen mid-playback. That
was a latent edge (change the view and it happens) and the normal case for this feature. The parent
now snapshots what it launched with, and only onClose takes the player down.

**4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of
videos into the same list is the shape this gets used in. The remembered id is a per-account pref;
the row is derived from `moveTargets`, which already excludes the list you are looking at, so it
cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a
shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is
nothing to repeat: there is nothing to explain.

**7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a
new `order=` param, and this is the decision worth recording: the owner's case is "even one page of
results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what
"sort these" means; and the zero-quota scrape source has no order parameter at all, so a
server-side control would silently do nothing (or quietly cost 100 quota units) depending on which
source served the page. Only keys a search hit carries are offered — `subscribers`/`priority`
describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its
own pref, so sorting a search page never re-sorts the library behind it.

Verified in the browser on localdev against the real account: the channel name renders as
`<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click
still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and
closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that
is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The
pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were
driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards.

22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the
query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click
no longer in-app · always jumping to the top of the list · the removed item left in the queue · no
clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse
flipping ties · unknown values first · titles compared numerically · shuffle offered on search
results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of
its own: its rule is enforced by construction, not by a check.
2026-09-10 22:34:12 +02:00

74 lines
3.2 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";
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 }],
])("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");
});
});