mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
test(client): expand frontend test suite to 69.1% coverage
Add and extend tests across 32 files (+10 595 lines) covering Admin panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat, Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar), Settings (DisplaySettings, Integrations, MapSettings), Files (FileManager, FilesPage), Map, Layout (DemoBanner, InAppNotificationBell), shared pickers (CustomDateTimePicker, CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit stores (authStore, inAppNotificationStore), API (authUrl, client integration), and i18n. Also updates sonar-project.properties and MSW trip handlers to support the new cases.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { render, screen, fireEvent, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CustomDatePicker, CustomDateTimePicker } from './CustomDateTimePicker';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
// ─── CustomDatePicker ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('CustomDatePicker', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-001: renders without crashing', () => {
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
expect(document.body).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-002: shows placeholder when no value', () => {
|
||||
render(<CustomDatePicker value="" onChange={onChange} placeholder="Start Date" />);
|
||||
expect(screen.getByText('Start Date')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-003: shows formatted date when value is set', () => {
|
||||
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
const btn = screen.getByRole('button');
|
||||
// Locale-formatted date should contain "Mar" or "15" or "2026"
|
||||
expect(btn.textContent).toMatch(/Mar|15|2026/);
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-004: clicking button opens calendar portal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
const dayBtns = screen.getAllByRole('button').filter(b => /^\d+$/.test(b.textContent?.trim() ?? ''));
|
||||
expect(dayBtns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-005: clicking a day calls onChange with correct ISO date', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="2026-03-01" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button')); // open March 2026
|
||||
const dayBtn = screen.getAllByRole('button').find(b => b.textContent?.trim() === '15');
|
||||
await user.click(dayBtn!);
|
||||
expect(onChange).toHaveBeenCalledWith('2026-03-15');
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-006: prev month navigation decrements month', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="2026-03-01" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button')); // open March 2026
|
||||
// Nav buttons have no text content (only SVG icons)
|
||||
const emptyBtns = screen.getAllByRole('button').filter(b => b.textContent?.trim() === '');
|
||||
await user.click(emptyBtns[0]); // left chevron = prev month
|
||||
expect(screen.getByText(/february 2026/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-007: next month navigation increments month', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="2026-03-01" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button')); // open March 2026
|
||||
const emptyBtns = screen.getAllByRole('button').filter(b => b.textContent?.trim() === '');
|
||||
await user.click(emptyBtns[emptyBtns.length - 1]); // right chevron = next month
|
||||
expect(screen.getByText(/april 2026/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-008: clear button calls onChange with empty string', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button')); // open
|
||||
const clearBtn = screen.getByText('✕');
|
||||
await user.click(clearBtn);
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-009: clear button absent when no value', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button')); // open
|
||||
expect(screen.queryByText('✕')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-010: clicking outside calendar closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button')); // open
|
||||
// Verify calendar is open (day buttons present)
|
||||
expect(screen.getAllByRole('button').filter(b => /^\d+$/.test(b.textContent?.trim() ?? '')).length).toBeGreaterThan(0);
|
||||
// Fire mousedown outside both the component div and the portal
|
||||
const outsideEl = document.createElement('div');
|
||||
document.body.appendChild(outsideEl);
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(outsideEl);
|
||||
});
|
||||
document.body.removeChild(outsideEl);
|
||||
// Day buttons should be gone
|
||||
expect(screen.getAllByRole('button').filter(b => /^\d+$/.test(b.textContent?.trim() ?? '')).length).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-011: double-click activates text input mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.dblClick(screen.getByRole('button'));
|
||||
expect(screen.getByPlaceholderText('DD.MM.YYYY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-012: text input accepts ISO format YYYY-MM-DD', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.dblClick(screen.getByRole('button'));
|
||||
const input = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.change(input, { target: { value: '2026-07-04' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith('2026-07-04');
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-013: text input accepts EU format DD.MM.YYYY', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.dblClick(screen.getByRole('button'));
|
||||
const input = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.change(input, { target: { value: '04.07.2026' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith('2026-07-04');
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-014: Escape in text input cancels text mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDatePicker value="" onChange={onChange} />);
|
||||
await user.dblClick(screen.getByRole('button'));
|
||||
const input = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(screen.queryByPlaceholderText('DD.MM.YYYY')).toBeNull();
|
||||
expect(screen.getByRole('button')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CustomDateTimePicker ─────────────────────────────────────────────────────
|
||||
|
||||
describe('CustomDateTimePicker', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Use 24h format for predictable time input behavior
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, time_format: '24h' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-015: renders date and time pickers side by side', () => {
|
||||
render(<CustomDateTimePicker value="" onChange={onChange} />);
|
||||
// Date picker renders a trigger button
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThanOrEqual(1);
|
||||
// Time picker renders a text input
|
||||
expect(screen.getByRole('textbox')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-016: setting a date-only value defaults time to 12:00', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomDateTimePicker value="" onChange={onChange} />);
|
||||
// The date trigger is the first button
|
||||
const dateTrigger = screen.getAllByRole('button')[0];
|
||||
await user.click(dateTrigger); // open calendar
|
||||
// Click day 1
|
||||
const day1 = screen.getAllByRole('button').find(b => b.textContent?.trim() === '1');
|
||||
await user.click(day1!);
|
||||
// onChange should have been called with T12:00 suffix
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/T12:00$/));
|
||||
});
|
||||
|
||||
it('FE-COMP-DATEPICKER-017: changing time part preserves date part', () => {
|
||||
render(<CustomDateTimePicker value="2026-06-01T09:30" onChange={onChange} />);
|
||||
const timeInput = screen.getByRole('textbox');
|
||||
fireEvent.change(timeInput, { target: { value: '10:00' } });
|
||||
expect(onChange).toHaveBeenCalledWith('2026-06-01T10:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
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(<CustomTimePicker value="" onChange={onChange} />);
|
||||
expect(document.body).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-TIMEPICKER-002: shows value in text input in 24h format', () => {
|
||||
render(<CustomTimePicker value="14:30" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="14:30" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="14:30" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="10:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="10:00" onChange={onChange} />);
|
||||
// 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(<CustomTimePicker value="10:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="10:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="10:55" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="23:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="10:30" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="14:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="14:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="14:00" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="9:05" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="1430" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="8" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="5:30 PM" onChange={onChange} />);
|
||||
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(<CustomTimePicker value="10:00" onChange={onChange} />);
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user