// 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();
});
});