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..738dd1e2 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportChart.test.tsx @@ -0,0 +1,209 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ReportChart } from '../ReportChart'; +import * as downloadUtils from '../report-download-utils'; + +jest.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) => ( +
{children}
+ ), + BarChart: ({ children, data }: any) => ( +
+ {children} +
+ ), + Bar: ({ dataKey, fill, name }: any) => ( +
+ ), + XAxis: ({ dataKey }: any) =>
, + YAxis: () =>
, + Tooltip: () =>
, + Legend: () =>
, + CartesianGrid: () =>
, +})); + +jest.mock('../report-download-utils', () => ({ + exportReportToCSV: jest.fn(), + exportChartToPNG: jest.fn().mockResolvedValue(undefined), +})); + +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 chart with title', () => { + render(); + expect(screen.getByText('Daily Report')).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 bar chart with data', () => { + render(); + const barChart = screen.getByTestId('bar-chart'); + expect(barChart).toBeInTheDocument(); + expect(barChart.getAttribute('data-chart-data')).toBe( + JSON.stringify(mockData) + ); + }); + + it('renders responsive container', () => { + render(); + expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); + }); + }); + + describe('Chart Elements', () => { + it('displays completed bar with correct color', () => { + render(); + const completedBar = screen.getByTestId('bar-completed'); + expect(completedBar).toHaveAttribute('data-fill', '#E776CB'); + expect(completedBar).toHaveAttribute('data-name', 'Completed'); + }); + + it('displays ongoing bar with correct color', () => { + render(); + const ongoingBar = screen.getByTestId('bar-ongoing'); + expect(ongoingBar).toHaveAttribute('data-fill', '#5FD9FA'); + expect(ongoingBar).toHaveAttribute('data-name', 'Ongoing'); + }); + + it('renders chart axes', () => { + render(); + expect(screen.getByTestId('x-axis')).toBeInTheDocument(); + expect(screen.getByTestId('y-axis')).toBeInTheDocument(); + }); + + it('renders legend', () => { + render(); + expect(screen.getByTestId('legend')).toBeInTheDocument(); + }); + + it('renders tooltip', () => { + render(); + expect(screen.getByTestId('tooltip')).toBeInTheDocument(); + }); + + it('renders cartesian grid', () => { + render(); + expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument(); + }); + }); + + describe('CSV Export', () => { + it('triggers CSV export when button clicked', () => { + render(); + const csvButton = screen.getByTitle('Download as CSV'); + + fireEvent.click(csvButton); + + expect(downloadUtils.exportReportToCSV).toHaveBeenCalledWith( + mockData, + 'Daily Report' + ); + }); + + it('does not disable CSV button during PNG export', async () => { + (downloadUtils.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); + + expect(csvButton).toBeDisabled(); + }); + }); + + describe('PNG Export', () => { + it('triggers PNG export when button clicked', async () => { + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + await waitFor(() => { + expect(downloadUtils.exportChartToPNG).toHaveBeenCalledWith( + 'daily-report-chart', + 'Daily Report' + ); + }); + }); + + it('shows loading spinner during export', async () => { + (downloadUtils.exportChartToPNG as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + expect(pngButton).toBeDisabled(); + + await waitFor(() => { + expect(downloadUtils.exportChartToPNG).toHaveBeenCalled(); + }); + }); + + it('re-enables button after export completes', async () => { + render(); + const pngButton = screen.getByTitle('Download as PNG'); + + fireEvent.click(pngButton); + + await waitFor(() => { + expect(pngButton).not.toBeDisabled(); + }); + }); + }); + + describe('Multiple Data Points', () => { + it('handles multiple data entries', () => { + const multiData = [ + { name: 'Week 1', completed: 10, ongoing: 5 }, + { name: 'Week 2', completed: 8, ongoing: 7 }, + { name: 'Week 3', completed: 12, ongoing: 3 }, + ]; + + render(); + + const barChart = screen.getByTestId('bar-chart'); + expect(barChart.getAttribute('data-chart-data')).toBe( + JSON.stringify(multiData) + ); + }); + + it('handles zero values', () => { + const zeroData = [{ name: 'Today', completed: 0, ongoing: 0 }]; + + render(); + + const barChart = screen.getByTestId('bar-chart'); + expect(barChart).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..d8fb482c --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/ReportsView.test.tsx @@ -0,0 +1,244 @@ +import { render, screen } from '@testing-library/react'; +import { ReportsView } from '../ReportsView'; +import { Task } from '@/components/utils/types'; + +jest.mock('../ReportChart', () => ({ + ReportChart: jest.fn(({ title, data, chartId }) => ( +
+

{title}

+
{JSON.stringify(data)}
+
+ )), +})); + +describe('ReportsView', () => { + const createMockTask = (overrides: Partial = {}): Task => ({ + id: 1, + description: 'Test task', + project: 'Test', + tags: [], + status: 'pending', + uuid: 'test-uuid', + urgency: 0, + priority: 'M', + due: '', + start: '', + end: '', + entry: '', + wait: '', + modified: '', + depends: [], + rtype: '', + recur: '', + email: 'test@example.com', + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders all three report charts', () => { + const tasks = [createMockTask()]; + render(); + + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + expect(screen.getByText('Weekly Report')).toBeInTheDocument(); + expect(screen.getByText('Monthly Report')).toBeInTheDocument(); + }); + + it('renders with empty tasks array', () => { + render(); + + expect(screen.getByText('Daily Report')).toBeInTheDocument(); + expect(screen.getByText('Weekly Report')).toBeInTheDocument(); + expect(screen.getByText('Monthly Report')).toBeInTheDocument(); + }); + }); + + describe('Data Calculation', () => { + it('counts completed tasks correctly', () => { + const today = new Date().toISOString(); + const tasks = [ + createMockTask({ status: 'completed', modified: today }), + createMockTask({ status: 'completed', modified: today }), + createMockTask({ status: 'pending', modified: today }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(2); + expect(data[0].ongoing).toBe(1); + }); + + it('counts pending tasks as ongoing', () => { + const today = new Date().toISOString(); + const tasks = [ + createMockTask({ status: 'pending', modified: today }), + createMockTask({ status: 'pending', modified: today }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].ongoing).toBe(2); + expect(data[0].completed).toBe(0); + }); + + it('filters tasks by date range correctly', () => { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const lastWeek = new Date(today); + lastWeek.setDate(lastWeek.getDate() - 8); + + const tasks = [ + createMockTask({ status: 'completed', modified: today.toISOString() }), + createMockTask({ + status: 'completed', + modified: yesterday.toISOString(), + }), + createMockTask({ + status: 'completed', + modified: lastWeek.toISOString(), + }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const weeklyData = screen.getByTestId('weekly-report-chart-data'); + + const daily = JSON.parse(dailyData.textContent || '[]'); + const weekly = JSON.parse(weeklyData.textContent || '[]'); + + expect(daily[0].completed).toBe(1); + expect(weekly[0].completed).toBeGreaterThanOrEqual(2); + }); + + it('uses modified date when available', () => { + const today = new Date().toISOString(); + const tasks = [ + createMockTask({ + status: 'completed', + modified: today, + due: '2020-01-01T00:00:00Z', + }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(1); + }); + + it('falls back to due date when modified is not available', () => { + const today = new Date().toISOString(); + const tasks = [ + createMockTask({ + status: 'completed', + modified: '', + due: today, + }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(1); + }); + + it('excludes tasks without modified or due dates', () => { + const tasks = [ + createMockTask({ + status: 'completed', + modified: '', + due: '', + }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(0); + expect(data[0].ongoing).toBe(0); + }); + + it('handles mixed statuses correctly', () => { + const today = new Date().toISOString(); + const tasks = [ + createMockTask({ status: 'completed', modified: today }), + createMockTask({ status: 'pending', modified: today }), + createMockTask({ status: 'deleted', modified: today }), + createMockTask({ status: 'recurring', modified: today }), + ]; + + render(); + + const dailyData = screen.getByTestId('daily-report-chart-data'); + const data = JSON.parse(dailyData.textContent || '[]'); + + expect(data[0].completed).toBe(1); + expect(data[0].ongoing).toBe(1); + }); + }); + + describe('Time Ranges', () => { + it('correctly identifies weekly date range', () => { + const today = new Date(); + const startOfWeek = new Date(today); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); + + const taskInWeek = new Date(startOfWeek); + taskInWeek.setDate(taskInWeek.getDate() + 1); + + const tasks = [ + createMockTask({ + status: 'completed', + modified: taskInWeek.toISOString(), + }), + ]; + + render(); + + const weeklyData = screen.getByTestId('weekly-report-chart-data'); + const data = JSON.parse(weeklyData.textContent || '[]'); + + expect(data[0].completed).toBeGreaterThanOrEqual(1); + }); + + it('correctly identifies monthly date range', () => { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + + const taskInMonth = new Date(startOfMonth); + taskInMonth.setDate(taskInMonth.getDate() + 5); + + const tasks = [ + createMockTask({ + status: 'completed', + modified: taskInMonth.toISOString(), + }), + ]; + + render(); + + const monthlyData = screen.getByTestId('monthly-report-chart-data'); + const data = JSON.parse(monthlyData.textContent || '[]'); + + expect(data[0].completed).toBe(1); + }); + }); +}); 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..20f81323 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/report-download-utils.test.ts @@ -0,0 +1,260 @@ +import { + exportReportToCSV, + exportChartToPNG, + ReportData, +} from '../report-download-utils'; +import html2canvas from 'html2canvas'; +import { toast } from 'react-toastify'; + +jest.mock('html2canvas'); +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +describe('report-download-utils', () => { + let mockLink: Partial; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + global.URL.revokeObjectURL = jest.fn() as any; + + mockLink = { + href: '', + download: '', + style: {} as CSSStyleDeclaration, + click: jest.fn(), + }; + + const originalCreateElement = document.createElement.bind(document); + jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + if (tagName === 'a') { + return mockLink as HTMLAnchorElement; + } + return originalCreateElement(tagName); + }); + + jest + .spyOn(document.body, 'appendChild') + .mockImplementation(() => null as any); + jest + .spyOn(document.body, 'removeChild') + .mockImplementation(() => null as any); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + describe('exportReportToCSV', () => { + const mockData: ReportData[] = [ + { name: 'Today', completed: 5, ongoing: 3 }, + { name: 'This Week', completed: 10, ongoing: 7 }, + ]; + + it('triggers download and shows success toast', () => { + exportReportToCSV(mockData, 'Daily Report'); + + expect(mockLink.click).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + 'Daily Report Report exported to CSV successfully!', + expect.objectContaining({ + position: 'bottom-right', + autoClose: 3000, + }) + ); + }); + + it('sets correct filename pattern', () => { + exportReportToCSV(mockData, 'Weekly Report'); + + expect(mockLink.download).toMatch( + /ccsync-weekly-report-report-\d{4}-\d{2}-\d{2}\.csv/ + ); + }); + + it('cleans up DOM and URL resources', () => { + exportReportToCSV(mockData, 'Monthly Report'); + + expect(document.body.appendChild).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('handles empty data array gracefully', () => { + exportReportToCSV([], 'Empty Report'); + + expect(toast.success).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + }); + + it('handles special characters in report name', () => { + exportReportToCSV(mockData, 'Report With Spaces'); + + expect(mockLink.download).toMatch( + /ccsync-report-with-spaces-report-\d{4}-\d{2}-\d{2}\.csv/ + ); + }); + + it('creates blob with correct type', () => { + const blobSpy = jest.spyOn(global, 'Blob'); + exportReportToCSV(mockData, 'Test Report'); + + expect(blobSpy).toHaveBeenCalledWith(expect.any(Array), { + type: 'text/csv;charset=utf-8;', + }); + }); + }); + + describe('exportChartToPNG', () => { + const mockCanvas = { + toBlob: jest.fn((callback) => { + callback(new Blob(['fake-image'], { type: 'image/png' })); + }), + }; + + beforeEach(() => { + (html2canvas as jest.Mock).mockResolvedValue(mockCanvas); + }); + + it('finds and processes DOM element', async () => { + const mockElement = document.createElement('div'); + mockElement.id = 'test-chart'; + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + await exportChartToPNG('test-chart', 'Test Report'); + + expect(document.getElementById).toHaveBeenCalledWith('test-chart'); + expect(html2canvas).toHaveBeenCalledWith(mockElement, { + backgroundColor: '#1c1c1c', + scale: 2, + logging: false, + useCORS: true, + }); + }); + + it('triggers download and shows success toast', async () => { + const mockElement = document.createElement('div'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + await exportChartToPNG('chart-id', 'Daily Report'); + + expect(mockLink.click).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + 'Daily Report Report exported to PNG successfully!', + expect.objectContaining({ + position: 'bottom-right', + autoClose: 3000, + }) + ); + }); + + it('sets correct filename pattern', async () => { + const mockElement = document.createElement('div'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + await exportChartToPNG('chart-id', 'Weekly Report'); + + expect(mockLink.download).toMatch( + /ccsync-weekly-report-report-\d{4}-\d{2}-\d{2}\.png/ + ); + }); + + it('cleans up resources after export', async () => { + const mockElement = document.createElement('div'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + await exportChartToPNG('chart-id', 'Report'); + + expect(document.body.removeChild).toHaveBeenCalled(); + expect(global.URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it('shows error when element not found', async () => { + jest.spyOn(document, 'getElementById').mockReturnValue(null); + + await exportChartToPNG('non-existent', 'Report'); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to export PNG. Please try again.', + expect.any(Object) + ); + expect(html2canvas).not.toHaveBeenCalled(); + }); + + it('handles html2canvas errors', async () => { + const mockElement = document.createElement('div'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + (html2canvas as jest.Mock).mockRejectedValue(new Error('Canvas error')); + + await exportChartToPNG('chart-id', 'Report'); + + expect(toast.error).toHaveBeenCalledWith( + 'Failed to export PNG. Please try again.', + expect.objectContaining({ + position: 'bottom-right', + }) + ); + }); + + it('handles canvas.toBlob failure', async () => { + const mockElement = document.createElement('div'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + const failingCanvas = { + toBlob: jest.fn((callback) => callback(null)), + }; + (html2canvas as jest.Mock).mockResolvedValue(failingCanvas); + + await exportChartToPNG('chart-id', 'Report'); + + expect(toast.error).toHaveBeenCalled(); + }); + + it('creates PNG blob correctly', async () => { + const mockElement = document.createElement('div'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + await exportChartToPNG('chart-id', 'Report'); + + expect(mockCanvas.toBlob).toHaveBeenCalledWith( + expect.any(Function), + 'image/png' + ); + }); + }); + + describe('Helper Functions', () => { + const mockData: ReportData[] = [{ name: 'Test', completed: 1, ongoing: 2 }]; + + it('generates date-stamped filenames', () => { + exportReportToCSV(mockData, 'Test Report'); + + const filename = mockLink.download as string; + expect(filename).toContain('ccsync-'); + expect(filename).toContain('-report-'); + expect(filename).toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + it('converts spaces to hyphens in filenames', () => { + exportReportToCSV(mockData, 'My Test Report'); + + expect(mockLink.download).toContain('my-test-report'); + }); + + it('uses lowercase for report type in filename', () => { + exportReportToCSV(mockData, 'DAILY REPORT'); + + expect(mockLink.download).toContain('daily-report'); + }); + }); +});