Skip to content

Commit 10479f4

Browse files
talissoncostaclaude
andcommitted
test(components): add unit tests for shared components
Add tests for shared UI components including ARIA accessibility, user interactions, and prop variations. - LoadingState: spinner, message, ARIA attributes - MiniPagination: navigation, disabled states, ARIA - FlagStatusIndicator: enabled/disabled states - FlagsmithLink: link rendering, iconOnly mode - SearchInput: input, clear button, debounce 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 51b218b commit 10479f4

File tree

5 files changed

+526
-0
lines changed

5 files changed

+526
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
2+
import { render, screen } from '@testing-library/react';
3+
import { FlagStatusIndicator } from '../FlagStatusIndicator';
4+
5+
describe('FlagStatusIndicator', () => {
6+
it('renders enabled state with green dot', () => {
7+
const { container } = render(<FlagStatusIndicator enabled />);
8+
9+
const dot = container.querySelector('[class*="enabled"]');
10+
expect(dot).toBeInTheDocument();
11+
});
12+
13+
it('renders disabled state with gray dot', () => {
14+
const { container } = render(<FlagStatusIndicator enabled={false} />);
15+
16+
const dot = container.querySelector('[class*="disabled"]');
17+
expect(dot).toBeInTheDocument();
18+
});
19+
20+
it('does not show label by default', () => {
21+
render(<FlagStatusIndicator enabled />);
22+
23+
expect(screen.queryByText('On')).not.toBeInTheDocument();
24+
expect(screen.queryByText('Off')).not.toBeInTheDocument();
25+
});
26+
27+
it('shows "On" label when enabled and showLabel is true', () => {
28+
render(<FlagStatusIndicator enabled showLabel />);
29+
30+
expect(screen.getByText('On')).toBeInTheDocument();
31+
});
32+
33+
it('shows "Off" label when disabled and showLabel is true', () => {
34+
render(<FlagStatusIndicator enabled={false} showLabel />);
35+
36+
expect(screen.getByText('Off')).toBeInTheDocument();
37+
});
38+
39+
it('renders smaller dot when size is small', () => {
40+
const { container } = render(
41+
<FlagStatusIndicator enabled size="small" />,
42+
);
43+
44+
const dot = container.querySelector('[class*="dot"]');
45+
expect(dot).toHaveStyle({ width: '8px', height: '8px' });
46+
});
47+
48+
it('renders medium dot by default', () => {
49+
const { container } = render(<FlagStatusIndicator enabled />);
50+
51+
const dot = container.querySelector('[class*="dot"]');
52+
expect(dot).toHaveStyle({ width: '10px', height: '10px' });
53+
});
54+
55+
it('renders tooltip when provided', () => {
56+
render(
57+
<FlagStatusIndicator
58+
enabled
59+
tooltip="Development: On • Production: Off"
60+
/>,
61+
);
62+
63+
// Tooltip wrapper should be present
64+
// Note: The actual tooltip text only appears on hover
65+
const indicator = screen.getByText('', { selector: '[class*="container"]' });
66+
expect(indicator).toBeInTheDocument();
67+
});
68+
69+
it('does not render tooltip wrapper when tooltip is not provided', () => {
70+
const { container } = render(<FlagStatusIndicator enabled />);
71+
72+
// Should not have Tooltip wrapper
73+
const muiTooltip = container.querySelector('[class*="MuiTooltip"]');
74+
// Without tooltip, there's no tooltip wrapper
75+
expect(muiTooltip).toBeNull();
76+
});
77+
78+
it('applies smaller font size for small size with label', () => {
79+
render(
80+
<FlagStatusIndicator enabled showLabel size="small" />,
81+
);
82+
83+
const label = screen.getByText('On');
84+
expect(label).toHaveStyle({ fontSize: '0.75rem' });
85+
});
86+
87+
it('applies medium font size for medium size with label', () => {
88+
render(
89+
<FlagStatusIndicator enabled showLabel size="medium" />,
90+
);
91+
92+
const label = screen.getByText('On');
93+
expect(label).toHaveStyle({ fontSize: '0.875rem' });
94+
});
95+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
2+
import { render, screen } from '@testing-library/react';
3+
import { FlagsmithLink } from '../FlagsmithLink';
4+
5+
describe('FlagsmithLink', () => {
6+
const defaultProps = {
7+
href: 'https://app.flagsmith.com/project/123',
8+
};
9+
10+
it('renders as a link with children', () => {
11+
render(
12+
<FlagsmithLink {...defaultProps}>
13+
View in Flagsmith
14+
</FlagsmithLink>,
15+
);
16+
17+
const link = screen.getByRole('link');
18+
expect(link).toHaveAttribute('href', defaultProps.href);
19+
expect(link).toHaveTextContent('View in Flagsmith');
20+
});
21+
22+
it('opens in new tab with security attributes', () => {
23+
render(
24+
<FlagsmithLink {...defaultProps}>
25+
Link
26+
</FlagsmithLink>,
27+
);
28+
29+
const link = screen.getByRole('link');
30+
expect(link).toHaveAttribute('target', '_blank');
31+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
32+
});
33+
34+
it('renders with default tooltip', () => {
35+
render(
36+
<FlagsmithLink {...defaultProps}>
37+
Link
38+
</FlagsmithLink>,
39+
);
40+
41+
const link = screen.getByRole('link');
42+
expect(link).toHaveAttribute(
43+
'aria-label',
44+
'Open in Flagsmith (opens in new tab)',
45+
);
46+
});
47+
48+
it('renders with custom tooltip', () => {
49+
render(
50+
<FlagsmithLink {...defaultProps} tooltip="View Dashboard">
51+
Link
52+
</FlagsmithLink>,
53+
);
54+
55+
const link = screen.getByRole('link');
56+
expect(link).toHaveAttribute(
57+
'aria-label',
58+
'View Dashboard (opens in new tab)',
59+
);
60+
});
61+
62+
it('renders icon-only mode as button', () => {
63+
render(<FlagsmithLink {...defaultProps} iconOnly />);
64+
65+
const button = screen.getByRole('button');
66+
expect(button).toHaveAttribute('href', defaultProps.href);
67+
expect(button).toHaveAttribute('aria-label', 'Open in Flagsmith');
68+
});
69+
70+
it('renders icon-only mode with custom tooltip', () => {
71+
render(<FlagsmithLink {...defaultProps} iconOnly tooltip="Open Dashboard" />);
72+
73+
const button = screen.getByRole('button');
74+
expect(button).toHaveAttribute('aria-label', 'Open Dashboard');
75+
});
76+
77+
it('renders external link icon', () => {
78+
const { container } = render(
79+
<FlagsmithLink {...defaultProps}>
80+
Link
81+
</FlagsmithLink>,
82+
);
83+
84+
// LaunchIcon should be present and hidden from screen readers
85+
const icon = container.querySelector('[aria-hidden="true"]');
86+
expect(icon).toBeInTheDocument();
87+
});
88+
89+
it('renders icon-only with correct security attributes', () => {
90+
render(<FlagsmithLink {...defaultProps} iconOnly />);
91+
92+
const button = screen.getByRole('button');
93+
expect(button).toHaveAttribute('target', '_blank');
94+
expect(button).toHaveAttribute('rel', 'noopener noreferrer');
95+
});
96+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
2+
import { render, screen } from '@testing-library/react';
3+
import { LoadingState } from '../LoadingState';
4+
5+
describe('LoadingState', () => {
6+
it('renders with default props', () => {
7+
render(<LoadingState />);
8+
9+
expect(screen.getByRole('status')).toBeInTheDocument();
10+
expect(screen.getByText('Loading...')).toBeInTheDocument();
11+
});
12+
13+
it('renders with custom message', () => {
14+
render(<LoadingState message="Fetching data..." />);
15+
16+
expect(screen.getByText('Fetching data...')).toBeInTheDocument();
17+
expect(screen.getByRole('status')).toHaveAttribute(
18+
'aria-label',
19+
'Fetching data...',
20+
);
21+
});
22+
23+
it('has correct ARIA attributes', () => {
24+
render(<LoadingState message="Loading feature flags..." />);
25+
26+
const status = screen.getByRole('status');
27+
expect(status).toHaveAttribute('aria-label', 'Loading feature flags...');
28+
});
29+
30+
it('hides spinner from screen readers', () => {
31+
const { container } = render(<LoadingState />);
32+
33+
// CircularProgress should have aria-hidden
34+
const spinner = container.querySelector('[aria-hidden="true"]');
35+
expect(spinner).toBeInTheDocument();
36+
});
37+
38+
it('renders without message when empty string provided', () => {
39+
render(<LoadingState message="" />);
40+
41+
// The status box should still render but without message text
42+
expect(screen.getByRole('status')).toBeInTheDocument();
43+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
44+
});
45+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { MiniPagination } from '../MiniPagination';
4+
5+
describe('MiniPagination', () => {
6+
const defaultProps = {
7+
page: 0,
8+
totalPages: 3,
9+
totalItems: 25,
10+
onPrevious: jest.fn(),
11+
onNext: jest.fn(),
12+
};
13+
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it('renders nothing when totalPages is 1', () => {
19+
const { container } = render(
20+
<MiniPagination {...defaultProps} totalPages={1} />,
21+
);
22+
23+
expect(container).toBeEmptyDOMElement();
24+
});
25+
26+
it('renders nothing when totalPages is 0', () => {
27+
const { container } = render(
28+
<MiniPagination {...defaultProps} totalPages={0} totalItems={0} />,
29+
);
30+
31+
expect(container).toBeEmptyDOMElement();
32+
});
33+
34+
it('renders pagination info correctly', () => {
35+
render(<MiniPagination {...defaultProps} />);
36+
37+
expect(screen.getByText('Page 1 of 3 (25 items)')).toBeInTheDocument();
38+
});
39+
40+
it('renders with custom item label', () => {
41+
render(<MiniPagination {...defaultProps} itemLabel="flags" />);
42+
43+
expect(screen.getByText('Page 1 of 3 (25 flags)')).toBeInTheDocument();
44+
});
45+
46+
it('has correct ARIA attributes', () => {
47+
render(<MiniPagination {...defaultProps} itemLabel="flags" />);
48+
49+
const nav = screen.getByRole('navigation');
50+
expect(nav).toHaveAttribute('aria-label', 'Pagination for flags');
51+
});
52+
53+
it('disables previous button on first page', () => {
54+
render(<MiniPagination {...defaultProps} page={0} />);
55+
56+
const prevButton = screen.getByRole('button', { name: /previous page/i });
57+
expect(prevButton).toBeDisabled();
58+
});
59+
60+
it('enables previous button on subsequent pages', () => {
61+
render(<MiniPagination {...defaultProps} page={1} />);
62+
63+
const prevButton = screen.getByRole('button', { name: /previous page/i });
64+
expect(prevButton).not.toBeDisabled();
65+
});
66+
67+
it('disables next button on last page', () => {
68+
render(<MiniPagination {...defaultProps} page={2} totalPages={3} />);
69+
70+
const nextButton = screen.getByRole('button', { name: /next page/i });
71+
expect(nextButton).toBeDisabled();
72+
});
73+
74+
it('enables next button on non-last pages', () => {
75+
render(<MiniPagination {...defaultProps} page={0} />);
76+
77+
const nextButton = screen.getByRole('button', { name: /next page/i });
78+
expect(nextButton).not.toBeDisabled();
79+
});
80+
81+
it('calls onPrevious when previous button is clicked', () => {
82+
const onPrevious = jest.fn();
83+
render(<MiniPagination {...defaultProps} page={1} onPrevious={onPrevious} />);
84+
85+
const prevButton = screen.getByRole('button', { name: /previous page/i });
86+
fireEvent.click(prevButton);
87+
88+
expect(onPrevious).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it('calls onNext when next button is clicked', () => {
92+
const onNext = jest.fn();
93+
render(<MiniPagination {...defaultProps} page={0} onNext={onNext} />);
94+
95+
const nextButton = screen.getByRole('button', { name: /next page/i });
96+
fireEvent.click(nextButton);
97+
98+
expect(onNext).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it('does not call onPrevious when disabled', () => {
102+
const onPrevious = jest.fn();
103+
render(<MiniPagination {...defaultProps} page={0} onPrevious={onPrevious} />);
104+
105+
const prevButton = screen.getByRole('button', { name: /previous page/i });
106+
fireEvent.click(prevButton);
107+
108+
expect(onPrevious).not.toHaveBeenCalled();
109+
});
110+
111+
it('does not call onNext when disabled', () => {
112+
const onNext = jest.fn();
113+
render(<MiniPagination {...defaultProps} page={2} totalPages={3} onNext={onNext} />);
114+
115+
const nextButton = screen.getByRole('button', { name: /next page/i });
116+
fireEvent.click(nextButton);
117+
118+
expect(onNext).not.toHaveBeenCalled();
119+
});
120+
121+
it('displays correct page number (1-indexed)', () => {
122+
render(<MiniPagination {...defaultProps} page={2} totalPages={5} />);
123+
124+
expect(screen.getByText('Page 3 of 5 (25 items)')).toBeInTheDocument();
125+
});
126+
});

0 commit comments

Comments
 (0)