Files
siftlode/frontend/src/components/ui/Toaster.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

211 lines
9.5 KiB
TypeScript

// @vitest-environment jsdom
//
// Which Toaster shows the toasts. The app's own sits in the content column, which is a stacking
// context — so a full-screen player portalled to <body> at the same z painted over every toast: a
// failed Remove raised while watching was drawn under the player's scrim, and clicking it closed
// the player. An overlay mounts its own host, and exactly one host shows the toasts.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => {
cleanup();
act(() => clearAll());
});
const hosts = () => Array.from(document.querySelectorAll("[aria-live]"));
describe("Toaster — one host shows the toasts", () => {
it("moves the toasts into an open overlay, and back when it closes", () => {
const app = render(
<div data-testid="app">
<Toaster />
</div>,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render(
<div data-testid="overlay">
<Toaster overlay />
</div>,
);
expect(screen.getByTestId("overlay").textContent).toContain("Couldn't remove the video.");
expect(screen.getByTestId("app").textContent).not.toContain("Couldn't remove the video.");
// Both live regions stay mounted — a polite region has to pre-exist to be announced.
expect(hosts()).toHaveLength(2);
// The taking-over host arms itself a task later (see the "inherits" test below).
expect(hosts().map((h) => h.getAttribute("aria-live"))).toEqual(["off", "off"]);
overlay.unmount();
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
app.unmount();
});
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" });
render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's
// clicks — and the two things that ARE there must still take them.
expect(region.className).toContain("pointer-events-none");
const card = screen.getByText("hello").closest(".toast-card")!;
expect(card.className).toContain("pointer-events-auto");
const dock = screen.getByText("dock").parentElement!;
expect(dock.className).toContain("pointer-events-auto");
expect(dock.closest("[data-toast-region]")).toBe(region);
stop();
});
it("does not let a click on a toast reach the overlay's backdrop", () => {
const backdrop = vi.fn();
render(
<div onClick={backdrop}>
<Toaster overlay />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled();
});
});
describe("Toaster — handing the toasts to another host", () => {
it("does not announce the toasts it inherits again, only the ones that come after", () => {
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
// assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" }));
render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull();
act(() => void notify({ level: "error", message: "fresh" }));
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
});
it("takes the inherited set ONCE, at the takeover: a toast of its own stays an alert", () => {
// The handover is a snapshot made when the host becomes top, not a read of what the top
// host last showed — re-read on every render, this host's own "first" turned silent the moment
// "second" arrived (its own screen had become the handover by then).
render(<Toaster />);
act(() => void notify({ level: "error", message: "first" }));
act(() => void notify({ level: "error", message: "second" }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").getAttribute("role")).toBe("alert");
});
it("keeps its live region off while the inherited toasts go in, and arms it a task later", async () => {
// The other half of "silently": a region that is already polite when the inherited nodes are
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
// arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" }));
const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over…
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
expect(region.getAttribute("aria-live")).toBe("polite"); // and polite for what comes next
});
it("announces a toast RAISED in the same commit as the takeover, and still not the inherited ones", () => {
// A dialog that notifies and closes in ONE handler (UploadDialog: "added to the queue" + close)
// puts its toast into the very batch that hands the app host the top spot. Classifying it from
// the store live at the takeover render counted it among the inherited, so it went in with no
// role into a region that was still `aria-live="off"` — announced by neither path.
function Stage() {
const [open, setOpen] = useState(true);
return (
<>
<Toaster />
{open && (
<>
<Toaster overlay />
<button
onClick={() => {
notify({ level: "success", message: "upload queued" });
setOpen(false);
}}
>
upload
</button>
</>
)}
</>
);
}
render(<Stage />);
act(() => void notify({ level: "error", message: "old news" }));
fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("upload queued").getAttribute("role")).toBe("status");
expect(card("old news").getAttribute("role")).toBeNull();
});
it("ranks hosts by when their OVERLAY opened, not by when the host mounted", async () => {
// PlayerModal's host moves into the fullscreen stage and back: remounted, it used to jump over a
// dialog opened above the player, and the toasts went back under that dialog's scrim.
const player = render(
<div data-testid="player">
<Toaster overlay order={1} />
</div>,
);
render(
<div data-testid="dialog">
<Toaster overlay order={2} />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender(
<div data-testid="player">
<span />
<Toaster overlay order={1} />
</div>,
);
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
expect(screen.getByTestId("dialog").textContent).toContain("boom");
expect(screen.getByTestId("player").textContent).not.toContain("boom");
});
it("keeps the countdown where it was, instead of starting it again from full", () => {
// The bar is a CSS animation on the node; the dismiss timer was armed once, at notify time. A
// new node restarted the bar, and the toast vanished with it still two-thirds full.
const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" }));
now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
expect(bar.style.animation).toContain("-4000ms");
});
it("keeps the players' control band free inside an overlay", () => {
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" }));
const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none");
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
"pointer-events-auto",
);
});
});