diff --git a/frontend/tests/components/AlertBanner.test.tsx b/frontend/tests/components/AlertBanner.test.tsx new file mode 100644 index 0000000..281259b --- /dev/null +++ b/frontend/tests/components/AlertBanner.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AlertBanner } from '@/components/AlertBanner'; + +describe('AlertBanner', () => { + it('renders error variant with alert role and alert-error class', () => { + const { container } = render(Error message); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'alert'); + expect(el).toHaveClass('alert-error'); + expect(screen.getByText('Error message')).toBeInTheDocument(); + }); + + it('renders warn variant with alert role and alert-warn class', () => { + const { container } = render(Warning message); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'alert'); + expect(el).toHaveClass('alert-warn'); + }); + + it('renders success variant with status role and alert-success class', () => { + const { container } = render(Done!); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'status'); + expect(el).toHaveClass('alert-success'); + }); + + it('renders info variant with status role and alert-info class', () => { + const { container } = render(FYI); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute('role', 'status'); + expect(el).toHaveClass('alert-info'); + }); + + it('renders optional title when provided', () => { + render(Detail text); + expect(screen.getByText('Heads up')).toBeInTheDocument(); + expect(screen.getByText('Detail text')).toBeInTheDocument(); + }); + + it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => { + const { container } = render(Test); + const el = container.firstChild as HTMLElement; + expect(el).not.toHaveClass('rounded-md'); + expect(el.className).not.toMatch(/transition-/); + expect(el.className).not.toMatch(/shadow-/); + }); +}); diff --git a/frontend/tests/components/BackLink.test.tsx b/frontend/tests/components/BackLink.test.tsx new file mode 100644 index 0000000..0f922a7 --- /dev/null +++ b/frontend/tests/components/BackLink.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { BackLink } from '@/components/BackLink'; + +function renderBackLink(to: string, label: string) { + return render( + + {label} + , + ); +} + +describe('BackLink', () => { + it('renders children as link text', () => { + renderBackLink('/engagements', 'Back to engagements'); + expect(screen.getByText('Back to engagements')).toBeInTheDocument(); + }); + + it('renders as an anchor with the correct href', () => { + renderBackLink('/engagements', 'Back to engagements'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/engagements'); + }); + + it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => { + renderBackLink('/engagements', 'Back'); + const link = screen.getByRole('link'); + expect(link).not.toHaveClass('rounded-md'); + expect(link.className).not.toMatch(/transition-/); + expect(link.className).not.toMatch(/shadow-/); + }); + + it('contains the ArrowLeft icon (svg element)', () => { + const { container } = renderBackLink('/engagements', 'Back'); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/Tabs.test.tsx b/frontend/tests/components/Tabs.test.tsx new file mode 100644 index 0000000..6fffbe1 --- /dev/null +++ b/frontend/tests/components/Tabs.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Tabs } from '@/components/Tabs'; + +const ITEMS = [ + { id: 'schedule', label: 'Schedule' }, + { id: 'description', label: 'Description' }, + { id: 'simulations', label: 'Simulations', count: 5 }, +]; + +describe('Tabs', () => { + it('renders all tab labels', () => { + render(); + expect(screen.getByText('Schedule')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Simulations')).toBeInTheDocument(); + }); + + it('marks the active tab with aria-selected=true', () => { + render(); + const descBtn = screen.getByRole('tab', { name: /Description/i }); + expect(descBtn).toHaveAttribute('aria-selected', 'true'); + const schedBtn = screen.getByRole('tab', { name: /Schedule/i }); + expect(schedBtn).toHaveAttribute('aria-selected', 'false'); + }); + + it('applies tab-underline-active class to the active tab', () => { + render(); + const activeBtn = screen.getByRole('tab', { name: /Schedule/i }); + expect(activeBtn).toHaveClass('tab-underline-active'); + }); + + it('renders count pill for the simulations tab', () => { + render(); + const pill = screen.getByText('5'); + expect(pill).toHaveClass('tab-count-pill'); + }); + + it('applies tab-count-pill-active when the tab with count is active', () => { + render(); + const pill = screen.getByText('5'); + expect(pill).toHaveClass('tab-count-pill-active'); + }); + + it('calls onChange with the tab id when clicked', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', { name: /Description/i })); + expect(onChange).toHaveBeenCalledWith('description'); + }); + + it('has no rounded-md, transition-*, or shadow-* on tab buttons (brutalism)', () => { + render(); + for (const btn of screen.getAllByRole('tab')) { + expect(btn).not.toHaveClass('rounded-md'); + expect(btn.className).not.toMatch(/transition-/); + expect(btn.className).not.toMatch(/shadow-/); + } + }); +}); diff --git a/frontend/tests/hooks/useHashTab.test.tsx b/frontend/tests/hooks/useHashTab.test.tsx new file mode 100644 index 0000000..5a8ca5f --- /dev/null +++ b/frontend/tests/hooks/useHashTab.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useHashTab } from '@/hooks/useHashTab'; + +beforeEach(() => { + window.location.hash = ''; +}); + +afterEach(() => { + window.location.hash = ''; +}); + +describe('useHashTab', () => { + it('returns the default id when hash is empty', () => { + const { result } = renderHook(() => useHashTab('schedule')); + expect(result.current[0]).toBe('schedule'); + }); + + it('reads the hash on mount', () => { + window.location.hash = 'simulations'; + const { result } = renderHook(() => useHashTab('schedule')); + expect(result.current[0]).toBe('simulations'); + }); + + it('navigate() updates activeId and sets location.hash', () => { + const { result } = renderHook(() => useHashTab('schedule')); + act(() => { + result.current[1]('description'); + }); + expect(result.current[0]).toBe('description'); + expect(window.location.hash).toBe('#description'); + }); + + it('falls back to defaultId when hash becomes empty', () => { + window.location.hash = 'simulations'; + const { result } = renderHook(() => useHashTab('schedule')); + act(() => { + window.location.hash = ''; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(result.current[0]).toBe('schedule'); + }); +});