test(fe): cover the palette's content, AltGr, the banner's name, share colours, playlist headers

- AppCommandPalette had no test: modules gated by the account, hidden channels left out, the
  playlist row selecting before it navigates, and the surprise riding setPage's `then`.
- keymap: an Alt/AltGr-modified key (Ctrl+Alt on Windows, a constant on the HU layout) fires no
  bare-key or Mod row.
- Banner: the dismiss button falls back to "Close" rather than going unnamed.
- ShareDialog: a protected link's lit lock and Revoke's danger colour stay on the icon.
- Both playlist headers are rendered now: the YouTube one needed the toolbar slot it portals into.
  Meta line sums known runtimes and treats an ABSENT duration as nothing (null never exercised the
  guard: `3000 + null` is 3000); a one-row list offers no view controls.
This commit is contained in:
2026-09-19 17:23:55 +02:00
parent cedcbb1434
commit dc902d2c0e
6 changed files with 374 additions and 1 deletions
+47 -1
View File
@@ -55,6 +55,7 @@ vi.mock("./PlayerModal", async () => {
});
import Playlists from "./Playlists";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { api, HttpError, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { patchCachedProgress } from "../lib/progressCache";
@@ -106,9 +107,15 @@ function mount(lists: Record<number, Video[]>, prefs: Record<string, unknown> =
);
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
// The header portals into the shell's fixed toolbar band; without a slot it renders nothing, and
// it went untested for exactly that reason.
const slot = document.createElement("div");
document.body.appendChild(slot);
const tree = () => (
<QueryClientProvider client={qc}>
<Playlists />
<PageToolbarSlot.Provider value={slot}>
<Playlists />
</PageToolbarSlot.Provider>
</QueryClientProvider>
);
const view = render(tree());
@@ -128,6 +135,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("Playlists — the row the player is launched with", () => {
@@ -403,3 +411,41 @@ describe("Playlists — the rows are a keyboard list", () => {
await waitFor(() => expect(h.player?.video.id).toBe("B"));
});
});
// The header's meta line and its view-controls gate were typechecked and linted but never rendered,
// so a regression in either shipped green (E9 review). The count and the runtime sum the WHOLE
// list; an unknown duration counts as nothing rather than poisoning the sum; and a list of one has
// no view to change.
describe("Playlists · the header", () => {
// MISSING, not null: `3000 + null` is 3000 in JS, so only an absent field shows whether the sum
// guards an unknown duration or would turn the whole runtime into NaN.
const untimed = (id: string) => {
const { duration_seconds: _drop, ...rest } = vid(id, 0);
return rest as unknown as Video;
};
const meta = async () =>
(
await screen.findByText(
(_, el) => el?.tagName === "SPAN" && /^playlists\.itemCount/.test(el.textContent ?? ""),
)
).textContent;
it("sums what is known, and says nothing about a runtime nothing sums to", async () => {
mount({ 1: [vid("A", 0), untimed("B")] });
expect(await meta()).toBe("playlists.itemCount · 50m");
cleanup();
mount({ 1: [untimed("C")] });
expect(await meta()).toBe("playlists.itemCount");
});
it("offers the view controls only once there is more than one row to arrange", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByText("Video A");
expect(screen.queryByText("playlists.groupByChannel")).toBeNull();
cleanup();
mount({ 1: [vid("A", 0), vid("B", 0)] });
expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy();
});
});