import { render, screen, fireEvent, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import CustomTimePicker from './CustomTimePicker'; import { useSettingsStore } from '../../store/settingsStore'; import { seedStore, resetAllStores } from '../../../tests/helpers/store'; import { buildSettings } from '../../../tests/helpers/factories'; describe('CustomTimePicker', () => { const onChange = vi.fn(); beforeEach(() => { vi.clearAllMocks(); resetAllStores(); seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) }); }); it('FE-COMP-TIMEPICKER-001: renders without crashing', () => { render(); expect(document.body).toBeTruthy(); }); it('FE-COMP-TIMEPICKER-002: shows value in text input in 24h format', () => { render(); const input = screen.getByRole('textbox'); expect(input).toHaveProperty('value', '14:30'); }); it('FE-COMP-TIMEPICKER-003: shows value in 12h format', () => { seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) }); render(); const input = screen.getByRole('textbox'); expect(input).toHaveProperty('value', '2:30 PM'); }); it('FE-COMP-TIMEPICKER-004: shows raw value while focused', async () => { seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) }); render(); const input = screen.getByRole('textbox'); await userEvent.setup().click(input); expect(input).toHaveProperty('value', '14:30'); }); it('FE-COMP-TIMEPICKER-005: clicking clock icon opens dropdown', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); // Dropdown should show hour and minute display boxes with "10" and "00" expect(screen.getByText('10')).toBeTruthy(); expect(screen.getByText('00')).toBeTruthy(); }); it('FE-COMP-TIMEPICKER-006: hour increment button increases hour', async () => { const user = userEvent.setup(); render(); // Open dropdown const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); // The first empty button inside the dropdown is the hour up chevron const chevrons = screen.getAllByRole('button').filter(b => b.textContent?.trim() === ''); // chevrons[0] is the clock icon, chevrons after that are up/down for hour, up/down for minute await user.click(chevrons[1]); // hour up expect(onChange).toHaveBeenCalledWith('11:00'); }); it('FE-COMP-TIMEPICKER-007: hour decrement button decreases hour', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); const chevrons = screen.getAllByRole('button').filter(b => b.textContent?.trim() === ''); await user.click(chevrons[2]); // hour down expect(onChange).toHaveBeenCalledWith('09:00'); }); it('FE-COMP-TIMEPICKER-008: minute increment steps by 5', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); const chevrons = screen.getAllByRole('button').filter(b => b.textContent?.trim() === ''); await user.click(chevrons[3]); // minute up expect(onChange).toHaveBeenCalledWith('10:05'); }); it('FE-COMP-TIMEPICKER-009: minute increment wraps and carries hour', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); const chevrons = screen.getAllByRole('button').filter(b => b.textContent?.trim() === ''); await user.click(chevrons[3]); // minute up expect(onChange).toHaveBeenCalledWith('11:00'); }); it('FE-COMP-TIMEPICKER-010: hour wraps at 23→0', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); const chevrons = screen.getAllByRole('button').filter(b => b.textContent?.trim() === ''); await user.click(chevrons[1]); // hour up expect(onChange).toHaveBeenCalledWith('00:00'); }); it('FE-COMP-TIMEPICKER-011: clear button calls onChange with empty string', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); const clearBtn = screen.getByText('✕'); await user.click(clearBtn); expect(onChange).toHaveBeenCalledWith(''); }); it('FE-COMP-TIMEPICKER-012: clear button absent when no value', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); expect(screen.queryByText('✕')).toBeNull(); }); it('FE-COMP-TIMEPICKER-013: AM/PM toggle shown in 12h mode', async () => { seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) }); const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); expect(screen.getByText('PM')).toBeTruthy(); }); it('FE-COMP-TIMEPICKER-014: AM/PM toggle hidden in 24h mode', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); expect(screen.queryByText('AM')).toBeNull(); expect(screen.queryByText('PM')).toBeNull(); }); it('FE-COMP-TIMEPICKER-015: AM/PM toggle switches hour', async () => { seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) }); const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); // In 12h mode with value "14:00", there are AM/PM chevrons after hour and minute chevrons const chevrons = screen.getAllByRole('button').filter(b => b.textContent?.trim() === ''); // chevrons: [0]=clock, [1]=hour up, [2]=hour down, [3]=min up, [4]=min down, [5]=ampm up, [6]=ampm down await user.click(chevrons[5]); // AM/PM toggle expect(onChange).toHaveBeenCalledWith('02:00'); }); it('FE-COMP-TIMEPICKER-016: blur normalizes HH:MM input', () => { // "9:05" matches /^\d{1,2}:\d{2}$/ and normalizes the hour to zero-padded render(); const input = screen.getByRole('textbox'); fireEvent.focus(input); fireEvent.blur(input); expect(onChange).toHaveBeenCalledWith('09:05'); }); it('FE-COMP-TIMEPICKER-017: blur normalizes 4-digit HHMM input', () => { render(); const input = screen.getByRole('textbox'); fireEvent.focus(input); fireEvent.blur(input); expect(onChange).toHaveBeenCalledWith('14:30'); }); it('FE-COMP-TIMEPICKER-018: blur normalizes bare hour', () => { render(); const input = screen.getByRole('textbox'); fireEvent.focus(input); fireEvent.blur(input); expect(onChange).toHaveBeenCalledWith('08:00'); }); it('FE-COMP-TIMEPICKER-019: blur normalizes 12h string "5:30 PM"', () => { seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) }); render(); const input = screen.getByRole('textbox'); fireEvent.focus(input); fireEvent.blur(input); expect(onChange).toHaveBeenCalledWith('17:30'); }); it('FE-COMP-TIMEPICKER-020: clicking outside dropdown closes it', async () => { const user = userEvent.setup(); render(); const clockBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === ''); await user.click(clockBtn!); // Verify dropdown is open expect(screen.getByText('10')).toBeTruthy(); // Click outside const outsideEl = document.createElement('div'); document.body.appendChild(outsideEl); await act(async () => { fireEvent.mouseDown(outsideEl); }); document.body.removeChild(outsideEl); // Hour display should be gone (only visible in dropdown) const allText = Array.from(document.querySelectorAll('div')).map(d => d.textContent); // The "10" in the dropdown display box should no longer be rendered as a standalone element expect(screen.queryByText('✕')).toBeNull(); // clear button gone = dropdown closed }); });