feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)

The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
This commit is contained in:
2026-09-29 02:12:32 +02:00
parent 13d95ef70e
commit 632e39d458
37 changed files with 673 additions and 203 deletions
+27 -6
View File
@@ -9,6 +9,7 @@
// identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { setLanguage } from "../i18n";
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
@@ -461,7 +462,7 @@ describe("Home", () => {
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en"));
// A press that RESOLVES with nothing left: the honest empty state.
discoverPick.mockResolvedValueOnce({ budget: "short", hero: null });
@@ -509,7 +510,7 @@ describe("Home", () => {
// A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short"));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en"));
// Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy();
});
@@ -523,7 +524,7 @@ describe("Home", () => {
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1, "en"),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
@@ -584,7 +585,7 @@ describe("Home", () => {
mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discover).toHaveBeenLastCalledWith("short", 0);
expect(discover).toHaveBeenLastCalledWith("short", 0, "en");
// The very same question — seed, the cards already seen and the press count all came back, not
// just the budget: the server answers each of them differently.
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked));
@@ -613,7 +614,7 @@ describe("Home", () => {
// …and on the press count the server reads as "which tier answers first": 2 leads with the
// SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1)
// led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19).
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2);
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2, "en");
});
// The rotation carries on from the requested tier: the page's own button after a palette surprise
@@ -753,7 +754,13 @@ describe("Home", () => {
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
expect(discoverPick).toHaveBeenLastCalledWith(
"any",
expect.any(Number),
["youtube:v1"],
1,
"en",
),
);
await screen.findByText("Rerolled");
fireEvent.click(screen.getByText("home.hero.reroll"));
@@ -767,6 +774,7 @@ describe("Home", () => {
// reinstate the measured nine-card cycle) left the whole suite green.
["youtube:v1", "youtube:v9"],
2,
"en",
),
);
});
@@ -872,3 +880,16 @@ describe("Home's hero text", () => {
expect(text.querySelector("p.measure")).toBeNull();
});
});
describe("Home · the reader's language (E30)", () => {
it("asks for the page in Hungarian, so a Plex card comes back with its Hungarian title", async () => {
setLanguage("hu");
try {
mount();
await waitFor(() => expect(discover).toHaveBeenCalled());
expect(discover.mock.calls[0]?.[2]).toBe("hu");
} finally {
setLanguage("en");
}
});
});