diff --git a/frontend/__tests__/toast.test.tsx b/frontend/__tests__/toast.test.tsx new file mode 100644 index 00000000..94136d2e --- /dev/null +++ b/frontend/__tests__/toast.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { ToastProvider, toast } from '@/components/ui/toast'; + +// Helper function to trigger animationEnd to complete react-toastify's exit animations +function triggerAnimationEnd(node: HTMLElement | HTMLElement[]) { + if (Array.isArray(node)) { + node.forEach(el => { + if (el.parentNode) { + fireEvent.animationEnd(el.parentNode as HTMLElement); + } + }); + } else if (node.parentNode) { + fireEvent.animationEnd(node.parentNode as HTMLElement); + } + jest.runAllTimers(); +} + +describe('Toast Component', () => { + beforeEach(() => { + // Use fake timers for reliable timing tests + jest.useFakeTimers(); + // Clear all toasts and mocks before each test + jest.clearAllMocks(); + render(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('auto-dismisses after the configured 3000ms timeout', async () => { + // Trigger a toast within act to handle async updates + act(() => { + toast.success('Test success message'); + }); + + // Verify the toast is initially present + const toastElement = await screen.findByText('Test success message'); + expect(toastElement).toBeInTheDocument(); + + // Fast-forward time by 3000ms (the configured autoClose time) + act(() => { + jest.advanceTimersByTime(3000); + }); + + // Trigger animation end to complete the exit transition + triggerAnimationEnd(toastElement); + + // Verify the toast is removed after the timeout + await waitFor(() => { + expect(screen.queryByText('Test success message')).not.toBeInTheDocument(); + }); + }); + + it('allows manual dismiss via the close button', async () => { + // Trigger a toast within act + act(() => { + toast.error('Test error message'); + }); + + // Verify the toast is present + const toastElement = await screen.findByText('Test error message'); + expect(toastElement).toBeInTheDocument(); + + // Find and click the close button (react-toastify uses aria-label="close") + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + act(() => { + fireEvent.click(closeButton); + }); + + // Trigger animation end to complete the exit transition + triggerAnimationEnd(toastElement); + + // Verify the toast is removed immediately + await waitFor(() => { + expect(screen.queryByText('Test error message')).not.toBeInTheDocument(); + }); + }); + + it('stacks multiple simultaneous toasts correctly instead of replacing them', async () => { + // Trigger multiple toasts in sequence within act + act(() => { + toast.info('First toast message'); + toast.success('Second toast message'); + toast.warning('Third toast message'); + }); + + // Verify all three toasts are present in the document (stacked) + const firstToast = await screen.findByText('First toast message'); + const secondToast = screen.getByText('Second toast message'); + const thirdToast = screen.getByText('Third toast message'); + + expect(firstToast).toBeInTheDocument(); + expect(secondToast).toBeInTheDocument(); + expect(thirdToast).toBeInTheDocument(); + + // Verify we have exactly 3 toasts in the DOM (they stack, not replace) + const toastElements = screen.getAllByRole('alert'); + expect(toastElements.length).toBe(3); + + // Fast-forward time to clear all toasts + act(() => { + jest.advanceTimersByTime(3000); + }); + + // Trigger animation ends for all toasts + triggerAnimationEnd(toastElements); + + // Verify all toasts are removed + await waitFor(() => { + expect(screen.queryByText('First toast message')).not.toBeInTheDocument(); + expect(screen.queryByText('Second toast message')).not.toBeInTheDocument(); + expect(screen.queryByText('Third toast message')).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/features/Dashboard/DashboardCharts.spec.tsx b/frontend/features/Dashboard/DashboardCharts.spec.tsx new file mode 100644 index 00000000..77ade16a --- /dev/null +++ b/frontend/features/Dashboard/DashboardCharts.spec.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import DashboardCharts from './DashboardCharts'; + +// Mock recharts to avoid issues with canvas/SVG in Jest +jest.mock('recharts', () => ({ + PieChart: () =>
PieChart
, + Pie: () => null, + Cell: () => null, + BarChart: () =>
BarChart
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <>{children}, + Legend: () => null, +})); + +// Mock data matching useReportsSummary's return type (ReportsSummary) +const mockReportsSummary = { + total: 25, + byStatus: { + active: 15, + assigned: 8, + maintenance: 2, + }, + byCategory: [ + { name: 'Laptops', count: 10 }, + { name: 'Phones', count: 8 }, + { name: 'Furniture', count: 7 }, + ], + byDepartment: [ + { name: 'Engineering', count: 12 }, + { name: 'Marketing', count: 8 }, + { name: 'Sales', count: 5 }, + ], + recent: [], +}; + +describe('DashboardCharts', () => { + it('renders correctly with the provided data summary', () => { + render(); + + // Verify all chart sections are present + expect(screen.getByText('Status Distribution')).toBeInTheDocument(); + expect(screen.getByText('Assets by Category')).toBeInTheDocument(); + expect(screen.getByText('Assets by Department')).toBeInTheDocument(); + + // Verify status data is displayed in table toggle view (default is chart, but toggle exists) + expect(screen.getByText('active')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('assigned')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + expect(screen.getByText('maintenance')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('toggles to table view when "Show data table" is clicked for status distribution', () => { + render(); + + // Find and click the toggle button for status donut + const toggleButton = screen.getAllByText('Show data table')[0]; + fireEvent.click(toggleButton); + + // Verify table is displayed instead of chart + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText('active')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + + // Verify button text toggles back to "Show chart" + expect(screen.getByText('Show chart')).toBeInTheDocument(); + }); + + it('toggles to table view when "Show data table" is clicked for category chart', () => { + render(); + + // Find and click the toggle button for category bar chart + const toggleButton = screen.getAllByText('Show data table')[1]; + fireEvent.click(toggleButton); + + // Verify table is displayed + expect(screen.getByText('Category')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText('Laptops')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + }); + + it('shows "No category data" message when category data is empty', () => { + const emptyData = { + ...mockReportsSummary, + byCategory: [], + }; + + render(); + expect(screen.getByText('No category data')).toBeInTheDocument(); + }); + + it('shows "No department data" message when department data is empty', () => { + const emptyData = { + ...mockReportsSummary, + byDepartment: [], + }; + + render(); + expect(screen.getByText('No department data')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/features/Dashboard/DateRangeSelector.spec.tsx b/frontend/features/Dashboard/DateRangeSelector.spec.tsx new file mode 100644 index 00000000..5f223b6f --- /dev/null +++ b/frontend/features/Dashboard/DateRangeSelector.spec.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DateRangeSelector } from './DateRangeSelector'; + +// Mock Next.js navigation hooks +const mockPush = jest.fn(); +const mockUseSearchParams = jest.fn(); +const mockUsePathname = jest.fn(() => '/dashboard'); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), + usePathname: () => mockUsePathname(), + useSearchParams: () => mockUseSearchParams(), +})); + +describe('DateRangeSelector', () => { + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + // Default search params (30d preset) + mockUseSearchParams.mockReturnValue(new URLSearchParams('preset=30d')); + }); + + it('renders all preset buttons correctly', () => { + render(); + + // Verify all preset buttons are present + expect(screen.getByText('7 days')).toBeInTheDocument(); + expect(screen.getByText('30 days')).toBeInTheDocument(); + expect(screen.getByText('90 days')).toBeInTheDocument(); + expect(screen.getByText('1 year')).toBeInTheDocument(); + + // Verify the 30d button is active (aria-pressed=true) + const activeButton = screen.getByText('30 days'); + expect(activeButton).toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onChange and updates URL search params when a preset is clicked', () => { + const mockOnChange = jest.fn(); + render(); + + // Click on "7 days" preset + const sevenDayButton = screen.getByText('7 days'); + fireEvent.click(sevenDayButton); + + // Verify router.push was called with correct search params + expect(mockPush).toHaveBeenCalled(); + const pushedUrl = mockPush.mock.calls[0][0]; + expect(pushedUrl).toContain('preset=7d'); + expect(pushedUrl).toContain('from='); + expect(pushedUrl).toContain('to='); + + // Verify onChange was called with the correct date range + expect(mockOnChange).toHaveBeenCalled(); + const range = mockOnChange.mock.calls[0][0]; + expect(range.from).toBeDefined(); + expect(range.to).toBeDefined(); + }); + + it('updates to custom range when date inputs are changed', () => { + const mockOnChange = jest.fn(); + render(); + + // Get the date inputs + const fromInput = screen.getByLabelText('Start date'); + const toInput = screen.getByLabelText('End date'); + + // Change the from date + fireEvent.change(fromInput, { target: { value: '2024-01-01' } }); + + // Verify it switched to custom preset + expect(mockPush).toHaveBeenCalled(); + let pushedUrl = mockPush.mock.calls[0][0]; + expect(pushedUrl).toContain('preset=custom'); + expect(pushedUrl).toContain('from=2024-01-01'); + + // Change the to date + fireEvent.change(toInput, { target: { value: '2024-01-31' } }); + + // Verify URL is updated with new to date + pushedUrl = mockPush.mock.calls[1][0]; + expect(pushedUrl).toContain('preset=custom'); + expect(pushedUrl).toContain('to=2024-01-31'); + + // Verify onChange was called twice (once for each change) + expect(mockOnChange).toHaveBeenCalledTimes(2); + }); + + it('maintains custom preset when custom dates are already in URL', () => { + // Set search params to custom range + mockUseSearchParams.mockReturnValue(new URLSearchParams('preset=custom&from=2024-01-01&to=2024-01-31')); + + render(); + + // Verify the date inputs have the correct values + const fromInput = screen.getByLabelText('Start date') as HTMLInputElement; + const toInput = screen.getByLabelText('End date') as HTMLInputElement; + + expect(fromInput.value).toBe('2024-01-01'); + expect(toInput.value).toBe('2024-01-31'); + }); + + it('correctly marks the active preset button based on URL params', () => { + // Set search params to 90d + mockUseSearchParams.mockReturnValue(new URLSearchParams('preset=90d')); + + render(); + + // Verify 90 days button is active + const activeButton = screen.getByText('90 days'); + expect(activeButton).toHaveAttribute('aria-pressed', 'true'); + + // Other buttons should not be active + expect(screen.getByText('7 days')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText('30 days')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText('1 year')).toHaveAttribute('aria-pressed', 'false'); + }); +}); \ No newline at end of file diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index a9874b1b..d4abab8d 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1 +1,2 @@ // jest.setup.js +import '@testing-library/jest-dom'; \ No newline at end of file