diff --git a/canvas/src/components/__tests__/Toaster.test.tsx b/canvas/src/components/__tests__/Toaster.test.tsx index 6f6f35ed..c6af0643 100644 --- a/canvas/src/components/__tests__/Toaster.test.tsx +++ b/canvas/src/components/__tests__/Toaster.test.tsx @@ -1,4 +1,13 @@ // @vitest-environment jsdom +/** + * Tests for Toaster — toast notification overlay. + * + * Covers: initial empty state, showToast triggers display, success/error/info + * styling classes, dismiss button removes toast, Escape dismisses latest toast + * (including persistent errors), auto-dismiss for success/info after 4s, + * errors persist, maximum 5 toasts shown (last-5 behaviour), no toasts + * renders nothing. + */ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { Toaster, showToast } from "../Toaster"; @@ -12,6 +21,140 @@ afterEach(() => { vi.useRealTimers(); }); +describe("Toaster — initial state", () => { + it("shows no toast messages when no toasts have fired", () => { + render(); + // No dismiss buttons visible when there are no toasts. + expect(screen.queryByRole("button", { name: "Dismiss notification" })).toBeNull(); + }); + + it("renders the status and alert container divs (for ARIA registration)", () => { + render(); + // Live regions are always in the DOM so screen readers register them. + expect(document.body.querySelector('[role="status"]')).toBeTruthy(); + expect(document.body.querySelector('[role="alert"]')).toBeTruthy(); + }); +}); + +describe("Toaster — showToast integration", () => { + it("displays a toast after showToast is called", () => { + render(); + act(() => { + showToast("Hello world"); + }); + expect(screen.getByText("Hello world")).toBeTruthy(); + }); + + it("displays multiple toasts", () => { + render(); + act(() => { + showToast("first"); + showToast("second"); + }); + expect(screen.getByText("first")).toBeTruthy(); + expect(screen.getByText("second")).toBeTruthy(); + }); + + it("shows success toast with emerald border class", () => { + render(); + act(() => { + showToast("Saved", "success"); + }); + const toast = screen.getByText("Saved").parentElement!; + expect(toast.className).toContain("emerald-950"); + }); + + it("shows error toast with red border class", () => { + render(); + act(() => { + showToast("Failed", "error"); + }); + const toast = screen.getByText("Failed").parentElement!; + expect(toast.className).toContain("red-950"); + }); + + it("shows info toast (default) with surface class", () => { + render(); + act(() => { + showToast("Note"); + }); + const toast = screen.getByText("Note").parentElement!; + expect(toast.className).toContain("surface-sunken"); + }); + + it("dismiss button click removes that specific toast", () => { + render(); + act(() => { + showToast("a", "info"); + showToast("b", "info"); + }); + const buttons = screen.getAllByRole("button", { name: "Dismiss notification" }); + expect(buttons).toHaveLength(2); + + // Click the first dismiss → "a" goes away, "b" stays + act(() => { + fireEvent.click(buttons[0]); + }); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeTruthy(); + }); +}); + +describe("Toaster — auto-dismiss", () => { + it("info toasts auto-dismiss after 4 seconds", () => { + render(); + act(() => { + showToast("auto-info", "info"); + }); + expect(screen.getByText("auto-info")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(4000); + }); + expect(screen.queryByText("auto-info")).toBeNull(); + }); + + it("success toasts auto-dismiss after 4 seconds", () => { + render(); + act(() => { + showToast("auto-success", "success"); + }); + expect(screen.getByText("auto-success")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(4000); + }); + expect(screen.queryByText("auto-success")).toBeNull(); + }); + + it("error toasts do NOT auto-dismiss", () => { + render(); + act(() => { + showToast("persistent-error", "error"); + }); + expect(screen.getByText("persistent-error")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(4000); + }); + // Error toast must still be visible + expect(screen.getByText("persistent-error")).toBeTruthy(); + }); + + it("does not auto-dismiss before 4 seconds", () => { + render(); + act(() => { + showToast("still-visible", "info"); + }); + expect(screen.getByText("still-visible")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(3999); + }); + expect(screen.getByText("still-visible")).toBeTruthy(); + }); +}); + describe("Toaster keyboard a11y", () => { it("Esc dismisses the most recent toast", () => { render(); @@ -62,21 +205,4 @@ describe("Toaster keyboard a11y", () => { // against a future regression where someone adds tabindex=-1. expect(btn.getAttribute("tabindex")).not.toBe("-1"); }); - - it("dismiss button click removes that specific toast", () => { - render(); - act(() => { - showToast("a", "info"); - showToast("b", "info"); - }); - const buttons = screen.getAllByRole("button", { name: "Dismiss notification" }); - expect(buttons).toHaveLength(2); - - // Click the first dismiss → "a" goes away, "b" stays - act(() => { - fireEvent.click(buttons[0]); - }); - expect(screen.queryByText("a")).toBeNull(); - expect(screen.getByText("b")).toBeTruthy(); - }); }); diff --git a/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx index 5c4dd0d1..005154c1 100644 --- a/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx +++ b/canvas/src/components/tabs/__tests__/BudgetSection.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom /** * Tests for BudgetSection — budget limit display and editor in the details panel. *