diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index dcc614ba..6bc5b584 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -2,6 +2,33 @@ import '@testing-library/jest-dom'; import { expect } from '@jest/globals'; import type { Plugin } from 'pretty-format'; +// ResizeObserver polyfill +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +// ScrollIntoView mock +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +// PointerEvent mock +class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || 'mouse'; + } +} +window.PointerEvent = MockPointerEvent as any; +window.HTMLElement.prototype.hasPointerCapture = jest.fn(); +window.HTMLElement.prototype.releasePointerCapture = jest.fn(); + let isSerializing = false; const radixSnapshotSerializer: Plugin = { diff --git a/frontend/src/components/ui/__tests__/date-time-picker.test.tsx b/frontend/src/components/ui/__tests__/date-time-picker.test.tsx new file mode 100644 index 00000000..73f7feb5 --- /dev/null +++ b/frontend/src/components/ui/__tests__/date-time-picker.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTimePicker } from '../date-time-picker'; +import '@testing-library/jest-dom'; + +describe('DateTimePicker', () => { + it('renders without crashing', () => { + const mockOnDateTimeChange = jest.fn(); + render( + + ); + expect( + screen.getByRole('button', { name: /calender-button/i }) + ).toBeInTheDocument(); + }); + + it('opens and closes the popover when the trigger button is clicked', async () => { + const user = userEvent.setup(); + const mockOnDateTimeChange = jest.fn(); + render( + + ); + + const triggerButton = screen.getByRole('button', { + name: /calender-button/i, + }); + + // Open popover + await user.click(triggerButton); + expect(screen.getByRole('dialog')).toBeInTheDocument(); // Popover content is a dialog + expect(screen.getByText(/February 2026/)).toBeInTheDocument(); // Check for specific content inside the calendar + + // Close popover using Escape key + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument(); // Check for absence of specific content + }); + }); + + it('allows selecting a date from the calendar', async () => { + const user = userEvent.setup(); + const mockOnDateTimeChange = jest.fn(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /calender-button/i })); // Open popover + + // Find a date in the current month (e.g., the 15th) + const dateToSelect = screen.getByRole('gridcell', { name: '15' }); + await user.click(dateToSelect); + + // Expect the popover to close after selecting a date + await waitFor(() => { + expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument(); + }); + + // Check if onDateTimeChange was called with the correct date (year, month, and day) + expect(mockOnDateTimeChange).toHaveBeenCalledTimes(1); + const calledDate = mockOnDateTimeChange.mock.calls[0][0]; + expect(calledDate).toBeInstanceOf(Date); + expect(calledDate.getDate()).toBe(15); + expect(calledDate.getMonth()).toBe(new Date().getMonth()); // Assuming current month for simplicity + expect(calledDate.getFullYear()).toBe(new Date().getFullYear()); // Assuming current year for simplicity + expect(calledDate.getHours()).toBe(0); // Should reset time to 00:00:00 + expect(mockOnDateTimeChange.mock.calls[0][1]).toBe(false); // hasTime should be false + }); + + it('allows selecting an hour, minute, and AM/PM', async () => { + const user = userEvent.setup(); + const mockOnDateTimeChange = jest.fn(); + const initialDate = new Date(2024, 0, 15, 10, 30); // Jan 15, 2024, 10:30 AM + render( + + ); + + await user.click(screen.getByRole('button', { name: /calender-button/i })); // Open popover + + // Verify time selection elements are present + expect(screen.getByText('AM')).toBeInTheDocument(); + expect(screen.getByText('PM')).toBeInTheDocument(); + + // Select an hour (e.g., 2 PM) + await user.click(screen.getByRole('button', { name: '2' })); // Select hour 2 + expect(mockOnDateTimeChange).toHaveBeenCalledTimes(1); // One call for hour selection + let calledDate = mockOnDateTimeChange.mock.calls[0][0]; + expect(calledDate.getHours()).toBe(2); // Should be 2 AM initially before PM is clicked + + await user.click(screen.getByRole('button', { name: 'PM' })); // Select PM + expect(mockOnDateTimeChange).toHaveBeenCalledTimes(2); // Second call for AM/PM selection + calledDate = mockOnDateTimeChange.mock.calls[1][0]; + expect(calledDate.getHours()).toBe(14); // 2 PM + expect(mockOnDateTimeChange.mock.calls[1][1]).toBe(true); // hasTime should be true + + // Select a minute (e.g., 45 minutes) + await user.click(screen.getByRole('button', { name: '45' })); + expect(mockOnDateTimeChange).toHaveBeenCalledTimes(3); // Third call for minute selection + calledDate = mockOnDateTimeChange.mock.calls[2][0]; + expect(calledDate.getMinutes()).toBe(45); + expect(mockOnDateTimeChange.mock.calls[2][1]).toBe(true); // hasTime should be true + }); +}); diff --git a/frontend/src/components/ui/__tests__/multi-select-filter.test.tsx b/frontend/src/components/ui/__tests__/multi-select-filter.test.tsx new file mode 100644 index 00000000..b5026549 --- /dev/null +++ b/frontend/src/components/ui/__tests__/multi-select-filter.test.tsx @@ -0,0 +1,229 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MultiSelectFilter } from '../multi-select'; +import '@testing-library/jest-dom'; + +describe('MultiSelectFilter', () => { + const mockOptions = ['Option A', 'Option B', 'Option C']; + const mockOnSelectionChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with title and placeholder when no options selected', () => { + render( + + ); + + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); // The button serves as a combobox trigger + }); + + it('renders with selected values', () => { + render( + + ); + + // Since selected values are visually indicated by checkboxes in the popover (which is closed), + // and potentially by a badge or count on the button (depending on implementation), + // let's check if the component renders without error first. + // Inspecting the component, it doesn't seem to show selected count on the button in this version, just the title. + // So we rely on the internal logic validation in interaction tests or if we open the popover. + // For this render test, ensuring it mounts with props is the baseline. + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render( + + ); + + expect(screen.getByRole('combobox')).toHaveClass('custom-class'); + }); + + it('renders icon when provided', () => { + const icon = Icon; + render( + + ); + + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); + + it('displays completion stats correctly', async () => { + const user = userEvent.setup(); + const completionStats = { + 'Option A': { completed: 5, total: 10, percentage: 50 }, + }; + + render( + + ); + + // Open popover to see options and stats + await user.click(screen.getByRole('combobox')); + + expect(screen.getByText('5/10 tasks, 50%')).toBeInTheDocument(); + }); + + it('opens and closes the popover', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('combobox'); + + // Open + await user.click(button); + expect(screen.getByText('All Test Filter')).toBeInTheDocument(); + + // Close by clicking button again + await user.click(button); + expect(screen.queryByText('All Test Filter')).not.toBeInTheDocument(); + + // Open again + await user.click(button); + expect(screen.getByText('All Test Filter')).toBeInTheDocument(); + + // Close by Escape + await user.keyboard('{Escape}'); + expect(screen.queryByText('All Test Filter')).not.toBeInTheDocument(); + }); + + it('selects and deselects options', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('combobox'); + await user.click(button); + + // Select unselected option + await user.click(screen.getByText('Option B')); + expect(mockOnSelectionChange).toHaveBeenCalledWith([ + 'Option A', + 'Option B', + ]); + + // Deselect selected option + await user.click(screen.getByText('Option A')); + expect(mockOnSelectionChange).toHaveBeenCalledWith([]); + }); + + it('clears selection when "All" option is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('combobox'); + await user.click(button); + + await user.click(screen.getByText('All Test Filter')); + expect(mockOnSelectionChange).toHaveBeenCalledWith([]); + }); + + it('filters options based on search input', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('combobox')); + const searchInput = screen.getByPlaceholderText('Search test filter...'); + + await user.type(searchInput, 'Option A'); + + expect(screen.getByText('Option A')).toBeInTheDocument(); + expect(screen.queryByText('Option B')).not.toBeInTheDocument(); + }); + + it('displays "No results found" for no matches', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('combobox')); + const searchInput = screen.getByPlaceholderText('Search test filter...'); + + await user.type(searchInput, 'Non-existent Option'); + + expect(screen.getByText('No results found.')).toBeInTheDocument(); + }); + + it('search is case-insensitive', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('combobox')); + const searchInput = screen.getByPlaceholderText('Search test filter...'); + + await user.type(searchInput, 'option a'); + + expect(screen.getByText('Option A')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/date-time-picker.tsx b/frontend/src/components/ui/date-time-picker.tsx index 0ee83ab1..d7e43f50 100644 --- a/frontend/src/components/ui/date-time-picker.tsx +++ b/frontend/src/components/ui/date-time-picker.tsx @@ -58,6 +58,10 @@ export const DateTimePicker = React.forwardRef< isInternalUpdate.current = true; onDateTimeChange(newDate, false); + setIsOpen((prev) => { + console.log('Closing popover, prev was:', prev); + return false; + }); } else { setInternalDate(undefined); setHasTime(false); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a575d8bd..148afbb0 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -27,6 +27,9 @@ "@/*": ["./src/*"] } }, - "include": ["src"], + "include": [ + "src", + "src/components/ui/__tests__/multi-select-filter.test.tsx" + ], "references": [{ "path": "./tsconfig.node.json" }] }