Files
TREK/client/src/components/Settings/DisplaySettingsTab.test.tsx
T

486 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(<DisplaySettingsTab />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-002: shows the language & region section title', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText('Language & region')).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-006: shows Language section', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText('Language')).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-007: shows Time Format section', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText('Time Format')).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-010: shows 24h time format option', () => {
render(<DisplaySettingsTab />);
// Label is "24h (14:30)"
expect(screen.getByText(/24h/i)).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-011: shows 12h time format option', () => {
render(<DisplaySettingsTab />);
// 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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
// "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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
// 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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
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(<DisplaySettingsTab />);
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(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(screen.getByRole('button', { name: /Sunday/ }));
await user.click(await screen.findByText('Saturday'));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});