fix(ui,i18n): E30 review round 2 — honest bio failure, a full show summary, one HU name per thing

- Person page: a bio ask that fails says so with a retry instead of reading as 'no biography'.
- Show page: the summary is a ClampedText (5 lines + More), like the person and collection pages;
  one More/Less pair (plex.textMore/textLess) for all three.
- Channels: a re-slotted row is placed in the language the server orders by (the stored
  preference, else English), not the UI's guess.
- A skipped run's body is only the reason (its title already says skipped).
- HU: Behúzási kvótatartalék; Eseménynapló ürítve / az eseménynaplót; indok; közvetlenül
  játszható / közvetlen közvetítés in the Plex hints.
- Tests: ClampedText re-measures on a resize, the show summary clamp, serverOrderLang.
This commit is contained in:
2026-10-01 08:31:04 +02:00
parent 6ac9ffd45e
commit 9454717f47
19 changed files with 140 additions and 45 deletions
+2 -1
View File
@@ -27,6 +27,7 @@ import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority";
import { placeByPriority } from "../lib/channelOrder";
import { serverOrderLang } from "../lib/saveLanguage";
import { focusNavItem } from "../lib/listNav";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ui/ColumnSortControl";
@@ -173,7 +174,7 @@ export default function Channels() {
setPriority(c.id, next);
qc.setQueryData<ManagedChannel[]>(
qk.channels(),
(old) => old && placeByPriority(old, c.id, lang),
(old) => old && placeByPriority(old, c.id, serverOrderLang(me.preferences)),
);
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
};
@@ -248,6 +248,6 @@ describe("PlexCollectionView — the summary (E30 review)", () => {
</QueryClientProvider>,
);
await screen.findByText("One short line.");
expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
});
});
@@ -336,8 +336,8 @@ export default function PlexCollectionView({
<ClampedText
text={d.summary}
lines={3}
moreLabel={t("plex.collection.more")}
lessLabel={t("plex.collection.less")}
moreLabel={t("plex.textMore")}
lessLabel={t("plex.textLess")}
className="mt-3"
/>
)}
@@ -7,7 +7,7 @@
// and the api is mocked so the query resolves synchronously in the test.
import { setLanguage } from "../i18n";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
@@ -251,7 +251,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
}
});
it("stops asking when the page's own request fails", async () => {
it("stops asking when the page's own request fails, and says so with a retry", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
plexPerson.mockReset();
@@ -265,6 +265,12 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails
expect(screen.queryByRole("status")).toBeNull();
// Not a blank that reads as "no biography": the failure is said, and a retry asks again.
const alert = screen.getByRole("alert");
expect(alert.textContent).toContain("plex.person.bioFailed");
plexPerson.mockResolvedValue(person({ biography: "Landed bio." }));
fireEvent.click(within(alert).getByRole("button"));
await screen.findByText("Landed bio.");
} finally {
vi.useRealTimers();
}
@@ -277,8 +283,8 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
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");
fireEvent.click(await screen.findByText("plex.textMore"));
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
} finally {
tall.mockRestore();
box.mockRestore();
@@ -287,7 +293,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
plexPerson.mockResolvedValue(person({ biography: "Short." }));
wrap(view);
await screen.findByText("Short.");
expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
});
});
@@ -99,9 +99,7 @@ describe("an audit row's details", () => {
target_id: "rss_poll",
after: { status: "skipped", summary: "sync paused" },
});
expect(details(skipped)).toBe(
"RSS-lekérdezés (új feltöltések) kihagyva | kihagyva: a szinkron szünetel",
);
expect(details(skipped)).toBe("RSS-lekérdezés (új feltöltések) kihagyva | a szinkron szünetel");
});
it("tell a secret's reset from its update, and name a choice", () => {
@@ -132,3 +132,28 @@ describe("the restart button on a show and a season", () => {
expect(onPlay).toHaveBeenCalledWith("e11", ALL, true);
});
});
describe("the show page's summary (E30 review)", () => {
it("is clamped with a More, so a long one can still be read in full", async () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
Object.assign(detail.show, { summary: "A long summary.\n\nThat goes on." });
try {
wrap(
<PlexShowView
showId="show"
onBack={() => {}}
onPlay={() => {}}
onOpenSeason={() => {}}
onOpenShow={() => {}}
/>,
);
fireEvent.click(await screen.findByText("plex.textMore"));
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
} finally {
tall.mockRestore();
box.mockRestore();
Object.assign(detail.show, { summary: undefined });
}
});
});
+18 -11
View File
@@ -325,17 +325,20 @@ export function PlexPersonView({
<ClampedText
text={d.biography}
lines={8}
moreLabel={t("plex.person.more")}
lessLabel={t("plex.person.less")}
moreLabel={t("plex.textMore")}
lessLabel={t("plex.textLess")}
className="mt-4"
/>
) : bioOnItsWay ? (
<LoadingState shape="text" label={t("plex.person.bioLoading")} className="mt-4" />
) : (
bioOnItsWay && (
<LoadingState
shape="text"
label={t("plex.person.bioLoading")}
className="mt-4"
/>
// An ask that failed while the bio was on its way is said, not left looking like a
// person with no bio; the retry asks (and waits) again.
d.enriching &&
q.isError && (
<StateMessage tone="error" compact onRetry={() => q.refetch()}>
{t("plex.person.bioFailed")}
</StateMessage>
)
)}
</div>
@@ -614,9 +617,13 @@ export function PlexShowView({
</div>
)}
{show.summary && (
<p className="measure mt-3 line-clamp-5 text-sm leading-relaxed text-muted">
{show.summary}
</p>
<ClampedText
text={show.summary}
lines={5}
moreLabel={t("plex.textMore")}
lessLabel={t("plex.textLess")}
className="mt-3"
/>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
@@ -3,7 +3,7 @@
// A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the
// collection page in the E30 review, where a one-line summary carried a More that did nothing).
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ClampedText } from "./ClampedText";
beforeAll(() => {
@@ -44,4 +44,33 @@ describe("ClampedText", () => {
box.mockRestore();
}
});
it("measures again when its box changes: the window or the text size moved the cut", () => {
const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something
const real = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(private cb: ResizeObserverCallback) {}
observe() {
seen.push(this.cb);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
let height = 60;
const tall = vi
.spyOn(HTMLElement.prototype, "scrollHeight", "get")
.mockImplementation(() => height);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
try {
render(view("A text that fits, until the reader raises the text size."));
expect(screen.queryByText("More")).toBeNull();
height = 400; // the text size went up: the same text now runs past three lines
act(() => seen[seen.length - 1]!([], {} as ResizeObserver));
expect(screen.getByText("More")).toBeTruthy();
} finally {
tall.mockRestore();
box.mockRestore();
globalThis.ResizeObserver = real;
}
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react";
import { Button } from "./Button";
// Static class names, so Tailwind sees them.
const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const;
const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as const;
/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a