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.
*