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-');
+ });
+ });
+});