// @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 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(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); import Toaster from "./Toaster"; import { clearAll, notify } from "../../lib/notifications"; 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(
, ); 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(
, ); 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("does not let a click on a toast reach the overlay's backdrop", () => { const backdrop = vi.fn(); render(
, ); 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(); act(() => void notify({ level: "error", message: "old news" })); render(); 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("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(); act(() => void notify({ level: "info", message: "saved" })); const overlay = render(); const region = overlay.container.firstElementChild!; 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 (round 5). function Stage() { const [open, setOpen] = useState(true); return ( <> {open && ( <> )} ); } render(); 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(
, ); render(
, ); 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(
, ); 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(); act(() => void notify({ level: "info", message: "saved" })); now.mockReturnValue(t0 + 4000); render(); 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(); act(() => void notify({ level: "error", message: "boom" })); const host = hosts()[0]!; 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", ); }); });