Files
siftlode/frontend/src/lib/history.navPosition.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

260 lines
10 KiB
TypeScript

// @vitest-environment jsdom
//
// Back hands focus to the list item a view was left from: opening a Plex title from Home's
// rail switches the page, which unmounts the rail, so focus used to come back to <body>. The position is
// stamped onto the entry being left (`rememberNavPosition`) and a popstate landing on it focuses the item
// once the page's lists are back. A landing is simulated the way the browser delivers one: the entry's
// state is current and a popstate carries it. Its own file: history.ts and listNav keep module state.
import { useEffect, useRef, useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { rememberNavPosition } from "./history";
import { navItemAttrs, useListNav } from "./listNav";
import { FOCUS_REST_ATTRS } from "./useFocusKeeper";
function List({ id, count, label }: { id: string; count: number; label?: string }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} data-testid={`${id}${i}`} {...navItemAttrs(i)}>
{id}
{i}
</div>
))}
</div>
);
}
const Page = () => (
<>
<List id="a" count={2} />
<List id="b" count={3} />
</>
);
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
const sleep = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
/** Walk `presses` items in, stamp the entry and open another view over it; returns the stamped state. */
function leaveFrom(item: string, presses: number): unknown {
for (let i = 0; i < presses; i++) press("j");
expect(focused()).toBe(item);
const spread = rememberNavPosition();
const stamped = window.history.state;
window.history.pushState({ ...spread, view: "other" }, "");
return stamped;
}
function landOn(state: unknown) {
window.history.replaceState(state, "");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
afterEach(() => {
// A restore that gave up waiting for its list polls on to its deadline; a push cancels it, and one
// left running fires after the file's jsdom is torn down ("document is not defined").
rememberNavPosition();
cleanup();
(document.activeElement as HTMLElement | null)?.blur?.();
window.history.replaceState(null, "");
});
describe("rememberNavPosition · Back focuses the item a view was left from", () => {
it("focuses the item once the page's lists are back, and spends the stamp", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
expect(window.history.state.sfNav).toBeUndefined();
unmount();
landOn(stamped);
await sleep(120);
render(<Page />);
await waitFor(() => expect(focused()).toBe("b1"));
expect(window.history.state.sfNav).toBeUndefined();
});
// Left from a1 (list 0 of 2). If the second list mounts first, "list 0" on screen is list b — so the
// restore must wait until the page has both lists again, not land on b1.
it("waits for every list the page had — the first list to mount is not list 0", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
const { rerender } = render(<List id="b" count={3} />);
await sleep(120);
expect(focused()).toBeUndefined();
rerender(<Page />);
await waitFor(() => expect(focused()).toBe("a1"));
});
// A sub-view (a Plex show page) switches back in its OWN popstate listener, registered after this
// module's: while ours runs, focus still sits in the view being left. Measured in Chrome:
// Discover card → show → `j` on the show page → Back left focus on <body>.
it("restores when a later popstate listener switches the view the focus was still in", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
function Views() {
const [view, setView] = useState<"sub" | "page">("sub");
useEffect(() => {
const onPop = () => setView("page");
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return view === "page" ? <Page /> : <List id="s" count={2} />;
}
render(<Views />);
press("j");
expect(focused()).toBe("s0");
landOn(stamped);
await waitFor(() => expect(focused()).toBe("b1"));
});
// A restore still waiting for the landed page's lists must not outlive the next push: it would
// focus "list 1, item 1" of whatever page that push opened.
it("is cancelled by the next push", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
landOn(stamped);
render(<List id="b" count={3} />); // one list of the two: the restore keeps waiting
await sleep(120);
window.history.pushState({ ...rememberNavPosition(), view: "elsewhere" }, "");
render(<Page />); // enough lists now, on the page the push opened
await sleep(150);
expect(focused()).toBeUndefined();
});
it("leaves focus where it is when something already has it", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
const { getByRole } = render(<input aria-label="search" />);
landOn(stamped);
getByRole("textbox").focus();
render(<Page />);
await sleep(150);
expect(document.activeElement).toBe(getByRole("textbox"));
});
});
// The page region is `<main tabIndex={-1}>` (the skip link's target), so a mouse click
// on the page's blank area focuses it. The restore took that for a control the reader had chosen and
// gave up: Back left the focus on the page instead of on the item the view was opened from.
describe("rememberNavPosition · the focus resting on the page region", () => {
it("still restores when the page region holds the focus", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
const region = render(<main tabIndex={-1} data-testid="main" {...FOCUS_REST_ATTRS} />);
region.getByTestId("main").focus();
landOn(stamped);
render(<Page />);
await waitFor(() => expect(focused()).toBe("b1"));
});
});
describe("rememberNavPosition · the list it comes back to", () => {
// Discover: a title opened from "Recently added" and watched — the page's cache expires and
// "Continue watching" fills with that very title, a rail ABOVE. Found by its place, focus landed in
// that rail, on a title never picked.
it("finds a named list by its name when a list appeared above it", async () => {
const Rails = () => (
<>
<List id="a" count={2} label="Recently added" />
<List id="b" count={3} label="Genres" />
</>
);
const { unmount } = render(<Rails />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
render(
<>
<List id="c" count={3} label="Continue watching" />
<Rails />
</>,
);
await waitFor(() => expect(focused()).toBe("a1"));
});
// Home's Continue rail leaves out whatever the hero shows: back from a card, a different hero put a
// card back in front of it, and by its place the focus landed on the neighbour (prod UAT 2026-09-19).
it("finds the item by its name when the rail's contents moved", async () => {
const Rail = ({ titles }: { titles: string[] }) => {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} aria-label="Continue watching">
{titles.map((title, i) => (
<div key={title} data-testid={title} data-nav-name={title} {...navItemAttrs(i)}>
{title}
</div>
))}
</div>
);
};
const { unmount } = render(<Rail titles={["Knight Rider", "I Am Mother", "Dawnwalker"]} />);
const stamped = leaveFrom("I Am Mother", 2);
unmount();
landOn(stamped);
render(<Rail titles={["Harry Potter", "Knight Rider", "I Am Mother", "Dawnwalker"]} />);
await waitFor(() => expect(focused()).toBe("I Am Mother"));
});
// Names repeat — a log console's `http.request GET /api/feed 200` lines. The first of them is not
// the one left: the saved place still holding the name wins, and when the list moved, the nearest.
it("among items of one name, takes the saved place or the nearest", async () => {
const Lines = ({ names }: { names: string[] }) => {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} aria-label="Lines">
{names.map((name, i) => (
<div key={i} data-testid={`l${i}`} data-nav-name={name} {...navItemAttrs(i)}>
{name}
</div>
))}
</div>
);
};
const names = ["GET 200", "POST 200", "POST 200", "GET 200", "POST 200"];
const first = render(<Lines names={names} />);
const stamped = leaveFrom("l3", 4);
first.unmount();
landOn(stamped);
const back = render(<Lines names={names} />);
await waitFor(() => expect(focused()).toBe("l3"));
back.unmount();
(document.activeElement as HTMLElement | null)?.blur?.();
landOn(stamped);
// A line arrived above: the left one is l4 now, a GET two places up is l1.
render(<Lines names={["new", ...names]} />);
await waitFor(() => expect(focused()).toBe("l4"));
});
it("gives up on an unnamed list when the page came back with another number of lists", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
render(
<>
<List id="c" count={3} />
<Page />
</>,
);
await sleep(150);
expect(focused()).toBeUndefined();
});
});