diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/ReportChart.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportChart.test.tsx new file mode 100644 index 00000000..b14cfbbb --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportChart.test.tsx @@ -0,0 +1,362 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ReportChart } from '../ReportChart'; +import * as reportDownloadUtils from '../report-download-utils'; + +// Mock the report download utilities +jest.mock('../report-download-utils', () => ({ + exportReportToCSV: jest.fn(), + exportChartToPNG: jest.fn(), +})); + +// Mock lucide-react icons +jest.mock('lucide-react', () => ({ + FileText: () =>
FileText
, + Image: () =>
Image
, + Loader2: () =>
Loader2
, +})); + +// Mock recharts +jest.mock('recharts', () => ({ + BarChart: ({ children }: any) => ( +
{children}
+ ), + Bar: () =>
Bar
, + XAxis: () =>
XAxis
, + YAxis: () =>
YAxis
, + Tooltip: () =>
Tooltip
, + Legend: () =>
Legend
, + CartesianGrid: () =>
CartesianGrid
, + ResponsiveContainer: ({ children }: any) => ( +
{children}
+ ), +})); + +describe('ReportChart', () => { + const mockData = [{ name: 'Today', completed: 5, ongoing: 3 }]; + + const defaultProps = { + data: mockData, + title: 'Daily Report', + chartId: 'daily-report-chart', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders the chart with correct title', () => { + render(); + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + }); + + it('renders the chart container with correct id', () => { + const { container } = render(); + const chartContainer = container.querySelector('#daily-report-chart'); + expect(chartContainer).toBeInTheDocument(); + }); + + it('renders CSV export button', () => { + render(); + const csvButton = screen.getByTitle('Download as CSV'); + expect(csvButton).toBeInTheDocument(); + }); + + it('renders PNG export button', () => { + render(); + const pngButton = screen.getByTitle('Download as PNG'); + expect(pngButton).toBeInTheDocument(); + }); + + it('renders recharts components', () => { + render(); + expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); + expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); + expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument(); + expect(screen.getByTestId('x-axis')).toBeInTheDocument(); + expect(screen.getByTestId('y-axis')).toBeInTheDocument(); + expect(screen.getByTestId('tooltip')).toBeInTheDocument(); + expect(screen.getByTestId('legend')).toBeInTheDocument(); + }); + + it('renders with different chart titles', () => { + const { rerender } = render(); + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText('Weekly Report')).toBeInTheDocument(); + expect(screen.queryByText('Daily Report')).not.toBeInTheDocument(); + }); + + it('renders with empty data array', () => { + render(); + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); + }); + + it('renders with multiple data entries', () => { + const multiData = [ + { name: 'Monday', completed: 5, ongoing: 3 }, + { name: 'Tuesday', completed: 7, ongoing: 2 }, + { name: 'Wednesday', completed: 4, ongoing: 5 }, + ]; + render(); + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + }); + }); + + describe('CSV Export Functionality', () => { + it('calls exportReportToCSV when CSV button is clicked', () => { + render(); + const csvButton = screen.getByTitle('Download as CSV'); + + fireEvent.click(csvButton); + + expect(reportDownloadUtils.exportReportToCSV).toHaveBeenCalledWith( + mockData, + 'Daily Report' + ); + }); + + it('disables both buttons during PNG export', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + const csvButton = screen.getByTitle('Download as CSV'); + + fireEvent.click(pngButton); + + // Both buttons are disabled during export (based on actual implementation) + await waitFor(() => { + expect(csvButton).toBeDisabled(); + expect(pngButton).toBeDisabled(); + }); + }); + + it('calls exportReportToCSV with correct data for different report types', () => { + const weeklyData = [{ name: 'This Week', completed: 20, ongoing: 10 }]; + render( + + ); + + const csvButton = screen.getByTitle('Download as CSV'); + fireEvent.click(csvButton); + + expect(reportDownloadUtils.exportReportToCSV).toHaveBeenCalledWith( + weeklyData, + 'Weekly Report' + ); + }); + }); + + describe('PNG Export Functionality', () => { + it('calls exportChartToPNG when PNG button is clicked', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockResolvedValue( + undefined + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + await waitFor(() => { + expect(reportDownloadUtils.exportChartToPNG).toHaveBeenCalledWith( + 'daily-report-chart', + 'Daily Report' + ); + }); + }); + + it('shows loading state during PNG export', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + // Check for loader icon + await waitFor(() => { + expect(screen.getByTestId('loader-icon')).toBeInTheDocument(); + }); + }); + + it('disables PNG button during export', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + await waitFor(() => { + expect(pngButton).toBeDisabled(); + }); + }); + + it('re-enables PNG button after successful export', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockResolvedValue( + undefined + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + await waitFor(() => { + expect(pngButton).not.toBeDisabled(); + }); + }); + + it('button loading state toggles correctly during export', async () => { + // Mock a slow export operation + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + // Button should be enabled initially + expect(pngButton).not.toBeDisabled(); + + // Click to trigger export + fireEvent.click(pngButton); + + // Button should be disabled during export + await waitFor(() => { + expect(pngButton).toBeDisabled(); + }); + + // Button should be re-enabled after export completes + await waitFor( + () => { + expect(pngButton).not.toBeDisabled(); + }, + { timeout: 1000 } + ); + }); + + it('calls exportChartToPNG with correct chartId for different charts', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockResolvedValue( + undefined + ); + + render( + + ); + + const pngButton = screen.getByTitle('Download as PNG'); + fireEvent.click(pngButton); + + await waitFor(() => { + expect(reportDownloadUtils.exportChartToPNG).toHaveBeenCalledWith( + 'monthly-report-chart', + 'Monthly Report' + ); + }); + }); + + it('handles multiple rapid PNG export clicks gracefully', async () => { + (reportDownloadUtils.exportChartToPNG as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + // Click multiple times rapidly + fireEvent.click(pngButton); + fireEvent.click(pngButton); + fireEvent.click(pngButton); + + // Should only be called once since button is disabled during export + await waitFor(() => { + expect(reportDownloadUtils.exportChartToPNG).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Chart Styling and Structure', () => { + it('applies correct CSS classes to chart container', () => { + const { container } = render(); + const chartContainer = container.querySelector('#daily-report-chart'); + + expect(chartContainer).toHaveClass('flex-1'); + expect(chartContainer).toHaveClass('min-w-[300px]'); + expect(chartContainer).toHaveClass('p-4'); + expect(chartContainer).toHaveClass('bg-[#1c1c1c]'); + expect(chartContainer).toHaveClass('rounded-lg'); + expect(chartContainer).toHaveClass('h-[350px]'); + expect(chartContainer).toHaveClass('relative'); + }); + + it('has correct button styling', () => { + render(); + const csvButton = screen.getByTitle('Download as CSV'); + + expect(csvButton).toHaveClass('h-8'); + expect(csvButton).toHaveClass('w-8'); + }); + }); + + describe('Edge Cases', () => { + it('handles data with zero values', () => { + const zeroData = [{ name: 'Today', completed: 0, ongoing: 0 }]; + render(); + + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); + }); + + it('handles large data values', () => { + const largeData = [{ name: 'Today', completed: 9999, ongoing: 8888 }]; + render(); + + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + }); + + it('handles special characters in title', () => { + render( + + ); + + expect( + screen.getByText('Report: Daily & Weekly (2023)') + ).toBeInTheDocument(); + }); + + it('handles special characters in chartId', () => { + const { container } = render( + + ); + + expect( + container.querySelector('#report-chart-2023-11-18') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx new file mode 100644 index 00000000..20c09587 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx @@ -0,0 +1,423 @@ +import { render, screen } from '@testing-library/react'; +import { ReportsView } from '../ReportsView'; +import { Task } from '@/components/utils/types'; + +// Mock ReportChart component +jest.mock('../ReportChart', () => ({ + ReportChart: jest.fn(({ data, title, chartId }) => ( +
+

{title}

+
{JSON.stringify(data)}
+
+ )), +})); + +// Use the real implementation +jest.mock('@/components/utils/utils', () => { + const actual = jest.requireActual('@/components/utils/utils'); + return { + ...actual, + getStartOfDay: (date: Date) => { + const newDate = new Date(date); + newDate.setHours(0, 0, 0, 0); + return newDate; + }, + }; +}); + +describe('ReportsView', () => { + // Helper function to create mock tasks + const createTask = ( + id: number, + status: string, + modified?: string, + due?: string + ): Task => ({ + id, + status, + modified: modified || '', + due: due || '', + description: `Task ${id}`, + project: '', + tags: [], + uuid: `uuid-${id}`, + urgency: 0, + priority: '', + end: '', + entry: '', + email: '', + start: '', + wait: '', + depends: [], + rtype: '', + recur: '', + }); + + beforeEach(() => { + // Mock the current date to a fixed date for consistent testing + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-11-18T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('Rendering', () => { + it('renders all three report charts', () => { + const tasks: Task[] = []; + render(); + + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + expect(screen.getByText('Weekly Report')).toBeInTheDocument(); + expect(screen.getByText('Monthly Report')).toBeInTheDocument(); + }); + + it('renders with correct chart IDs', () => { + const tasks: Task[] = []; + render(); + + expect( + screen.getByTestId('report-chart-daily-report-chart') + ).toBeInTheDocument(); + expect( + screen.getByTestId('report-chart-weekly-report-chart') + ).toBeInTheDocument(); + expect( + screen.getByTestId('report-chart-monthly-report-chart') + ).toBeInTheDocument(); + }); + + it('applies correct CSS classes to container', () => { + const tasks: Task[] = []; + const { container } = render(); + + const mainDiv = container.firstChild; + expect(mainDiv).toHaveClass('flex'); + expect(mainDiv).toHaveClass('flex-wrap'); + expect(mainDiv).toHaveClass('gap-4'); + expect(mainDiv).toHaveClass('justify-center'); + expect(mainDiv).toHaveClass('mt-10'); + }); + }); + + describe('Data Calculation - Daily Report', () => { + it('counts completed and ongoing tasks for today', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', today), + createTask(2, 'completed', today), + createTask(3, 'pending', today), + createTask(4, 'pending', today), + createTask(5, 'pending', today), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'Today', + completed: 2, + ongoing: 3, + }); + }); + + it('excludes tasks from yesterday', () => { + const yesterday = '2023-11-17T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', yesterday), + createTask(2, 'pending', yesterday), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'Today', + completed: 0, + ongoing: 0, + }); + }); + + it('uses due date when modified date is not available', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', '', today), + createTask(2, 'pending', '', today), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(1); + expect(data[0].ongoing).toBe(1); + }); + + it('excludes tasks without modified or due dates', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', today), + createTask(2, 'completed', '', ''), // No dates + createTask(3, 'pending', today), + createTask(4, 'pending', '', ''), // No dates + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'Today', + completed: 1, + ongoing: 1, + }); + }); + + it('ignores deleted tasks', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', today), + createTask(2, 'pending', today), + createTask(3, 'deleted', today), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'Today', + completed: 1, + ongoing: 1, + }); + }); + }); + + describe('Data Calculation - Weekly Report', () => { + it('counts tasks from the beginning of the week', () => { + // Sunday (start of week in this implementation) + const sunday = '2023-11-12T12:00:00.000Z'; + const monday = '2023-11-13T12:00:00.000Z'; + const today = '2023-11-18T12:00:00.000Z'; // Saturday + + const tasks: Task[] = [ + createTask(1, 'completed', sunday), + createTask(2, 'completed', monday), + createTask(3, 'completed', today), + createTask(4, 'pending', sunday), + createTask(5, 'pending', today), + ]; + + render(); + + const weeklyData = screen.getByTestId('chart-data-weekly-report-chart'); + const data = JSON.parse(weeklyData.textContent || '[]'); + + expect(data[0].name).toBe('This Week'); + expect(data[0].completed).toBe(3); + expect(data[0].ongoing).toBe(2); + }); + + it('excludes tasks from last week', () => { + const lastWeek = '2023-11-11T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', lastWeek), + createTask(2, 'pending', lastWeek), + ]; + + render(); + + const weeklyData = screen.getByTestId('chart-data-weekly-report-chart'); + const data = JSON.parse(weeklyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'This Week', + completed: 0, + ongoing: 0, + }); + }); + }); + + describe('Data Calculation - Monthly Report', () => { + it('counts tasks from the beginning of the month', () => { + const startOfMonth = '2023-11-01T12:00:00.000Z'; + const midMonth = '2023-11-15T12:00:00.000Z'; + const today = '2023-11-18T12:00:00.000Z'; + + const tasks: Task[] = [ + createTask(1, 'completed', startOfMonth), + createTask(2, 'completed', midMonth), + createTask(3, 'completed', today), + createTask(4, 'pending', startOfMonth), + createTask(5, 'pending', midMonth), + createTask(6, 'pending', today), + ]; + + render(); + + const monthlyData = screen.getByTestId('chart-data-monthly-report-chart'); + const data = JSON.parse(monthlyData.textContent || '[]'); + + expect(data[0].name).toBe('This Month'); + expect(data[0].completed).toBe(3); + expect(data[0].ongoing).toBe(3); + }); + + it('excludes tasks from last month', () => { + const lastMonth = '2023-10-31T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', lastMonth), + createTask(2, 'pending', lastMonth), + ]; + + render(); + + const monthlyData = screen.getByTestId('chart-data-monthly-report-chart'); + const data = JSON.parse(monthlyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'This Month', + completed: 0, + ongoing: 0, + }); + }); + }); + + describe('Edge Cases', () => { + it('handles empty tasks array', () => { + const tasks: Task[] = []; + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0]).toEqual({ + name: 'Today', + completed: 0, + ongoing: 0, + }); + }); + + it('handles tasks with invalid date formats', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', 'invalid-date'), + createTask(2, 'pending', today), + createTask(3, 'completed', 'not-a-date'), + ]; + + render(); + + // Should still render without crashing + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + }); + + it('handles very large task counts', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + ...Array.from({ length: 500 }, (_, i) => + createTask(i, 'completed', today) + ), + ...Array.from({ length: 300 }, (_, i) => + createTask(i + 500, 'pending', today) + ), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(500); + expect(data[0].ongoing).toBe(300); + }); + + it('handles tasks with timestamps at different times of day', () => { + const morningToday = '2023-11-18T06:30:00.000Z'; + const noonToday = '2023-11-18T12:00:00.000Z'; + const eveningToday = '2023-11-18T20:45:00.000Z'; + + const tasks: Task[] = [ + createTask(1, 'completed', morningToday), + createTask(2, 'completed', noonToday), + createTask(3, 'pending', eveningToday), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(2); + expect(data[0].ongoing).toBe(1); + }); + + it('prioritizes modified date over due date when both exist', () => { + const today = '2023-11-18T12:00:00.000Z'; + const yesterday = '2023-11-17T12:00:00.000Z'; + + const tasks: Task[] = [ + createTask(1, 'completed', today, yesterday), // modified is today, due is yesterday + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const data = JSON.parse(dailyData.textContent || '[]'); + + // Should count it for today (using modified date) + expect(data[0].completed).toBe(1); + }); + }); + + describe('Data Consistency Across Reports', () => { + it('includes daily tasks in weekly report', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', today), + createTask(2, 'pending', today), + ]; + + render(); + + const dailyData = screen.getByTestId('chart-data-daily-report-chart'); + const weeklyData = screen.getByTestId('chart-data-weekly-report-chart'); + + const daily = JSON.parse(dailyData.textContent || '[]'); + const weekly = JSON.parse(weeklyData.textContent || '[]'); + + // Today's tasks should be in both reports + expect(daily[0].completed).toBe(1); + expect(weekly[0].completed).toBeGreaterThanOrEqual(1); + }); + + it('includes weekly tasks in monthly report', () => { + const today = '2023-11-18T12:00:00.000Z'; + const tasks: Task[] = [ + createTask(1, 'completed', today), + createTask(2, 'pending', today), + ]; + + render(); + + const weeklyData = screen.getByTestId('chart-data-weekly-report-chart'); + const monthlyData = screen.getByTestId('chart-data-monthly-report-chart'); + + const weekly = JSON.parse(weeklyData.textContent || '[]'); + const monthly = JSON.parse(monthlyData.textContent || '[]'); + + // This week's tasks should be in both reports + expect(weekly[0].completed).toBe(1); + expect(monthly[0].completed).toBeGreaterThanOrEqual(1); + }); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Task-Skeleton.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Task-Skeleton.test.tsx new file mode 100644 index 00000000..6f84b8bf --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Task-Skeleton.test.tsx @@ -0,0 +1,285 @@ +import { render } from '@testing-library/react'; +import { Taskskeleton } from '../Task-Skeleton'; + +// Mock Table components +jest.mock('@/components/ui/table', () => ({ + TableCell: ({ children, ...props }: any) => {children}, + TableRow: ({ children, ...props }: any) => {children}, +})); + +describe('Taskskeleton', () => { + describe('Rendering', () => { + it('renders correct number of skeleton rows', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + expect(rows).toHaveLength(5); + }); + + it('renders single skeleton row when count is 1', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + expect(rows).toHaveLength(1); + }); + + it('renders no skeleton rows when count is 0', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + expect(rows).toHaveLength(0); + }); + + it('renders correct number of skeleton rows for large count', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + expect(rows).toHaveLength(20); + }); + + it('renders correct number of cells per row', () => { + const { container } = render(); + const cells = container.querySelectorAll('td'); + expect(cells).toHaveLength(3); // ID, Description, Status + }); + }); + + describe('Styling and Animation', () => { + it('applies animate-pulse class to skeleton rows', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + + rows.forEach((row) => { + expect(row).toHaveClass('animate-pulse'); + }); + }); + + it('applies correct background color to skeleton elements', () => { + const { container } = render(); + const skeletonDivs = container.querySelectorAll('div'); + + // Check that at least one skeleton div has the correct background color + const hasBgClass = Array.from(skeletonDivs).some((div) => + div.className.includes('bg-[#252528]') + ); + expect(hasBgClass).toBe(true); + }); + + it('applies correct padding to cells', () => { + const { container } = render(); + const cells = container.querySelectorAll('td'); + + cells.forEach((cell) => { + expect(cell).toHaveClass('py-3'); + }); + }); + + it('applies rounded corners to skeleton elements', () => { + const { container } = render(); + const skeletonDivs = container.querySelectorAll('div'); + + // Check that elements have appropriate rounded corners + const hasRoundedClasses = Array.from(skeletonDivs).some( + (div) => + div.className.includes('rounded-md') || + div.className.includes('rounded-full') || + div.className.includes('rounded-xl') + ); + expect(hasRoundedClasses).toBe(true); + }); + }); + + describe('Skeleton Structure', () => { + it('renders ID column skeleton with correct dimensions', () => { + const { container } = render(); + const idSkeleton = container.querySelector('td:first-child div'); + + expect(idSkeleton).toHaveClass('h-5'); + expect(idSkeleton).toHaveClass('w-6'); + }); + + it('renders description column with priority indicator and text skeletons', () => { + const { container } = render(); + const descriptionCell = container.querySelector('td:nth-child(2)'); + const skeletonElements = descriptionCell?.querySelectorAll('div'); + + expect(skeletonElements?.length).toBeGreaterThan(1); // Priority + text + badge + }); + + it('renders priority indicator as circular skeleton', () => { + const { container } = render(); + const skeletonDivs = container.querySelectorAll('div'); + + // Check that there's a circular skeleton element (rounded-full) + const hasCircularSkeleton = Array.from(skeletonDivs).some((div) => + div.className.includes('rounded-full') + ); + expect(hasCircularSkeleton).toBe(true); + }); + + it('renders description text skeleton with responsive width', () => { + const { container } = render(); + const descriptionText = container.querySelector( + 'td:nth-child(2) div:nth-child(2)' + ); + + expect(descriptionText).toHaveClass('w-16'); + expect(descriptionText).toHaveClass('md:w-48'); + }); + + it('renders project badge skeleton', () => { + const { container } = render(); + const badgeSkeleton = container.querySelector( + 'td:nth-child(2) div:nth-child(3)' + ); + + expect(badgeSkeleton).toHaveClass('w-12'); + expect(badgeSkeleton).toHaveClass('md:w-16'); + }); + + it('renders status column skeleton with correct dimensions', () => { + const { container } = render(); + const statusSkeleton = container.querySelector('td:last-child div'); + + expect(statusSkeleton).toHaveClass('h-5'); + expect(statusSkeleton).toHaveClass('w-6'); + }); + + it('applies flex layout to description cell content', () => { + const { container } = render(); + const descriptionContent = container.querySelector( + 'td:nth-child(2) > div' + ); + + expect(descriptionContent).toHaveClass('flex'); + expect(descriptionContent).toHaveClass('items-center'); + expect(descriptionContent).toHaveClass('space-x-2'); + }); + }); + + describe('Multiple Rows', () => { + it('renders each row with unique key', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + + // Check that each row is rendered (React would warn if keys weren't unique) + expect(rows).toHaveLength(5); + }); + + it('renders consistent structure across multiple rows', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + + rows.forEach((row) => { + const cells = row.querySelectorAll('td'); + expect(cells).toHaveLength(3); + expect(row).toHaveClass('animate-pulse'); + }); + }); + + it('renders identical skeleton structure for each row', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + + const firstRowHTML = rows[0].innerHTML; + rows.forEach((row) => { + expect(row.innerHTML).toBe(firstRowHTML); + }); + }); + }); + + describe('Edge Cases', () => { + it('handles negative count gracefully', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + expect(rows).toHaveLength(0); + }); + + it('handles very large count', () => { + const { container } = render(); + const rows = container.querySelectorAll('tr'); + expect(rows).toHaveLength(100); + }); + + it('renders fragment wrapper correctly', () => { + const { container } = render(); + + // The component returns a fragment, so the container's first child should be the first tr + expect(container.firstChild?.nodeName).toBe('TR'); + }); + }); + + describe('Accessibility', () => { + it('maintains proper table structure for screen readers', () => { + const { container } = render(); + + // All rows should have cells + const rows = container.querySelectorAll('tr'); + rows.forEach((row) => { + const cells = row.querySelectorAll('td'); + expect(cells.length).toBeGreaterThan(0); + }); + }); + + it('does not contain any interactive elements', () => { + const { container } = render(); + + // Skeleton should not have buttons, links, or inputs + expect(container.querySelectorAll('button')).toHaveLength(0); + expect(container.querySelectorAll('a')).toHaveLength(0); + expect(container.querySelectorAll('input')).toHaveLength(0); + }); + }); + + describe('Visual Consistency', () => { + it('uses consistent color scheme across all skeleton elements', () => { + const { container } = render(); + const skeletonDivs = container.querySelectorAll('div'); + + // Check that skeleton divs with backgrounds use the consistent color + const bgDivs = Array.from(skeletonDivs).filter((div) => + div.className.includes('bg-') + ); + expect(bgDivs.length).toBeGreaterThan(0); + }); + + it('maintains spacing between elements', () => { + const { container } = render(); + const descriptionContent = container.querySelector( + 'td:nth-child(2) > div' + ); + + expect(descriptionContent).toHaveClass('space-x-2'); + }); + + it('uses correct height values for skeleton elements', () => { + const { container } = render(); + const skeletonDivs = container.querySelectorAll('div'); + + // Check that skeleton divs have height classes + const hasHeightClasses = Array.from(skeletonDivs).some( + (div) => div.className.includes('h-4') || div.className.includes('h-5') + ); + expect(hasHeightClasses).toBe(true); + }); + }); + + describe('Responsive Design', () => { + it('applies responsive width classes to description text', () => { + const { container } = render(); + const descriptionText = container.querySelector( + 'td:nth-child(2) div:nth-child(2)' + ); + + // Should have base and md breakpoint widths + expect(descriptionText?.className).toContain('w-16'); + expect(descriptionText?.className).toContain('md:w-48'); + }); + + it('applies responsive width classes to badge skeleton', () => { + const { container } = render(); + const badgeSkeleton = container.querySelector( + 'td:nth-child(2) div:nth-child(3)' + ); + + expect(badgeSkeleton?.className).toContain('w-12'); + expect(badgeSkeleton?.className).toContain('md:w-16'); + }); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 4f3492c9..4426614c 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -1,5 +1,6 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { Tasks } from '../Tasks'; +import * as hooks from '../hooks'; // Mock props for the Tasks component const mockProps = { @@ -7,10 +8,109 @@ const mockProps = { email: 'test@example.com', encryptionSecret: 'mockEncryptionSecret', UUID: 'mockUUID', - isLoading: false, // mock the loading state - setIsLoading: jest.fn(), // mock the setter function + isLoading: false, + setIsLoading: jest.fn(), }; +// Create mock tasks with various properties +const createMockTasks = () => [ + { + id: 1, + description: 'Complete project documentation', + status: 'pending', + project: 'ProjectA', + tags: ['urgent', 'docs'], + uuid: 'uuid-1', + priority: 'H', + due: '2024-01-15', + urgency: 5, + entry: '2024-01-01', + modified: '2024-01-05', + start: '', + wait: '', + end: '', + depends: [], + recur: '', + rtype: '', + }, + { + id: 2, + description: 'Review code changes', + status: 'completed', + project: 'ProjectB', + tags: ['review'], + uuid: 'uuid-2', + priority: 'M', + due: '', + urgency: 3, + entry: '2024-01-02', + modified: '2024-01-06', + start: '', + wait: '', + end: '2024-01-06', + depends: [], + recur: '', + rtype: '', + }, + { + id: 3, + description: 'Fix bug in login', + status: 'pending', + project: 'ProjectA', + tags: ['bug', 'urgent'], + uuid: 'uuid-3', + priority: 'H', + due: '2024-01-10', + urgency: 8, + entry: '2024-01-03', + modified: '2024-01-07', + start: '', + wait: '', + end: '', + depends: [], + recur: '', + rtype: '', + }, + { + id: 4, + description: 'Update dependencies', + status: 'deleted', + project: 'ProjectC', + tags: ['maintenance'], + uuid: 'uuid-4', + priority: 'L', + due: '', + urgency: 1, + entry: '2024-01-04', + modified: '2024-01-08', + start: '', + wait: '', + end: '', + depends: [], + recur: '', + rtype: '', + }, + { + id: 5, + description: 'Write unit tests', + status: 'pending', + project: 'ProjectB', + tags: ['testing'], + uuid: 'uuid-5', + priority: 'M', + due: '2024-01-20', + urgency: 4, + entry: '2024-01-05', + modified: '2024-01-09', + start: '', + wait: '', + end: '', + depends: [], + recur: '', + rtype: '', + }, +]; + // Mock functions and modules jest.mock('react-toastify', () => ({ toast: { @@ -22,9 +122,9 @@ jest.mock('react-toastify', () => ({ jest.mock('../tasks-utils', () => { const originalModule = jest.requireActual('../tasks-utils'); return { - ...originalModule, // Includes all real functions like sortTasksById - markTaskAsCompleted: jest.fn(), - markTaskAsDeleted: jest.fn(), + ...originalModule, + markTaskAsCompleted: jest.fn().mockResolvedValue({}), + markTaskAsDeleted: jest.fn().mockResolvedValue({}), getTimeSinceLastSync: jest .fn() .mockReturnValue('Last updated 5 minutes ago'), @@ -33,8 +133,19 @@ jest.mock('../tasks-utils', () => { }); jest.mock('@/components/ui/multiSelect', () => ({ - MultiSelectFilter: jest.fn(({ title }) => ( -
Mocked MultiSelect: {title}
+ MultiSelectFilter: jest.fn(({ title, onSelectionChange, selectedValues }) => ( +
+ Mocked MultiSelect: {title} + + + {selectedValues.join(',')} + +
)), })); @@ -42,37 +153,40 @@ jest.mock('../../BottomBar/BottomBar', () => { return jest.fn(() =>
Mocked BottomBar
); }); -jest.mock('../hooks', () => ({ - TasksDatabase: jest.fn(() => ({ +jest.mock('../ReportsView', () => ({ + ReportsView: jest.fn(() => ( +
Reports View
+ )), +})); + +// Mock hooks module - must be created inside the mock factory +jest.mock('../hooks', () => { + const mockTasksDatabase = { tasks: { - where: jest.fn(() => ({ - equals: jest.fn(() => ({ - // Mock 12 tasks to test pagination - toArray: jest.fn().mockResolvedValue( - Array.from({ length: 12 }, (_, i) => ({ - id: i + 1, - description: `Task ${i + 1}`, - status: 'pending', - project: i % 2 === 0 ? 'ProjectA' : 'ProjectB', - tags: i % 3 === 0 ? ['tag1'] : ['tag2'], - uuid: `uuid-${i + 1}`, - })) - ), - })), - })), + where: jest.fn(), + bulkPut: jest.fn(), }, - })), - fetchTaskwarriorTasks: jest.fn().mockResolvedValue([]), - addTaskToBackend: jest.fn().mockResolvedValue({}), - editTaskOnBackend: jest.fn().mockResolvedValue({}), -})); + transaction: jest.fn(), + }; + + return { + TasksDatabase: jest.fn(() => mockTasksDatabase), + fetchTaskwarriorTasks: jest.fn(), + addTaskToBackend: jest.fn(), + editTaskOnBackend: jest.fn(), + modifyTaskOnBackend: jest.fn(), + __mockTasksDatabase: mockTasksDatabase, // Export for use in tests + }; +}); jest.mock('../Pagination', () => { return jest.fn((props) => (
- {/* Render props to make them testable */} {props.totalPages} {props.currentPage} +
)); }); @@ -100,48 +214,1149 @@ describe('Tasks Component', () => { beforeEach(() => { localStorageMock.clear(); jest.clearAllMocks(); + + // Setup default mock for database + const mockTasks = createMockTasks(); + const mockDb = (hooks as any).__mockTasksDatabase; + if (mockDb) { + mockDb.tasks.where.mockReturnValue({ + equals: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValue(mockTasks), + delete: jest.fn().mockResolvedValue(undefined), + }), + }); + } + }); + + describe('Basic Rendering', () => { + it('renders tasks component and the mocked BottomBar', async () => { + render(); + expect(screen.getByTestId('tasks')).toBeInTheDocument(); + expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument(); + }); + + it('renders the tasks heading', async () => { + render(); + expect(screen.getByTestId('tasks')).toBeInTheDocument(); + }); + + it('renders Show Reports button', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Show Reports')).toBeInTheDocument(); + }); + }); + + it('shows loading skeleton when isLoading is true', async () => { + render(); + await waitFor(() => { + // Skeleton component should be rendered + const skeletons = screen.getAllByRole('row'); + expect(skeletons.length).toBeGreaterThan(0); + }); + }); + + it('displays tasks when loaded', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + }); + }); + + describe('Reports Toggle', () => { + it('toggles to reports view when button clicked', async () => { + render(); + + const reportButton = await screen.findByText('Show Reports'); + fireEvent.click(reportButton); + + await waitFor(() => { + expect(screen.getByTestId('reports-view')).toBeInTheDocument(); + }); + }); + + it('toggles back to tasks view from reports', async () => { + render(); + + const reportButton = await screen.findByText('Show Reports'); + fireEvent.click(reportButton); + + await waitFor(() => { + expect(screen.getByTestId('reports-view')).toBeInTheDocument(); + }); + + const tasksButton = screen.getByText('Show Tasks'); + fireEvent.click(tasksButton); + + await waitFor(() => { + expect(screen.queryByTestId('reports-view')).not.toBeInTheDocument(); + }); + }); + + it('changes button text when toggling reports', async () => { + render(); + + const reportButton = await screen.findByText('Show Reports'); + expect(reportButton).toBeInTheDocument(); + + fireEvent.click(reportButton); + + await waitFor(() => { + expect(screen.getByText('Show Tasks')).toBeInTheDocument(); + }); + }); + }); + + describe('Search Functionality', () => { + it('renders search input', async () => { + render(); + await waitFor(() => { + const searchInput = screen.getByTestId('task-search-bar'); + expect(searchInput).toBeInTheDocument(); + }); + }); + + it('filters tasks by description when searching', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const searchInput = screen.getByTestId('task-search-bar'); + fireEvent.change(searchInput, { target: { value: 'documentation' } }); + + // Debounce delay + await waitFor( + () => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }, + { timeout: 500 } + ); + }); + + it('shows empty results when no tasks match search', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const searchInput = screen.getByTestId('task-search-bar'); + fireEvent.change(searchInput, { target: { value: 'nonexistent task' } }); + + await waitFor( + () => { + expect( + screen.queryByText('Complete project documentation') + ).not.toBeInTheDocument(); + }, + { timeout: 500 } + ); + }); + + it('updates search term state when input changes', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const searchInput = screen.getByTestId('task-search-bar'); + + // Search for something + fireEvent.change(searchInput, { target: { value: 'bug' } }); + + // Input should have the value + expect(searchInput).toHaveValue('bug'); + }); + }); + + describe('Sorting Functionality', () => { + it('sorts tasks by ID in ascending order', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const idHeader = screen.getByText('ID'); + fireEvent.click(idHeader); + + // Tasks should be sorted by ID ascending + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(0); + }); + + it('sorts tasks by ID in descending order on second click', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const idHeader = screen.getByText('ID'); + + // First click - ascending + fireEvent.click(idHeader); + + // Second click - descending + fireEvent.click(idHeader); + + await waitFor(() => { + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(0); + }); + }); + + it('sorts tasks by status', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const statusHeader = screen.getByText('Status'); + fireEvent.click(statusHeader); + + await waitFor(() => { + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(0); + }); + }); + }); + + describe('Pagination', () => { + it('renders the "Tasks per Page" dropdown with default value', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const dropdown = screen.getByLabelText('Show:'); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveValue('10'); + }); + + it('loads "tasksPerPage" from localStorage on initial render', async () => { + localStorageMock.setItem('mockHashedKey', '20'); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + expect(screen.getByLabelText('Show:')).toHaveValue('20'); + }); + + it('updates pagination when "Tasks per Page" is changed', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('total-pages')).toHaveTextContent('1'); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '5' } }); + + expect(screen.getByTestId('total-pages')).toHaveTextContent('1'); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'mockHashedKey', + '5' + ); + expect(screen.getByTestId('current-page')).toHaveTextContent('1'); + }); + + it('resets to page 1 when changing tasks per page', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '5' } }); + + expect(screen.getByTestId('current-page')).toHaveTextContent('1'); + }); + }); + + describe('Sync Functionality', () => { + it('renders sync button on desktop', async () => { + render(); + + await waitFor(() => { + const syncButtons = screen.getAllByText('Sync'); + expect(syncButtons.length).toBeGreaterThan(0); + }); + }); + + it('calls setIsLoading when sync button clicked', async () => { + const mockSetIsLoading = jest.fn(); + (hooks.fetchTaskwarriorTasks as jest.Mock).mockResolvedValue([]); + + render(); + + await waitFor(() => { + const syncButtons = screen.getAllByText('Sync'); + expect(syncButtons.length).toBeGreaterThan(0); + }); + + const syncButton = screen.getAllByText('Sync')[0]; + fireEvent.click(syncButton); + + expect(mockSetIsLoading).toHaveBeenCalled(); + }); + + it('displays last sync time', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Last updated 5 minutes ago') + ).toBeInTheDocument(); + }); + }); + }); + + describe('Add Task Dialog', () => { + it('renders Add Task button', async () => { + render(); + + await waitFor(() => { + const addButton = screen.getByText('Add Task'); + expect(addButton).toBeInTheDocument(); + }); + }); + + it('Add Task button is clickable and enabled', async () => { + render(); + + const addButton = await screen.findByText('Add Task'); + + // Button should be enabled + expect(addButton).toBeEnabled(); + expect(addButton).not.toBeDisabled(); + }); + }); + + describe('Task Details Dialog', () => { + it('opens task details when task row is clicked', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + + it('displays task details in dialog', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('ID:')).toBeInTheDocument(); + expect(screen.getByText('Description:')).toBeInTheDocument(); + }); + }); + + it('closes task details dialog when Close button is clicked', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + + const closeButtons = screen.getAllByText('Close'); + fireEvent.click(closeButtons[0]); + + await waitFor(() => { + expect(screen.queryByText('Details')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Task Status Display', () => { + it('displays pending tasks with P badge', async () => { + render(); + + await waitFor(() => { + const badges = screen.getAllByText('P'); + expect(badges.length).toBeGreaterThan(0); + }); + }); + + it('displays completed tasks with C badge', async () => { + render(); + + await waitFor(() => { + const badges = screen.getAllByText('C'); + expect(badges.length).toBeGreaterThan(0); + }); + }); + + it('displays deleted tasks with D badge', async () => { + render(); + + await waitFor(() => { + const badges = screen.getAllByText('D'); + expect(badges.length).toBeGreaterThan(0); + }); + }); }); - test('renders tasks component and the mocked BottomBar', async () => { - render(); - expect(screen.getByTestId('tasks')).toBeInTheDocument(); - expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument(); + describe('Task Priority Display', () => { + it('displays high priority indicator for high priority tasks', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + // High priority tasks should have red indicator + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(0); + }); + + it('displays medium priority indicator for medium priority tasks', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Review code changes')).toBeInTheDocument(); + }); + + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(0); + }); + + it('displays low priority indicator for tasks without H or M priority', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Update dependencies')).toBeInTheDocument(); + }); + + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(0); + }); + }); + + describe('Empty State', () => { + it('displays "No tasks found" message when there are no tasks', async () => { + // Mock empty tasks + const mockDb = (hooks as any).__mockTasksDatabase; + if (mockDb) { + mockDb.tasks.where.mockReturnValue({ + equals: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValue([]), + delete: jest.fn().mockResolvedValue(undefined), + }), + }); + } + + render(); + + await waitFor(() => { + expect(screen.getByText('found')).toBeInTheDocument(); + }); + }); + + it('shows add task button in empty state', async () => { + const mockDb = (hooks as any).__mockTasksDatabase; + if (mockDb) { + mockDb.tasks.where.mockReturnValue({ + equals: jest.fn().mockReturnValue({ + toArray: jest.fn().mockResolvedValue([]), + delete: jest.fn().mockResolvedValue(undefined), + }), + }); + } + + render(); + + await waitFor(() => { + expect(screen.getByText('Add Task')).toBeInTheDocument(); + }); + }); }); - test('renders the "Tasks per Page" dropdown with default value', async () => { - render(); + describe('LocalStorage Integration', () => { + it('saves tasksPerPage to localStorage', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '20' } }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'mockHashedKey', + '20' + ); + }); + + it('loads tasksPerPage from localStorage on mount', async () => { + localStorageMock.setItem('mockHashedKey', '5'); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Show:')).toHaveValue('5'); + }); + }); + + it('loads lastSyncTime from localStorage', async () => { + const now = Date.now(); + localStorageMock.setItem('mockHashedKey', now.toString()); + + render(); + + await waitFor(() => { + expect(localStorageMock.getItem).toHaveBeenCalled(); + }); + }); + }); + + describe('Error Handling', () => { + it('handles database fetch errors gracefully', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const mockDb = (hooks as any).__mockTasksDatabase; + if (mockDb) { + mockDb.tasks.where.mockReturnValue({ + equals: jest.fn().mockReturnValue({ + toArray: jest.fn().mockRejectedValue(new Error('Database error')), + }), + }); + } + + render(); + + await waitFor(() => { + expect(consoleError).toHaveBeenCalled(); + }); + + consoleError.mockRestore(); + }); + }); + + describe('Filter Functionality', () => { + it('renders project filter MultiSelect', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('multiselect-projects')).toBeInTheDocument(); + }); + }); + + it('renders tag filter MultiSelect', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('multiselect-tags')).toBeInTheDocument(); + }); + }); + + it('renders status filter MultiSelect', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('multiselect-status')).toBeInTheDocument(); + }); + }); + + it('can trigger project filter selection', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('multiselect-projects')).toBeInTheDocument(); + }); + + const projectButton = screen.getByTestId('multiselect-projects-button'); + fireEvent.click(projectButton); + + // Filter should be triggered + expect(screen.getByTestId('multiselect-projects')).toBeInTheDocument(); + }); + + it('can trigger tag filter selection', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('multiselect-tags')).toBeInTheDocument(); + }); + + const tagButton = screen.getByTestId('multiselect-tags-button'); + fireEvent.click(tagButton); + + expect(screen.getByTestId('multiselect-tags')).toBeInTheDocument(); + }); + + it('can trigger status filter selection', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('multiselect-status')).toBeInTheDocument(); + }); + + const statusButton = screen.getByTestId('multiselect-status-button'); + fireEvent.click(statusButton); + + expect(screen.getByTestId('multiselect-status')).toBeInTheDocument(); + }); + }); + + describe('Task Actions', () => { + it('renders complete button for pending tasks', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + // Complete buttons should be rendered for pending tasks + const taskRow = screen + .getByText('Complete project documentation') + .closest('tr'); + expect(taskRow).toBeInTheDocument(); + }); + + it('renders delete button for tasks', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen + .getByText('Complete project documentation') + .closest('tr'); + expect(taskRow).toBeInTheDocument(); + }); + + it('handles task completion when complete button clicked', async () => { + const { markTaskAsCompleted } = require('../tasks-utils'); + markTaskAsCompleted.mockResolvedValueOnce({}); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + // Find and click a task row to open details + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + + it('handles task deletion when delete button clicked', async () => { + const { markTaskAsDeleted } = require('../tasks-utils'); + markTaskAsDeleted.mockResolvedValueOnce({}); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + }); + + describe('Task Copy Functionality', () => { + it('renders copy button for task UUID', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('UUID:')).toBeInTheDocument(); + }); + }); + + it('displays task UUID in details dialog', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('UUID:')).toBeInTheDocument(); + }); + }); + }); + + describe('Task Properties Display', () => { + it('displays task project in table', async () => { + render(); + + await waitFor(() => { + const projects = screen.getAllByText('ProjectA'); + expect(projects.length).toBeGreaterThan(0); + }); + }); + + it('displays task tags as badges', async () => { + render(); + + await waitFor(() => { + // Tags should be displayed + const tags = screen.getAllByText(/urgent|docs|review|bug|testing/); + expect(tags.length).toBeGreaterThan(0); + }); + }); + + it('displays multiple tags for a single task', async () => { + render(); + + await waitFor(() => { + // Tags should be rendered + const tags = screen.getAllByText(/urgent|docs|review|bug|testing/); + expect(tags.length).toBeGreaterThan(0); + }); + }); + + it('displays task urgency value', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + // Click to open details + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + + it('displays task due date when present', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + + it('handles tasks without due date', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Review code changes')).toBeInTheDocument(); + }); + + // Task 2 has no due date + const taskRow = screen.getByText('Review code changes'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + }); + + describe('Task Table Display', () => { + it('renders table headers', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('ID')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + }); + + it('renders task rows with correct data', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + expect(screen.getByText('Review code changes')).toBeInTheDocument(); + expect(screen.getByText('Fix bug in login')).toBeInTheDocument(); + }); + }); + + it('displays task ID column', async () => { + render(); + + await waitFor(() => { + // Task IDs should be visible + const taskRows = screen.getAllByRole('row'); + expect(taskRows.length).toBeGreaterThan(1); + }); + }); + + it('displays task description column', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + }); + + it('displays task status column with badges', async () => { + render(); + + await waitFor(() => { + // Status badges: P, C, D + expect(screen.getAllByText('P').length).toBeGreaterThan(0); + }); + }); + }); + + describe('Component State Management', () => { + it('initializes with default state', async () => { + render(); + + await waitFor(() => { + // Should render in tasks view (not reports) + expect(screen.queryByTestId('reports-view')).not.toBeInTheDocument(); + }); + }); + + it('maintains state when switching between views', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + // Switch to reports + const reportButton = screen.getByText('Show Reports'); + fireEvent.click(reportButton); + + await waitFor(() => { + expect(screen.getByTestId('reports-view')).toBeInTheDocument(); + }); + + // Switch back to tasks + const tasksButton = screen.getByText('Show Tasks'); + fireEvent.click(tasksButton); + + await waitFor(() => { + // Tasks should still be there + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + }); + + it('preserves current page when changing tasks per page', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '20' } }); + + // Should reset to page 1 + expect(screen.getByTestId('current-page')).toHaveTextContent('1'); + }); + }); + + describe('Loading States', () => { + it('shows skeleton when isLoading is true', async () => { + render(); + + await waitFor(() => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBeGreaterThan(0); + }); + }); + + it('hides skeleton when isLoading is false', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + }); + + it('shows loading state during sync', async () => { + const mockSetIsLoading = jest.fn(); + render(); + + await waitFor(() => { + const syncButtons = screen.getAllByText('Sync'); + expect(syncButtons.length).toBeGreaterThan(0); + }); + + const syncButton = screen.getAllByText('Sync')[0]; + fireEvent.click(syncButton); + + expect(mockSetIsLoading).toHaveBeenCalled(); + }); + }); + + describe('Task Details Fields', () => { + it('displays entry date in task details', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Entry:')).toBeInTheDocument(); + }); + }); + + it('displays modified date in task details', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + // Modified date should be in details + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + }); + + it('displays priority in task details', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Priority:')).toBeInTheDocument(); + }); + }); + + it('displays project in task details', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Project:')).toBeInTheDocument(); + }); + }); + + it('displays tags in task details', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); + + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); + + await waitFor(() => { + expect(screen.getByText('Tags:')).toBeInTheDocument(); + }); + }); + + it('displays urgency in task details', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); - expect(await screen.findByText('Task 12')).toBeInTheDocument(); + const taskRow = screen.getByText('Complete project documentation'); + fireEvent.click(taskRow); - const dropdown = screen.getByLabelText('Show:'); - expect(dropdown).toBeInTheDocument(); - expect(dropdown).toHaveValue('10'); + await waitFor(() => { + expect(screen.getByText('Urgency:')).toBeInTheDocument(); + }); + }); }); - test('loads "tasksPerPage" from localStorage on initial render', async () => { - localStorageMock.setItem('mockHashedKey', '20'); + describe('Responsive Behavior', () => { + it('renders sync button on desktop view', async () => { + render(); - render(); + await waitFor(() => { + const syncButtons = screen.getAllByText('Sync'); + expect(syncButtons.length).toBeGreaterThan(0); + }); + }); - expect(await screen.findByText('Task 1')).toBeInTheDocument(); + it('renders mobile controls', async () => { + render(); - expect(screen.getByLabelText('Show:')).toHaveValue('20'); + await waitFor(() => { + // Mobile controls should be present + expect(screen.getByLabelText('Show:')).toBeInTheDocument(); + }); + }); }); - test('updates pagination when "Tasks per Page" is changed', async () => { - render(); + describe('Data Persistence', () => { + it('persists tasks per page selection', async () => { + render(); - expect(await screen.findByText('Task 12')).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByText('Complete project documentation') + ).toBeInTheDocument(); + }); - expect(screen.getByTestId('total-pages')).toHaveTextContent('2'); + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '20' } }); - const dropdown = screen.getByLabelText('Show:'); - fireEvent.change(dropdown, { target: { value: '5' } }); + // Should call setItem with the new value + expect(localStorageMock.setItem).toHaveBeenCalled(); + }); - expect(screen.getByTestId('total-pages')).toHaveTextContent('3'); + it('retrieves tasks from database on mount', async () => { + const mockDb = (hooks as any).__mockTasksDatabase; - expect(localStorageMock.setItem).toHaveBeenCalledWith('mockHashedKey', '5'); + render(); - expect(screen.getByTestId('current-page')).toHaveTextContent('1'); + await waitFor(() => { + expect(mockDb.tasks.where).toHaveBeenCalled(); + }); + }); }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/report-download-utils.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/report-download-utils.test.ts new file mode 100644 index 00000000..36153e70 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/report-download-utils.test.ts @@ -0,0 +1,451 @@ +import { + exportReportToCSV, + exportChartToPNG, + ReportData, +} from '../report-download-utils'; +import html2canvas from 'html2canvas'; +import { toast } from 'react-toastify'; + +// Mock html2canvas +jest.mock('html2canvas'); + +// Mock react-toastify +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +describe('report-download-utils', () => { + let mockCreateObjectURL: jest.Mock; + let mockRevokeObjectURL: jest.Mock; + let mockClick: jest.Mock; + let mockLink: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock URL methods + mockCreateObjectURL = jest.fn(() => 'mock-url'); + mockRevokeObjectURL = jest.fn(); + global.URL.createObjectURL = mockCreateObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL; + + // Mock link element + mockClick = jest.fn(); + mockLink = { + href: '', + download: '', + style: { display: '' }, + click: mockClick, + }; + + // Mock createElement to return our mock link + const originalCreateElement = document.createElement.bind(document); + jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + if (tagName === 'a') { + return mockLink as any; + } + return originalCreateElement(tagName); + }); + + // Mock appendChild and removeChild + jest.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink); + jest.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('exportReportToCSV', () => { + const mockData: ReportData[] = [ + { name: 'Today', completed: 5, ongoing: 3 }, + ]; + + it('creates blob with correct MIME type', () => { + exportReportToCSV(mockData, 'Daily Report'); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + const blob = mockCreateObjectURL.mock.calls[0][0]; + expect(blob.type).toBe('text/csv;charset=utf-8;'); + }); + + it('generates filename with report type and date', () => { + const dateSpy = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2023-11-18T12:00:00.000Z'); + + exportReportToCSV(mockData, 'Daily Report'); + + expect(mockLink.download).toContain('ccsync-'); + expect(mockLink.download).toContain('daily-report'); + expect(mockLink.download).toContain('2023-11-18'); + expect(mockLink.download).toContain('.csv'); + + dateSpy.mockRestore(); + }); + + it('triggers file download', () => { + exportReportToCSV(mockData, 'Daily Report'); + + expect(document.body.appendChild).toHaveBeenCalled(); + expect(mockClick).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + }); + + it('cleans up URL after download', () => { + exportReportToCSV(mockData, 'Daily Report'); + + expect(mockRevokeObjectURL).toHaveBeenCalledWith('mock-url'); + }); + + it('shows success toast notification', () => { + exportReportToCSV(mockData, 'Daily Report'); + + expect(toast.success).toHaveBeenCalledWith( + 'Daily Report Report exported to CSV successfully!', + { + position: 'bottom-right', + autoClose: 3000, + } + ); + }); + + it('handles multiple data entries', () => { + const multiData: ReportData[] = [ + { name: 'Monday', completed: 5, ongoing: 3 }, + { name: 'Tuesday', completed: 7, ongoing: 2 }, + { name: 'Wednesday', completed: 4, ongoing: 5 }, + ]; + + exportReportToCSV(multiData, 'Weekly Report'); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalled(); + }); + + it('handles empty data array', () => { + exportReportToCSV([], 'Empty Report'); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalled(); + }); + + it('handles zero values in data', () => { + const zeroData: ReportData[] = [ + { name: 'Today', completed: 0, ongoing: 0 }, + ]; + + exportReportToCSV(zeroData, 'Daily Report'); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalled(); + }); + + it('handles large numbers in data', () => { + const largeData: ReportData[] = [ + { name: 'Today', completed: 99999, ongoing: 88888 }, + ]; + + exportReportToCSV(largeData, 'Daily Report'); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalled(); + }); + + it('handles special characters in report name', () => { + exportReportToCSV(mockData, 'Daily & Weekly Report (2023)'); + + expect(toast.success).toHaveBeenCalledWith( + 'Daily & Weekly Report (2023) Report exported to CSV successfully!', + expect.any(Object) + ); + }); + + it('shows error toast on exception', () => { + // Suppress console.error for this test + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Force an error by mocking Blob constructor to throw + const originalBlob = global.Blob; + global.Blob = jest.fn(() => { + throw new Error('Blob creation failed'); + }) as any; + + exportReportToCSV(mockData, 'Daily Report'); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to export CSV. Please try again.', + { + position: 'bottom-right', + autoClose: 3000, + } + ); + + // Restore + global.Blob = originalBlob; + consoleError.mockRestore(); + }); + }); + + describe('exportChartToPNG', () => { + let mockCanvas: any; + let mockElement: HTMLElement; + + beforeEach(() => { + // Mock canvas + mockCanvas = { + toBlob: jest.fn((callback) => { + const blob = new Blob(['mock-image-data'], { type: 'image/png' }); + callback(blob); + }), + }; + + // Mock element + mockElement = document.createElement('div'); + mockElement.id = 'test-chart'; + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + // Mock html2canvas to return mockCanvas + (html2canvas as jest.Mock).mockResolvedValue(mockCanvas); + }); + + it('finds element by ID and creates canvas', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(document.getElementById).toHaveBeenCalledWith('test-chart'); + expect(html2canvas).toHaveBeenCalledWith( + mockElement, + expect.objectContaining({ + backgroundColor: '#1c1c1c', + scale: 2, + logging: false, + useCORS: true, + }) + ); + }); + + it('throws error if element not found', async () => { + // Suppress console.error for this test + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + jest.spyOn(document, 'getElementById').mockReturnValue(null); + + await exportChartToPNG('non-existent-chart', 'Daily Report'); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to export PNG. Please try again.', + { + position: 'bottom-right', + autoClose: 3000, + } + ); + + consoleError.mockRestore(); + }); + + it('creates canvas with correct options', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(html2canvas).toHaveBeenCalledWith( + mockElement, + expect.objectContaining({ + backgroundColor: '#1c1c1c', + scale: 2, + logging: false, + useCORS: true, + }) + ); + }); + + it('converts canvas to blob', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(mockCanvas.toBlob).toHaveBeenCalledWith( + expect.any(Function), + 'image/png' + ); + }); + + it('generates filename with report type and date', async () => { + const dateSpy = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2023-11-18T12:00:00.000Z'); + + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(mockLink.download).toContain('ccsync-'); + expect(mockLink.download).toContain('daily-report'); + expect(mockLink.download).toContain('2023-11-18'); + expect(mockLink.download).toContain('.png'); + + dateSpy.mockRestore(); + }); + + it('triggers file download', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(document.body.appendChild).toHaveBeenCalled(); + expect(mockClick).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + }); + + it('cleans up URL after download', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(mockRevokeObjectURL).toHaveBeenCalledWith('mock-url'); + }); + + it('shows success toast notification', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(toast.success).toHaveBeenCalledWith( + 'Daily Report Report exported to PNG successfully!', + { + position: 'bottom-right', + autoClose: 3000, + } + ); + }); + + it('handles canvas toBlob returning null', async () => { + // Suppress console.error for this test + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockCanvas.toBlob = jest.fn((callback) => callback(null)); + + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to export PNG. Please try again.', + { + position: 'bottom-right', + autoClose: 3000, + } + ); + + consoleError.mockRestore(); + }); + + it('handles html2canvas rejection', async () => { + // Suppress console.error for this test + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + (html2canvas as jest.Mock).mockRejectedValue( + new Error('Canvas creation failed') + ); + + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to export PNG. Please try again.', + { + position: 'bottom-right', + autoClose: 3000, + } + ); + + consoleError.mockRestore(); + }); + + it('handles special characters in chart ID', async () => { + const specialElement = document.createElement('div'); + specialElement.id = 'chart-2023-11-18'; + jest.spyOn(document, 'getElementById').mockReturnValue(specialElement); + + await exportChartToPNG('chart-2023-11-18', 'Report'); + + expect(html2canvas).toHaveBeenCalledWith( + specialElement, + expect.any(Object) + ); + }); + + it('handles special characters in report title', async () => { + await exportChartToPNG('test-chart', 'Report: Daily & Weekly (2023)'); + + expect(toast.success).toHaveBeenCalledWith( + 'Report: Daily & Weekly (2023) Report exported to PNG successfully!', + expect.any(Object) + ); + }); + + it('creates blob with correct MIME type', async () => { + await exportChartToPNG('test-chart', 'Daily Report'); + + expect(mockCanvas.toBlob).toHaveBeenCalledWith( + expect.any(Function), + 'image/png' + ); + }); + }); + + describe('Filename Generation', () => { + const mockData: ReportData[] = [ + { name: 'Today', completed: 5, ongoing: 3 }, + ]; + + it('generates consistent filenames for CSV', () => { + const dateSpy = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2023-11-18T12:00:00.000Z'); + + exportReportToCSV(mockData, 'Daily Report'); + const download1 = mockLink.download; + + // Reset mock link + mockLink.download = ''; + + exportReportToCSV(mockData, 'Daily Report'); + const download2 = mockLink.download; + + expect(download1).toBe(download2); + + dateSpy.mockRestore(); + }); + + it('converts report type to lowercase and replaces spaces with hyphens', () => { + const dateSpy = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2023-11-18T12:00:00.000Z'); + + exportReportToCSV(mockData, 'Weekly Report'); + + expect(mockLink.download).toContain('weekly-report'); + expect(mockLink.download).not.toContain('Weekly Report'); + + dateSpy.mockRestore(); + }); + + it('includes date in YYYY-MM-DD format', () => { + const dateSpy = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2023-11-18T12:00:00.000Z'); + + exportReportToCSV(mockData, 'Daily Report'); + + expect(mockLink.download).toMatch(/\d{4}-\d{2}-\d{2}/); + expect(mockLink.download).toContain('2023-11-18'); + + dateSpy.mockRestore(); + }); + + it('includes "ccsync" prefix in filename', () => { + exportReportToCSV(mockData, 'Daily Report'); + + expect(mockLink.download).toContain('ccsync-'); + }); + }); +});