diff --git a/canvas/src/components/__tests__/Spinner.test.tsx b/canvas/src/components/__tests__/Spinner.test.tsx new file mode 100644 index 00000000..610f3a03 --- /dev/null +++ b/canvas/src/components/__tests__/Spinner.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +/** + * Tests for Spinner component. + * + * Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class. + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Spinner } from "../Spinner"; + +describe("Spinner — size variants", () => { + it("renders with sm size class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeTruthy(); + expect(svg?.className).toContain("w-3"); + expect(svg?.className).toContain("h-3"); + }); + + it("renders with md size class (default)", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("w-4"); + expect(svg?.className).toContain("h-4"); + }); + + it("renders with lg size class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("w-5"); + expect(svg?.className).toContain("h-5"); + }); + + it("defaults to md size when no size prop given", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("w-4"); + expect(svg?.className).toContain("h-4"); + }); + + it("has aria-hidden=true so screen readers skip it", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("includes the motion-safe:animate-spin class for CSS animation", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("motion-safe:animate-spin"); + }); + + it("renders exactly one SVG element", () => { + const { container } = render(); + expect(container.querySelectorAll("svg").length).toBe(1); + }); +}); diff --git a/canvas/src/components/__tests__/StatusBadge.test.tsx b/canvas/src/components/__tests__/StatusBadge.test.tsx new file mode 100644 index 00000000..4a8ccddf --- /dev/null +++ b/canvas/src/components/__tests__/StatusBadge.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +/** + * Tests for StatusBadge component. + * + * Covers: renders all three status variants, aria-label, role=status, + * icon presence, className variants, no render when passed invalid status. + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatusBadge } from "../ui/StatusBadge"; + +describe("StatusBadge — render", () => { + it("renders verified status with ✓ icon", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.textContent).toBe("✓"); + expect(badge.getAttribute("aria-label")).toBe("Connection status: verified"); + }); + + it("renders invalid status with ✗ icon", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.textContent).toBe("✗"); + expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid"); + }); + + it("renders unverified status with ○ icon", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.textContent).toBe("○"); + expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified"); + }); + + it("has role=status on the badge element", () => { + render(); + expect(screen.getByRole("status")).toBeTruthy(); + }); + + it("includes the config className on the rendered element", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.className).toContain("status-badge--valid"); + }); + + it("includes status-badge--invalid class for invalid status", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.className).toContain("status-badge--invalid"); + }); + + it("includes status-badge--unverified class for unverified status", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.className).toContain("status-badge--unverified"); + }); +}); diff --git a/canvas/src/components/__tests__/ValidationHint.test.tsx b/canvas/src/components/__tests__/ValidationHint.test.tsx new file mode 100644 index 00000000..1b2fc015 --- /dev/null +++ b/canvas/src/components/__tests__/ValidationHint.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +/** + * Tests for ValidationHint component. + * + * Covers: error state, valid state, neutral/hidden state, + * aria-live for error, icon rendering. + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ValidationHint } from "../ui/ValidationHint"; + +describe("ValidationHint — error state", () => { + it("renders error message when error is a non-null string", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("Invalid email address")).toBeTruthy(); + }); + + it("includes the warning icon in error state", () => { + render(); + expect(screen.getByText(/⚠/)).toBeTruthy(); + }); + + it("uses the error class on the paragraph element", () => { + render(); + const el = screen.getByRole("alert"); + expect(el.className).toContain("validation-hint--error"); + }); + + it("renders error even when showValid is true", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByText(/✓/)).toBeNull(); + }); +}); + +describe("ValidationHint — valid state", () => { + it("renders valid message when error is null and showValid is true", () => { + render(); + expect(screen.getByText("Valid format")).toBeTruthy(); + }); + + it("includes the checkmark icon in valid state", () => { + render(); + expect(screen.getByText(/✓ Valid format/)).toBeTruthy(); + }); + + it("uses the valid class on the paragraph element", () => { + render(); + const el = document.body.querySelector(".validation-hint--valid"); + expect(el).toBeTruthy(); + }); + + it("renders nothing when error is null and showValid is false (default)", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when error is empty string", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); +}); + +describe("ValidationHint — neutral / not-yet-validated", () => { + it("renders nothing when error is null and showValid defaults to false", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when error is undefined", () => { + // @ts-expect-error — testing runtime behavior with undefined + const { container } = render(); + expect(container.textContent).toBe(""); + }); +});