Files
siftlode/frontend/src/components/Home.pin.dom.test.tsx
T
peter 3709b71a94 fix(e24): the review's low findings — dead constants, honest labels, a 500 that should be a 404
`add_pin`'s IntegrityError handler assumed the unique constraint was the only way in and
re-raised otherwise, so a target row pruned between `resolve_ref` confirming it and the
INSERT (a Plex sync dropping a removed item) tripped the FK and handed the user a 500.
Semantically that is the same "no such thing" the pre-check already returns. Untested: the
race needs two concurrent transactions.

The five per-kind unique constraints had no test at all — the route's own dedupe returns
before reaching the DB, and the migration drift check filters constraint diffs out as noise,
so they could go missing with every lane green.

Frontend, all from the same review: the shelf's unpin row is now `danger` like every other
destructive row in the app; the hero's artwork button says what it will actually do and
names the item instead of always announcing "Play"; the empty-budget message offers a reroll
rather than `StateMessage`'s "Retry", which promised a re-run of something that had not
failed; `setState` and the watch-later toggle catch their rejections instead of leaving an
unhandled promise and a click that silently does nothing.

`StateMessage` gained a `compact` rung, which retires the frontend's only two `!important`
overrides — they existed to beat the primitive's own padding from the call site, and were
the copyable idiom for the next surface that wanted a compact empty state.

Dead vocabulary removed or made load-bearing: `DISCOVER_BUDGETS` was exported, re-exported
and consumed by nothing while `Home.tsx` re-typed the same four bands (a `readonly
DiscoverBudget[]` accepts any subset, so a fifth band would have typechecked green and
rendered no chip); `PIN_KINDS`' value export had no consumer at all; `useComfortPin` no
longer publishes `pinIdOf`/`toggle`, which nothing outside the module calls.

Three comments corrected against the code they describe: a pointer to a `resolve.py` that
was never written, the file header's claim that Home defines no card component of its own,
and the `seen` window still quoting the 8 that was measured and abandoned for 32.

The rest of the review's survivors are logged on ROADMAP.md under "Deferred — E24 epic-end
deep-review (2026-09-08)" — the ones whose dissent was substantive, and the ones whose fix
is wider than the finding.
2026-09-08 03:23:59 +02:00

270 lines
10 KiB
TypeScript

