feat(plex): the person page in two columns; a bio on its way holds its place (E30 S6)

The biography sat at reading width in the left half of a full-width panel with nothing on the right.
Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the
left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width.
One column where the panel is narrow (200% zoom, the largest text size).

While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of
LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so
another person starts fresh.
This commit is contained in:
2026-09-30 02:19:54 +02:00
parent 705377c797
commit 63d01babe5
6 changed files with 244 additions and 80 deletions
@@ -186,3 +186,68 @@ describe("PlexPersonView — the reader's language (E30)", () => {
}
});
});
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
const view = (
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>
);
const person = (over: object) => ({
name: "Jane Doe",
photo: null,
also_known_as: [],
known_for: [],
movies: [card("m1", "Movie One")],
shows: [],
...over,
});
it("holds the bio's place and asks again until it lands", async () => {
plexPerson.mockReset();
plexPerson
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
.mockResolvedValue(person({ biography: "Landed bio." }));
wrap(view);
await screen.findByText("Movie One");
expect(screen.getByRole("status").textContent).toContain("plex.person.bioLoading");
await screen.findByText("Landed bio.", undefined, { timeout: 3000 });
expect(screen.queryByRole("status")).toBeNull();
expect(plexPerson).toHaveBeenCalledTimes(2);
});
it("does not ask again for a person whose bio is not coming", async () => {
plexPerson.mockReset();
plexPerson.mockResolvedValue(person({ biography: null, enriching: false }));
wrap(view);
await screen.findByText("Movie One");
await new Promise((r) => setTimeout(r, 1800));
expect(plexPerson).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("status")).toBeNull();
});
it("offers More only when the bio is cut, and Less once open", async () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
try {
plexPerson.mockReset();
plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." }));
wrap(view);
fireEvent.click(await screen.findByText("plex.person.more"));
expect(screen.getByText("plex.person.less").getAttribute("aria-expanded")).toBe("true");
} finally {
tall.mockRestore();
box.mockRestore();
}
cleanup();
plexPerson.mockResolvedValue(person({ biography: "Short." }));
wrap(view);
await screen.findByText("Short.");
expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut
});
});