Files
smoothschedule/frontend/src/components/dashboard/__tests__/MetricWidget.test.tsx
poduck 8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00

703 lines
18 KiB
TypeScript

/**
* 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<string, string> = {
'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(
<MetricWidget
title="Total Revenue"
value="$12,345"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
});
it('should render title correctly', () => {
render(
<MetricWidget
title="Total Customers"
value={150}
growth={mockGrowthData}
/>
);
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(
<MetricWidget
title="Total Appointments"
value={42}
growth={mockGrowthData}
/>
);
const value = screen.getByText('42');
expect(value).toBeInTheDocument();
expect(value).toHaveClass('text-2xl', 'font-bold', 'text-gray-900');
});
it('should render string value', () => {
render(
<MetricWidget
title="Revenue"
value="$25,000"
growth={mockGrowthData}
/>
);
const value = screen.getByText('$25,000');
expect(value).toBeInTheDocument();
});
it('should render with custom icon', () => {
const CustomIcon = () => <span data-testid="custom-icon">💰</span>;
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
icon={<CustomIcon />}
/>
);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('should render without icon', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeTexts = screen.getAllByText('0%');
expect(changeTexts).toHaveLength(2);
});
});
});
describe('Weekly and Monthly Metrics', () => {
it('should display weekly label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Week:')).toBeInTheDocument();
});
it('should display monthly label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Month:')).toBeInTheDocument();
});
it('should display weekly change percentage', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('+5.5%')).toBeInTheDocument();
});
it('should display monthly change percentage', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={mixedGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={preciseGrowth}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={false}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).not.toBeInTheDocument();
});
it('should show drag handle when in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toBeInTheDocument();
});
it('should show remove button when in edit mode', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={vi.fn()}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={handleRemove}
/>
);
const removeButton = screen.getByRole('button');
await user.click(removeButton);
expect(handleRemove).toHaveBeenCalledTimes(1);
});
it('should apply padding when in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
/>
);
const contentContainer = container.querySelector('.pl-5');
expect(contentContainer).toBeInTheDocument();
});
it('should not apply padding when not in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={false}
/>
);
const contentContainer = container.querySelector('.pl-5');
expect(contentContainer).not.toBeInTheDocument();
});
});
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
it('should apply trend badge styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const badges = container.querySelectorAll('.rounded-full');
expect(badges.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have semantic HTML structure', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('text-gray-500');
});
it('should make remove button accessible when in edit mode', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={vi.fn()}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
});
describe('Internationalization', () => {
it('should use translation for week label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Week:')).toBeInTheDocument();
});
it('should use translation for month label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Month:')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render correctly with all props', () => {
const CustomIcon = () => <span data-testid="icon">📊</span>;
const handleRemove = vi.fn();
const fullGrowth = {
weekly: { value: 150, change: 10 },
monthly: { value: 600, change: -5 },
};
render(
<MetricWidget
title="Total Revenue"
value="$15,000"
growth={fullGrowth}
icon={<CustomIcon />}
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(
<MetricWidget
title="Edge Case"
value={0}
growth={edgeCaseGrowth}
/>
);
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(
<MetricWidget
title="Very Long Metric Title That Should Still Display Properly"
value="$1000"
growth={mockGrowthData}
/>
);
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(
<MetricWidget
title="Revenue"
value="$1,234,567,890"
growth={mockGrowthData}
/>
);
expect(screen.getByText('$1,234,567,890')).toBeInTheDocument();
});
it('should display multiple trend indicators simultaneously', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
// Should have trend indicators for both weekly and monthly
const trendBadges = container.querySelectorAll('.rounded-full');
expect(trendBadges.length).toBeGreaterThanOrEqual(2);
});
});
});