// @vitest-environment jsdom
//
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
// sibling test's mocks would leak into.
//
// What is asserted here is what tsc cannot see: that the menu the user gets over a SHELF tile is a
// different menu from the one over a resume tile, that a move row is disabled exactly when the move
// would go nowhere, and that a move writes the shelf's WHOLE new order rather than the pair it
// swapped.
//
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, wrap in a FloatingTree,
// and match menu items by text with `{ hidden: true }` (floating-ui never positions in jsdom, so the
// surface stays visibility:hidden and its contents have no accessible name).
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "en" },
}),
};
});
const discover = vi.fn();
const comfortPins = vi.fn();
const addComfortPin = vi.fn();
const removeComfortPin = vi.fn();
const reorderComfortPins = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: () => new Promise(() => {}),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
comfortPins: (...a: unknown[]) => comfortPins(...a),
addComfortPin: (...a: unknown[]) => addComfortPin(...a),
removeComfortPin: (...a: unknown[]) => removeComfortPin(...a),
reorderComfortPins: (...a: unknown[]) => reorderComfortPins(...a),
},
HttpError: class extends Error {},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
const video = (id: string, title: string) => ({
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
});
const base = (id: string, title: string) => ({
source: "youtube",
kind: "video",
id,
title,
subtitle: null,
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 0,
remaining_seconds: 600,
status: "new",
plex: null,
video: video(id, title),
});
/** A pinned CHANNEL — the shelf tile with no native card, and the one that is easiest to address by
* its title in a test (the video tiles render a whole VideoCard). */
const channelPin = (id: string, title: string, pinId: number) => ({
...base(id, title),
kind: "channel",
video: null,
unwatched_count: 3,
pin_id: pinId,
next: base(`${id}-next`, `${title} next`),
});
const shelf = [
channelPin("UCa", "Comfort A", 3),
channelPin("UCb", "Comfort B", 7),
channelPin("UCc", "Comfort C", 9),
];
const payload = () => ({
budget: "any",
hero: null,
continue_watching: [base("v2", "Resume thing")],
comfort: shelf,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<FloatingTree>
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>
</FloatingTree>,
);
}
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found as HTMLButtonElement;
};
beforeEach(() => {
discover.mockReset().mockResolvedValue(payload());
comfortPins.mockReset().mockResolvedValue({ items: shelf });
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
removeComfortPin.mockReset().mockResolvedValue({ deleted: 1 });
reorderComfortPins.mockReset().mockResolvedValue({ ok: true });
});
afterEach(cleanup);
describe("Home — the comfort shelf's context menu", () => {
it("offers the four moves and the unpin over a SHELF tile", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort B"));
expect(labels()).toEqual([
"home.hero.play",
"home.shelf.moveFirst",
"home.shelf.moveLeft",
"home.shelf.moveRight",
"home.shelf.moveLast",
"home.pin.remove",
]);
});
it("disables exactly the moves that would go nowhere", async () => {
mount();
// The first tile can go right, but not left or to the front it is already at.
fireEvent.contextMenu(await screen.findByText("Comfort A"));
expect(item("home.shelf.moveFirst").disabled).toBe(true);
expect(item("home.shelf.moveLeft").disabled).toBe(true);
expect(item("home.shelf.moveRight").disabled).toBe(false);
expect(item("home.shelf.moveLast").disabled).toBe(false);
});
it("writes the WHOLE new order when a tile moves", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
// Not the swapped pair: the endpoint takes the shelf's complete order, and a short list would be
// the server reordering half the shelf.
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
});
it("moves the tile under the pointer, not only in the request", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort A"));
// Hold the refetch that follows the write: it would answer with this mock's ORIGINAL order,
// which is not what is under test. The claim here is that the tile moves under the pointer,
// before any round trip — `keepPreviousData` keeps the patched cache on screen meanwhile.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalled());
// The RENDERED order, not just the payload sent: drop the optimistic cache write and the request
// is still perfectly right while the shelf visibly does not move, so the user clicks again.
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
"Comfort B",
"Comfort A",
"Comfort C",
]);
});
it("re-asks the server when a move fails instead of restoring a snapshot", async () => {
// Writing a captured `before` back was wrong twice: two overlapping moves each reverted to their
// own snapshot and the shelf settled on an order neither side held, and `before` is the WHOLE
// payload, so the rollback also discarded any rail refresh that landed mid-flight.
reorderComfortPins.mockRejectedValue(new Error("nope"));
mount();
await screen.findByText("Comfort A");
expect(discover).toHaveBeenCalledTimes(1);
fireEvent.contextMenu(screen.getByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
});
it("unpins with the pin id the shelf card carries", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort C"));
fireEvent.click(item("home.pin.remove"));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(9));
});
it("offers the plain PIN over a resume tile, and never a move", async () => {
// The rail below the shelf is not orderable, so its menu must not grow rows that would reorder
// something the tile is not part of.
mount();
await screen.findByText("Comfort A");
fireEvent.contextMenu(screen.getByText("Resume thing"));
await waitFor(() => expect(labels()).toContain("home.pin.add"));
expect(labels().some((l) => l?.startsWith("home.shelf.move"))).toBe(false);
fireEvent.click(item("home.pin.add"));
await waitFor(() => expect(addComfortPin).toHaveBeenCalledWith("video", "v2"));
});
it("still opens the menu over a shelf tile that has nothing left to play", async () => {
// The tile with nothing left to play is the one you most want to take OFF the shelf, so its
// menu still has to open and its Play row still has to be honest about being dead.
// NOTE what this does NOT prove: jsdom delivers `contextmenu` to a real `<button disabled>`
// too, so swapping the tile's `aria-disabled` back to `disabled` keeps this test green. That
// choice was settled in a real browser instead (see the tile's own comment) — the difference
// is focus, which jsdom's event dispatch never exercises.
discover.mockResolvedValue({
...payload(),
comfort: [{ ...channelPin("UCd", "Nothing left", 11), next: null }],
});
mount();
fireEvent.contextMenu(await screen.findByText("Nothing left"));
expect(item("home.hero.play").disabled).toBe(true);
fireEvent.click(item("home.pin.remove"));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
});
it("says so rather than guessing while the shelf is still loading", async () => {
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
// trustworthy once the shelf has answered.
comfortPins.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Comfort A");
fireEvent.contextMenu(screen.getByText("Resume thing"));
expect(item("home.pin.add").disabled).toBe(true);
});
});