// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055 import { render, screen, within, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildSettings } from '../../../tests/helpers/factories'; import DisplaySettingsTab from './DisplaySettingsTab'; import { ToastContainer } from '../shared/Toast'; /** The switch of a yes-or-no row, named by the row's label. */ function toggleFor(label: RegExp): HTMLElement { return screen.getByRole('button', { name: label }); } beforeEach(() => { resetAllStores(); server.use( http.put('/api/settings', async () => HttpResponse.json({ success: true })), ); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light', language: 'en' }) }); }); describe('DisplaySettingsTab', () => { it('FE-COMP-DISPLAY-001: renders without crashing', () => { render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-002: shows the language & region section title', () => { render(); expect(screen.getByText('Language & region')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-006: shows Language section', () => { render(); expect(screen.getByText('Language')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-007: shows Time Format section', () => { render(); expect(screen.getByText('Time Format')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-010: shows 24h time format option', () => { render(); // Label is "24h (14:30)" expect(screen.getByText(/24h/i)).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-011: shows 12h time format option', () => { render(); // Label is "12h (2:30 PM)" expect(screen.getByText(/12h/i)).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-015: clicking a language button calls updateSetting with that language code', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting }); render(); await user.click(screen.getByText('Deutsch')); expect(updateSetting).toHaveBeenCalledWith('language', 'de'); }); it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => { seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) }); render(); // "English" is on the chip and on the compact trigger; only the chip is a pressed choice. expect(screen.getByRole('button', { name: 'English', pressed: true })).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-017: shows Temperature section label', () => { render(); expect(screen.getByText(/temperature/i)).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-018: celsius button is active when temperature_unit is celsius', () => { seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }) }); render(); const celsiusBtn = screen.getByText('°C Celsius').closest('button')!; expect(celsiusBtn).toHaveAttribute('aria-pressed', 'true'); }); it('FE-COMP-DISPLAY-019: clicking fahrenheit button calls updateSetting with fahrenheit', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }), updateSetting }); render(); await user.click(screen.getByText('°F Fahrenheit')); expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit'); }); it('FE-COMP-DISPLAY-028: metric distance button is active by default', () => { seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } }); render(); const metricBtn = screen.getByText('km Metric').closest('button')!; expect(metricBtn).toHaveAttribute('aria-pressed', 'true'); }); it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }), updateSetting }); render(); await user.click(screen.getByText('mi Imperial')); expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial'); }); it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting }); render(); // The label is split across a text node ('24h') and a responsive span (' (14:30)'). // Click the button that contains the 24h text instead of matching the full string. await user.click(screen.getByRole('button', { name: /24h/ })); expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); }); it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => { render(); expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-025: blur booking codes switch is on when blur_booking_codes is true', () => { seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: true }) }); render(); expect(toggleFor(/blur booking codes/i)).toHaveAttribute('aria-pressed', 'true'); }); it('FE-COMP-DISPLAY-030: shows Always show booking routes next to Booking route labels', () => { render(); expect(screen.getByText(/always show booking routes/i)).toBeInTheDocument(); // Adjacent rows within the Travel & Map section: the always-show switch // immediately follows the booking-labels switch. const switches = Array.from(document.querySelectorAll('button[aria-label]')); const labels = switches.indexOf(toggleFor(/booking route labels/i)); expect(switches.indexOf(toggleFor(/always show booking routes/i))).toBe(labels + 1); }); it('FE-COMP-DISPLAY-031: always-show-routes switch is off by default (unset)', () => { render(); expect(toggleFor(/always show booking routes/i)).toHaveAttribute('aria-pressed', 'false'); }); it('FE-COMP-DISPLAY-032: switching always-show-routes on calls updateSetting with map_always_show_routes true', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings(), updateSetting }); render(); await user.click(toggleFor(/always show booking routes/i)); expect(updateSetting).toHaveBeenCalledWith('map_always_show_routes', true); }); it('FE-COMP-DISPLAY-026: updateSetting failure shows toast error', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockRejectedValue(new Error('Server error')); seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }), updateSetting }); render(<>); await user.click(screen.getByText('°F Fahrenheit')); expect(await screen.findByText('Server error')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-027: temperature unit local state updates optimistically before API resolves', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockReturnValue(new Promise(() => {})); seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }), updateSetting }); render(); await user.click(screen.getByText('°F Fahrenheit')); const fahrenheitBtn = screen.getByText('°F Fahrenheit').closest('button')!; expect(fahrenheitBtn).toHaveAttribute('aria-pressed', 'true'); }); }); // ── Display currency (033–034) ──────────────────────────────────────────────── function seedFailing(message = 'Server error') { const updateSetting = vi.fn().mockRejectedValue(new Error(message)); seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting }); return updateSetting; } /** The row of one labelled setting: climbs from its label until the row's control is inside. */ function optionBlock(label: RegExp | string): HTMLElement { let el: HTMLElement | null = screen.getByText(label); while (el && !el.querySelector('button')) el = el.parentElement; return el as HTMLElement; } describe('DisplaySettingsTab – Display currency', () => { it('FE-COMP-DISPLAY-055: place names follow the app until another language is picked for them (#1799)', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }), updateSetting }); render(); await user.click(screen.getByRole('button', { name: 'Same as the app' })); // The language grid shows the same name, so the option is the last one on screen. const options = await screen.findAllByText('English'); await user.click(options[options.length - 1]); expect(updateSetting).toHaveBeenCalledWith('place_language', 'en'); expect(updateSetting).not.toHaveBeenCalledWith('language', expect.anything()); }); it('FE-COMP-DISPLAY-033: picking "Trip currency" clears the personal display currency', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }), updateSetting }); render(); await user.click(screen.getByRole('button', { name: /USD/ })); await user.click(await screen.findByText('Trip currency')); expect(updateSetting).toHaveBeenCalledWith('default_currency', ''); }); it('FE-COMP-DISPLAY-034: a rejected currency change surfaces the error', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockRejectedValue(new Error('Currency locked')); seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }), updateSetting }); render(<>); await user.click(screen.getByRole('button', { name: /USD/ })); await user.click(await screen.findByText('Trip currency')); expect(await screen.findByText('Currency locked')).toBeInTheDocument(); }); }); // ── Compact language dropdown (035–039) ─────────────────────────────────────── function mobileLangWrap(): HTMLElement { return optionBlock('Language').querySelector('.sm\\:hidden') as HTMLElement; } describe('DisplaySettingsTab – Compact language picker', () => { it('FE-COMP-DISPLAY-035: the compact trigger shows the active language and opens the list', async () => { const user = userEvent.setup(); render(); const wrap = mobileLangWrap(); expect(within(wrap).getAllByRole('button')).toHaveLength(1); expect(within(wrap).getByText('English')).toBeInTheDocument(); await user.click(within(wrap).getByRole('button')); expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1); }); it('FE-COMP-DISPLAY-036: picking a language from the list saves it and closes the list', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting }); render(); const wrap = mobileLangWrap(); await user.click(within(wrap).getByRole('button')); await user.click(within(wrap).getByText('Deutsch').closest('button')!); expect(updateSetting).toHaveBeenCalledWith('language', 'de'); expect(within(wrap).getAllByRole('button')).toHaveLength(1); }); it('FE-COMP-DISPLAY-037: a rejected pick from the list surfaces the error', async () => { const user = userEvent.setup(); seedFailing('Language locked'); render(<>); const wrap = mobileLangWrap(); await user.click(within(wrap).getByRole('button')); await user.click(within(wrap).getByText('Deutsch').closest('button')!); expect(await screen.findByText('Language locked')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-048: a rejected pick from the desktop grid surfaces the error', async () => { const user = userEvent.setup(); seedFailing('Language locked'); render(<>); await user.click(screen.getByText('Deutsch')); expect(await screen.findByText('Language locked')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-038: a mousedown outside the picker closes it', async () => { const user = userEvent.setup(); render(); const wrap = mobileLangWrap(); await user.click(within(wrap).getByRole('button')); expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1); fireEvent.mouseDown(document.body); expect(within(wrap).getAllByRole('button')).toHaveLength(1); }); it('FE-COMP-DISPLAY-039: a mousedown inside the picker keeps it open', async () => { const user = userEvent.setup(); render(); const wrap = mobileLangWrap(); await user.click(within(wrap).getByRole('button')); fireEvent.mouseDown(within(wrap).getByText('Deutsch')); expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1); }); }); // ── Map & privacy toggles (040–047) ─────────────────────────────────────────── describe('DisplaySettingsTab – Map and privacy toggles', () => { it('FE-COMP-DISPLAY-040: turning booking route labels off persists false', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ map_booking_labels: true }), updateSetting }); render(); await user.click(toggleFor(/booking route labels/i)); expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', false); }); it('FE-COMP-DISPLAY-041: a rejected booking-labels change surfaces the error', async () => { const user = userEvent.setup(); seedFailing('Labels locked'); render(<>); await user.click(toggleFor(/booking route labels/i)); expect(await screen.findByText('Labels locked')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-042: a rejected always-show-routes change surfaces the error', async () => { const user = userEvent.setup(); seedFailing('Routes locked'); render(<>); await user.click(toggleFor(/always show booking routes/i)); expect(await screen.findByText('Routes locked')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-043: the POI pill defaults to On and can be turned off', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings(), updateSetting }); render(); expect(toggleFor(/explore places on the map/i)).toHaveAttribute('aria-pressed', 'true'); await user.click(toggleFor(/explore places on the map/i)); expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false); }); it('FE-COMP-DISPLAY-044: a rejected POI pill change surfaces the error', async () => { const user = userEvent.setup(); seedFailing('POI locked'); render(<>); await user.click(toggleFor(/explore places on the map/i)); expect(await screen.findByText('POI locked')).toBeInTheDocument(); }); it('FE-COMP-DISPLAY-045: turning blur booking codes on persists true', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: false }), updateSetting }); render(); await user.click(toggleFor(/blur booking codes/i)); expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true); }); it('FE-COMP-DISPLAY-046: the accommodation optimisation defaults to On and can be turned off', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings(), updateSetting }); render(); expect(toggleFor(/optimize route from accommodation/i)).toHaveAttribute('aria-pressed', 'true'); await user.click(toggleFor(/optimize route from accommodation/i)); expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false); }); it('FE-COMP-DISPLAY-047: rejected blur, optimisation, distance and time-format changes all toast', async () => { const user = userEvent.setup(); seedFailing('Nope'); render(<>); await user.click(toggleFor(/blur booking codes/i)); await screen.findByText('Nope'); await user.click(toggleFor(/optimize route from accommodation/i)); await user.click(screen.getByText('mi Imperial')); await user.click(screen.getByRole('button', { name: /24h/ })); expect(screen.getAllByText('Nope').length).toBeGreaterThan(0); }); }); describe('DisplaySettingsTab – startup destination', () => { it('FE-COMP-DISPLAY-049: defaults to the dashboard and hides the tab picker', () => { seedStore(useSettingsStore, { settings: buildSettings() }); render(); const block = optionBlock(/^Start page$/); expect(within(block).getByText('Dashboard').closest('button')).toHaveAttribute('aria-pressed', 'true'); // Nothing to pick a tab for while TREK opens on the dashboard. expect(screen.queryByText('Start tab')).not.toBeInTheDocument(); }); it('FE-COMP-DISPLAY-050: choosing the active trip saves it and reveals the tab picker', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings(), updateSetting }); render(); await user.click(within(optionBlock(/^Start page$/)).getByText('Active trip')); expect(updateSetting).toHaveBeenCalledWith('start_page', 'active_trip'); }); it('FE-COMP-DISPLAY-051: with the active trip chosen, the tab picker stores the planner tab id', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'plan' }), updateSetting, }); render(); await user.click(screen.getByRole('button', { name: 'Plan' })); await user.click(await screen.findByText('Costs')); // The German legacy id, not the English label the user sees. expect(updateSetting).toHaveBeenCalledWith('start_trip_tab', 'finanzplan'); }); it('FE-COMP-DISPLAY-052: a rejected start-page change surfaces the error', async () => { const user = userEvent.setup(); seedFailing('Start locked'); render(<>); await user.click(within(optionBlock(/^Start page$/)).getByText('Active trip')); expect(await screen.findByText('Start locked')).toBeInTheDocument(); }); }); // ── Week start (053, 054) ───────────────────────────────────────────────────── describe('DisplaySettingsTab week start (#2029)', () => { it('FE-COMP-DISPLAY-053: shows Monday when unset and saves the day picked', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); seedStore(useSettingsStore, { settings: buildSettings({ week_start: undefined }), updateSetting }); render(); expect(screen.getByText('Week starts on')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Monday/ })); await user.click(await screen.findByText('Sunday')); expect(updateSetting).toHaveBeenCalledWith('week_start', 'sunday'); }); it('FE-COMP-DISPLAY-054: a rejected change surfaces the error', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed')); seedStore(useSettingsStore, { settings: buildSettings({ week_start: 'sunday' }), updateSetting }); render(<>); await user.click(screen.getByRole('button', { name: /Sunday/ })); await user.click(await screen.findByText('Saturday')); expect(await screen.findByText('Save failed')).toBeInTheDocument(); }); });