Files
siftlode/frontend/src/components/comfortPin.dom.test.ts
T
peter ccd5f3739a feat(e24): pin to the comfort shelf from every card and row
S3 wires the shelf's editor. The gesture is the E10 right-click menu, so the
decision "this is comfort media" can be made where the thing is — the feed, a
Plex grid, a channel row, a playlist — rather than only on the page that lists
the shelf.

One shared piece, `components/comfortPin.tsx`: the five pin-kind constructors,
the shelf query, and ONE `ActionMenuItem` that switches Pin/Unpin honestly (and
says "not known yet" with a disabled row until the shelf has answered, since a
confident "Pin" over something already pinned is exactly the quiet lie the house
rules forbid). Surfaces that already have a row menu compose the item in
(`Playlists`, `PlaylistsRail`); the rest mount `ComfortPinMenu`.

Two structural notes:

- The feed's menu lives in `VirtualFeed`, not in `Feed` — that is the list, and
  it is also what gives the channel page the gesture for free, since it renders
  `<Feed channelScoped>` all the way down.
- Cards take `menuProps` as a FUNCTION of the row, not the built props object.
  `triggerProps` is stable across renders while its return value is not, and a
  fresh object prop would blow past `VideoCard`'s memo for every card in the
  virtualized feed on every parent render.

`DataTable` and `ChannelLayoutGrid` grow a generic `rowProps` so a channel row
answers the same gesture in both of the manager's layouts; the table stays
domain-free and never learns what a menu is.

On Home the shelf tile's menu is the whole editor: four move rows (front / left
/ right / end, reusing the playlist menu's ordering idiom) plus the unpin, over
menu rows rather than drag & drop — dragging inside a horizontal, scrolling
Carousel fights the scroll, and menu rows are keyboard-reachable for free. The
reorder is optimistic on the cached payload and re-asks the server on failure
rather than showing an order that was never accepted.

The exhausted shelf tile (a pinned channel with nothing unwatched left) is
`aria-disabled`, not `disabled`: measured in Chromium, a disabled button still
receives contextmenu and pointerdown, but it loses focus — and with it Shift+F10,
the only keyboard route to the menu it most needs.

Verified end to end in the browser against the live account: all five pin kinds,
the reorder (persisted across a reload), the unpin, and the honest Unpin label on
a resume tile that is already on the shelf.
2026-09-06 05:07:06 +02:00

92 lines
3.4 KiB
TypeScript

// @vitest-environment jsdom
//
// jsdom only because the module graph reaches `lib/api` -> `i18n`, which reads localStorage at
// import time; nothing here touches the DOM.
// The two pure rules behind the comfort shelf (E24 S3). Both are one-liners that eight call sites
// depend on, and both are exactly the kind of mapping that rots silently: a wrong pin kind is a 404
// nobody sees until they try that one surface, and a move rule that disagrees with the menu row's
// disabled state shows an enabled row that no-ops.
import { describe, it, expect } from "vitest";
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
import type { DiscoverCard } from "../lib/api";
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
({
source: "youtube",
kind: "video",
id: "x",
title: "t",
subtitle: null,
thumb: null,
aspect: "wide",
duration_seconds: null,
position_seconds: 0,
remaining_seconds: null,
status: "new",
video: null,
plex: null,
...over,
}) as DiscoverCard;
describe("pinTargetForCard", () => {
it("pins a Plex SHOW as a show and every other Plex tile as a leaf item", () => {
// The distinction the whole shelf turns on: "put that series on" has to resolve to an episode,
// so a show may not be stored as the item it is browsed as.
expect(pinTargetForCard(card({ source: "plex", kind: "show", id: "1234" }))).toEqual({
kind: "plex_show",
ref: "1234",
});
expect(pinTargetForCard(card({ source: "plex", kind: "movie", id: "99" }))).toEqual({
kind: "plex_item",
ref: "99",
});
expect(pinTargetForCard(card({ source: "plex", kind: "episode", id: "77" }))).toEqual({
kind: "plex_item",
ref: "77",
});
});
it("maps the three YouTube kinds to their own columns", () => {
expect(pinTargetForCard(card({ kind: "video", id: "vid" }))).toEqual({
kind: "video",
ref: "vid",
});
expect(pinTargetForCard(card({ kind: "channel", id: "UC1" }))).toEqual({
kind: "channel",
ref: "UC1",
});
expect(pinTargetForCard(card({ kind: "playlist", id: "12" }))).toEqual({
kind: "playlist",
ref: "12",
});
});
});
describe("shelfMoveTarget", () => {
it("moves within the shelf", () => {
expect(shelfMoveTarget(2, "first", 4)).toBe(0);
expect(shelfMoveTarget(2, "left", 4)).toBe(1);
expect(shelfMoveTarget(2, "right", 4)).toBe(3);
expect(shelfMoveTarget(2, "last", 4)).toBe(3);
});
it("reports -1 for every move that would go nowhere", () => {
// This is what the menu reads to disable a row, so each of these is a row the user must not be
// able to press onto a no-op.
expect(shelfMoveTarget(0, "first", 4)).toBe(-1);
expect(shelfMoveTarget(0, "left", 4)).toBe(-1);
expect(shelfMoveTarget(3, "right", 4)).toBe(-1);
expect(shelfMoveTarget(3, "last", 4)).toBe(-1);
// A single-item shelf can do none of the four.
expect(shelfMoveTarget(0, "right", 1)).toBe(-1);
expect(shelfMoveTarget(0, "last", 1)).toBe(-1);
});
it("reports -1 for an index that is not on the shelf", () => {
// `findIndex` returns -1 when the shelf has already been refetched without that pin — the menu
// may still be open over the tile that was there a moment ago.
expect(shelfMoveTarget(-1, "right", 4)).toBe(-1);
expect(shelfMoveTarget(9, "left", 4)).toBe(-1);
});
});