Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions frontend/__tests__/toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ToastProvider />);
});

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();
});
});
});
108 changes: 108 additions & 0 deletions frontend/features/Dashboard/DashboardCharts.spec.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="pie-chart">PieChart</div>,
Pie: () => null,
Cell: () => null,
BarChart: () => <div data-testid="bar-chart">BarChart</div>,
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(<DashboardCharts data={mockReportsSummary} />);

// 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(<DashboardCharts data={mockReportsSummary} />);

// 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(<DashboardCharts data={mockReportsSummary} />);

// 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(<DashboardCharts data={emptyData} />);
expect(screen.getByText('No category data')).toBeInTheDocument();
});

it('shows "No department data" message when department data is empty', () => {
const emptyData = {
...mockReportsSummary,
byDepartment: [],
};

render(<DashboardCharts data={emptyData} />);
expect(screen.getByText('No department data')).toBeInTheDocument();
});
});
120 changes: 120 additions & 0 deletions frontend/features/Dashboard/DateRangeSelector.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<DateRangeSelector />);

// 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(<DateRangeSelector onChange={mockOnChange} />);

// 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(<DateRangeSelector onChange={mockOnChange} />);

// 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(<DateRangeSelector />);

// 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(<DateRangeSelector />);

// 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');
});
});
1 change: 1 addition & 0 deletions frontend/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// jest.setup.js
import '@testing-library/jest-dom';
Loading