/** * Unit tests for MetricWidget component * * Tests cover: * - Component rendering with title and value * - Growth/trend indicators (positive, negative, neutral) * - Change percentage formatting * - Weekly and monthly metrics display * - Icon rendering * - Edit mode with drag handle and remove button * - Internationalization (i18n) * - Accessibility */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import MetricWidget from '../MetricWidget'; // Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => { const translations: Record = { 'dashboard.weekLabel': 'Week:', 'dashboard.monthLabel': 'Month:', }; return translations[key] || key; }, }), })); describe('MetricWidget', () => { const mockGrowthData = { weekly: { value: 100, change: 5.5 }, monthly: { value: 400, change: -2.3 }, }; beforeEach(() => { vi.clearAllMocks(); }); describe('Rendering', () => { it('should render the component', () => { render( ); expect(screen.getByText('Total Revenue')).toBeInTheDocument(); }); it('should render title correctly', () => { render( ); const title = screen.getByText('Total Customers'); expect(title).toBeInTheDocument(); expect(title).toHaveClass('text-sm', 'font-medium', 'text-gray-500'); }); it('should render numeric value', () => { render( ); const value = screen.getByText('42'); expect(value).toBeInTheDocument(); expect(value).toHaveClass('text-2xl', 'font-bold', 'text-gray-900'); }); it('should render string value', () => { render( ); const value = screen.getByText('$25,000'); expect(value).toBeInTheDocument(); }); it('should render with custom icon', () => { const CustomIcon = () => 💰; render( } /> ); expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); }); it('should render without icon', () => { const { container } = render( ); const iconContainer = container.querySelector('.text-brand-500'); expect(iconContainer).not.toBeInTheDocument(); }); }); describe('Trend Indicators', () => { describe('Positive Change', () => { it('should show positive trend icon for weekly growth', () => { const positiveGrowth = { weekly: { value: 100, change: 10.5 }, monthly: { value: 400, change: 0 }, }; const { container } = render( ); const changeText = screen.getByText('+10.5%'); expect(changeText).toBeInTheDocument(); // Check for TrendingUp icon (lucide-react renders as SVG) const svgs = container.querySelectorAll('svg'); expect(svgs.length).toBeGreaterThan(0); }); it('should apply positive change styling', () => { const positiveGrowth = { weekly: { value: 100, change: 15 }, monthly: { value: 400, change: 0 }, }; render( ); const changeElement = screen.getByText('+15.0%').closest('span'); expect(changeElement).toHaveClass('text-green-700', 'bg-green-50'); }); it('should format positive change with plus sign', () => { const positiveGrowth = { weekly: { value: 100, change: 7.8 }, monthly: { value: 400, change: 3.2 }, }; render( ); expect(screen.getByText('+7.8%')).toBeInTheDocument(); expect(screen.getByText('+3.2%')).toBeInTheDocument(); }); }); describe('Negative Change', () => { it('should show negative trend icon for monthly growth', () => { const negativeGrowth = { weekly: { value: 100, change: 0 }, monthly: { value: 400, change: -5.5 }, }; const { container } = render( ); const changeText = screen.getByText('-5.5%'); expect(changeText).toBeInTheDocument(); // Check for TrendingDown icon const svgs = container.querySelectorAll('svg'); expect(svgs.length).toBeGreaterThan(0); }); it('should apply negative change styling', () => { const negativeGrowth = { weekly: { value: 100, change: -12.3 }, monthly: { value: 400, change: 0 }, }; render( ); const changeElement = screen.getByText('-12.3%').closest('span'); expect(changeElement).toHaveClass('text-red-700', 'bg-red-50'); }); it('should format negative change without extra minus sign', () => { const negativeGrowth = { weekly: { value: 100, change: -8.9 }, monthly: { value: 400, change: -15.2 }, }; render( ); expect(screen.getByText('-8.9%')).toBeInTheDocument(); expect(screen.getByText('-15.2%')).toBeInTheDocument(); }); }); describe('Zero Change', () => { it('should show neutral trend icon for zero change', () => { const zeroGrowth = { weekly: { value: 100, change: 0 }, monthly: { value: 400, change: 0 }, }; const { container } = render( ); const changeTexts = screen.getAllByText('0%'); expect(changeTexts).toHaveLength(2); // Check for Minus icon const svgs = container.querySelectorAll('svg'); expect(svgs.length).toBeGreaterThan(0); }); it('should apply neutral change styling', () => { const zeroGrowth = { weekly: { value: 100, change: 0 }, monthly: { value: 400, change: 0 }, }; render( ); const changeElements = screen.getAllByText('0%'); changeElements.forEach((element) => { const spanElement = element.closest('span'); expect(spanElement).toHaveClass('text-gray-700', 'bg-gray-50'); }); }); it('should format zero change as 0%', () => { const zeroGrowth = { weekly: { value: 100, change: 0 }, monthly: { value: 400, change: 0 }, }; render( ); const changeTexts = screen.getAllByText('0%'); expect(changeTexts).toHaveLength(2); }); }); }); describe('Weekly and Monthly Metrics', () => { it('should display weekly label', () => { render( ); expect(screen.getByText('Week:')).toBeInTheDocument(); }); it('should display monthly label', () => { render( ); expect(screen.getByText('Month:')).toBeInTheDocument(); }); it('should display weekly change percentage', () => { render( ); expect(screen.getByText('+5.5%')).toBeInTheDocument(); }); it('should display monthly change percentage', () => { render( ); expect(screen.getByText('-2.3%')).toBeInTheDocument(); }); it('should handle different weekly and monthly trends', () => { const mixedGrowth = { weekly: { value: 100, change: 12.5 }, monthly: { value: 400, change: -8.2 }, }; render( ); expect(screen.getByText('+12.5%')).toBeInTheDocument(); expect(screen.getByText('-8.2%')).toBeInTheDocument(); }); it('should format change values to one decimal place', () => { const preciseGrowth = { weekly: { value: 100, change: 5.456 }, monthly: { value: 400, change: -3.789 }, }; render( ); expect(screen.getByText('+5.5%')).toBeInTheDocument(); expect(screen.getByText('-3.8%')).toBeInTheDocument(); }); }); describe('Edit Mode', () => { it('should not show edit controls when isEditing is false', () => { const { container } = render( ); const dragHandle = container.querySelector('.drag-handle'); expect(dragHandle).not.toBeInTheDocument(); }); it('should show drag handle when in edit mode', () => { const { container } = render( ); const dragHandle = container.querySelector('.drag-handle'); expect(dragHandle).toBeInTheDocument(); }); it('should show remove button when in edit mode', () => { render( ); const removeButton = screen.getByRole('button'); expect(removeButton).toBeInTheDocument(); }); it('should call onRemove when remove button is clicked', async () => { const user = userEvent.setup(); const handleRemove = vi.fn(); render( ); const removeButton = screen.getByRole('button'); await user.click(removeButton); expect(handleRemove).toHaveBeenCalledTimes(1); }); it('should apply padding when in edit mode', () => { const { container } = render( ); const contentContainer = container.querySelector('.pl-5'); expect(contentContainer).toBeInTheDocument(); }); it('should not apply padding when not in edit mode', () => { const { container } = render( ); const contentContainer = container.querySelector('.pl-5'); expect(contentContainer).not.toBeInTheDocument(); }); }); describe('Styling', () => { it('should apply container styles', () => { const { container } = render( ); const widget = container.firstChild; expect(widget).toHaveClass( 'h-full', 'p-4', 'bg-white', 'rounded-xl', 'border', 'border-gray-200', 'shadow-sm', 'relative', 'group' ); }); it('should apply dark mode styles', () => { const { container } = render( ); const widget = container.firstChild; expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700'); }); it('should apply trend badge styles', () => { const { container } = render( ); const badges = container.querySelectorAll('.rounded-full'); expect(badges.length).toBeGreaterThan(0); }); }); describe('Accessibility', () => { it('should have semantic HTML structure', () => { const { container } = render( ); const paragraphs = container.querySelectorAll('p'); const divs = container.querySelectorAll('div'); expect(paragraphs.length).toBeGreaterThan(0); expect(divs.length).toBeGreaterThan(0); }); it('should have readable text contrast', () => { render( ); const title = screen.getByText('Revenue'); expect(title).toHaveClass('text-gray-500'); }); it('should make remove button accessible when in edit mode', () => { render( ); const button = screen.getByRole('button'); expect(button).toBeInTheDocument(); }); }); describe('Internationalization', () => { it('should use translation for week label', () => { render( ); expect(screen.getByText('Week:')).toBeInTheDocument(); }); it('should use translation for month label', () => { render( ); expect(screen.getByText('Month:')).toBeInTheDocument(); }); }); describe('Integration', () => { it('should render correctly with all props', () => { const CustomIcon = () => 📊; const handleRemove = vi.fn(); const fullGrowth = { weekly: { value: 150, change: 10 }, monthly: { value: 600, change: -5 }, }; render( } isEditing={true} onRemove={handleRemove} /> ); expect(screen.getByText('Total Revenue')).toBeInTheDocument(); expect(screen.getByText('$15,000')).toBeInTheDocument(); expect(screen.getByTestId('icon')).toBeInTheDocument(); expect(screen.getByText('+10.0%')).toBeInTheDocument(); expect(screen.getByText('-5.0%')).toBeInTheDocument(); expect(screen.getByRole('button')).toBeInTheDocument(); }); it('should handle edge case values', () => { const edgeCaseGrowth = { weekly: { value: 0, change: 0 }, monthly: { value: 1000000, change: 99.9 }, }; render( ); expect(screen.getByText('Edge Case')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument(); expect(screen.getByText('0%')).toBeInTheDocument(); expect(screen.getByText('+99.9%')).toBeInTheDocument(); }); it('should maintain layout with long titles', () => { render( ); const title = screen.getByText('Very Long Metric Title That Should Still Display Properly'); expect(title).toBeInTheDocument(); expect(title).toHaveClass('text-sm'); }); it('should handle large numeric values', () => { render( ); expect(screen.getByText('$1,234,567,890')).toBeInTheDocument(); }); it('should display multiple trend indicators simultaneously', () => { const { container } = render( ); // Should have trend indicators for both weekly and monthly const trendBadges = container.querySelectorAll('.rounded-full'); expect(trendBadges.length).toBeGreaterThanOrEqual(2); }); }); });