feat(frontend): Spectrum UX port — Tabs + AlertBanner + BackLink + compact tables (sprint 11) #13
48
frontend/tests/components/AlertBanner.test.tsx
Normal file
48
frontend/tests/components/AlertBanner.test.tsx
Normal file
@@ -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(<AlertBanner variant="error">Error message</AlertBanner>);
|
||||||
|
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(<AlertBanner variant="warn">Warning message</AlertBanner>);
|
||||||
|
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(<AlertBanner variant="success">Done!</AlertBanner>);
|
||||||
|
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(<AlertBanner variant="info">FYI</AlertBanner>);
|
||||||
|
const el = container.firstChild as HTMLElement;
|
||||||
|
expect(el).toHaveAttribute('role', 'status');
|
||||||
|
expect(el).toHaveClass('alert-info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders optional title when provided', () => {
|
||||||
|
render(<AlertBanner variant="info" title="Heads up">Detail text</AlertBanner>);
|
||||||
|
expect(screen.getByText('Heads up')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Detail text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no rounded-md, transition-*, or shadow-* (brutalism)', () => {
|
||||||
|
const { container } = render(<AlertBanner variant="error">Test</AlertBanner>);
|
||||||
|
const el = container.firstChild as HTMLElement;
|
||||||
|
expect(el).not.toHaveClass('rounded-md');
|
||||||
|
expect(el.className).not.toMatch(/transition-/);
|
||||||
|
expect(el.className).not.toMatch(/shadow-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/tests/components/BackLink.test.tsx
Normal file
39
frontend/tests/components/BackLink.test.tsx
Normal file
@@ -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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<BackLink to={to}>{label}</BackLink>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/tests/components/Tabs.test.tsx
Normal file
60
frontend/tests/components/Tabs.test.tsx
Normal file
@@ -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(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
||||||
|
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(<Tabs items={ITEMS} activeId="description" onChange={vi.fn()} />);
|
||||||
|
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(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
||||||
|
const activeBtn = screen.getByRole('tab', { name: /Schedule/i });
|
||||||
|
expect(activeBtn).toHaveClass('tab-underline-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders count pill for the simulations tab', () => {
|
||||||
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
||||||
|
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(<Tabs items={ITEMS} activeId="simulations" onChange={vi.fn()} />);
|
||||||
|
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(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
|
||||||
|
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(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
||||||
|
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-/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/tests/hooks/useHashTab.test.tsx
Normal file
43
frontend/tests/hooks/useHashTab.test.tsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user