Files
TREK/client/src/components/Settings/MapSettingsTab.test.tsx
T
Maurice e28bd2fc99 fix(maps): draw with Leaflet wherever WebGL2 is missing
MapLibre 6 and Mapbox GL 3 both throw out of their Map constructor without
WebGL2. Every place that builds a GL map now asks one helper first,
components/Map/glSupport.ts, and a browser without WebGL2 gets the Leaflet
map with the user's tiles instead of a map that fails to start:

- the trip map (MapViewAuto) and the journey maps (JourneyMapAuto) pick
  their renderer through useGlRenderer, which folds in the Mapbox token
  rule they each had;
- the settings preview, desktop and phone, shows the Leaflet preview
  through useMapSettingsForm and says why under it;
- the tour planner keeps the 2D map when 3D is asked for and says that
  3D needs WebGL2;
- the PDF route map and the Leaflet vector basemap already fell back and
  now report a GPU failure too.

Behind the probe, buildGlMap wraps each constructor (MapViewGL,
JourneyMapGL, the settings preview, the terrain map, the PDF map). A GPU
that claims WebGL2 and still refuses the context is reported through
utils/webgl, which turns the answer to no and tells every mounted map,
so they switch to Leaflet without a reload; any other error is still
thrown. Two new strings, map.webgl2.fallback and map.webgl2.terrain, in
all 27 locales.
2026-10-24 22:41:56 +00:00

450 lines
18 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-MAP-001 to FE-COMP-MAP-035
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
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 { ToastContainer } from '../shared/Toast';
import MapSettingsTab from './MapSettingsTab';
// Mock MapView to avoid Leaflet DOM issues in jsdom. tileUrl is surfaced because
// the preview has to draw the basemap being configured, key included.
vi.mock('../Map/MapView', () => ({
MapView: ({ onMapClick, tileUrl }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void; tileUrl?: string }) => (
<div data-testid="map-view" data-tile-url={tileUrl} onClick={() => onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} />
),
}));
// The GL preview boots a real mapbox/maplibre instance; the tab only cares that
// it gets the right provider/style, so render those as data attributes.
vi.mock('./MapboxPreview', () => ({
default: ({ provider, style, token, enable3d, quality }: {
provider?: string; style: string; token?: string; enable3d: boolean; quality?: boolean;
}) => (
<div
data-testid="gl-preview"
data-provider={provider}
data-style={style}
data-token={token}
data-3d={String(enable3d)}
data-quality={String(quality)}
/>
),
}));
// jsdom has no WebGL2; these cases are about a browser that has it.
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => true,
}));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: '' }),
updateSettings: vi.fn().mockResolvedValue(undefined),
});
});
describe('MapSettingsTab', () => {
it('FE-COMP-MAP-001: renders without crashing', () => {
render(<MapSettingsTab />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-MAP-002: shows the Map section title', () => {
render(<MapSettingsTab />);
expect(screen.getByText('Map')).toBeInTheDocument();
});
it('FE-COMP-MAP-003: shows the map template label', () => {
render(<MapSettingsTab />);
expect(screen.getByText('Map Template')).toBeInTheDocument();
});
it('FE-COMP-MAP-004: no longer offers a default map centre — each map frames its own places', () => {
render(<MapSettingsTab />);
expect(screen.queryByText('Latitude')).not.toBeInTheDocument();
expect(screen.queryByText('Longitude')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-009: tile URL text input is shown', () => {
render(<MapSettingsTab />);
const tileInput = screen.getByPlaceholderText(/openstreetmap/i);
expect(tileInput).toBeInTheDocument();
});
it('FE-COMP-MAP-010: typing a custom tile URL updates the text input', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
const tileInput = screen.getByPlaceholderText(/openstreetmap/i);
await user.clear(tileInput);
// Escape curly braces so userEvent doesn't treat them as special keys
await user.type(tileInput, 'https://custom.tiles/{{z}/{{x}/{{y}.png');
expect(screen.getByDisplayValue('https://custom.tiles/{z}/{x}/{y}.png')).toBeInTheDocument();
});
it('FE-COMP-MAP-011: clicking the Save Map button calls updateSettings', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: '' }),
updateSettings,
});
render(<MapSettingsTab />);
await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
map_tile_url: expect.any(String),
map_provider: expect.any(String),
}));
});
it('FE-COMP-MAP-012: Save Map no longer writes a default centre or zoom', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: '' }),
updateSettings,
});
render(<MapSettingsTab />);
await user.click(screen.getByText('Save Map'));
const saved = updateSettings.mock.calls[0][0];
expect(saved).not.toHaveProperty('default_lat');
expect(saved).not.toHaveProperty('default_lng');
expect(saved).not.toHaveProperty('default_zoom');
});
it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockReturnValue(new Promise(() => {}));
seedStore(useSettingsStore, {
settings: buildSettings(),
updateSettings,
});
render(<MapSettingsTab />);
await user.click(screen.getByText('Save Map'));
const saveBtn = screen.getByText('Save Map').closest('button')!;
expect(saveBtn).toBeDisabled();
});
it('FE-COMP-MAP-014: Save Map error shows a toast', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockRejectedValue(new Error('Save failed'));
seedStore(useSettingsStore, {
settings: buildSettings(),
updateSettings,
});
render(<><ToastContainer /><MapSettingsTab /></>);
await user.click(screen.getByText('Save Map'));
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
it('FE-COMP-MAP-016: preset dropdown is rendered', () => {
render(<MapSettingsTab />);
expect(screen.getByText('Select template...')).toBeInTheDocument();
});
it('FE-COMP-MAP-017: settings update from store syncs local state', async () => {
const { rerender } = render(<MapSettingsTab />);
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: 'https://custom.tiles/{z}/{x}/{y}.png' }),
});
rerender(<MapSettingsTab />);
await waitFor(() => {
expect(screen.getByDisplayValue('https://custom.tiles/{z}/{x}/{y}.png')).toBeInTheDocument();
});
});
});
// ── Provider switching and GL settings (018–030) ──────────────────────────────
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
/** The style preset picker sitting above the raw style input. */
function styleDropdown(): HTMLElement {
return screen.getByText('Map Style').closest('div')!.querySelector('div.relative') as HTMLElement;
}
/** The ToggleSwitch belonging to a labelled GL option row. */
function toggleFor(label: string): HTMLElement {
let el: HTMLElement | null = screen.getByText(label);
while (el && !el.querySelector('button')) el = el.parentElement;
return el!.querySelector('button') as HTMLElement;
}
describe('MapSettingsTab – GL providers', () => {
it('FE-COMP-MAP-018: picking Mapbox GL reveals the token field, the style picker and the preview', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
expect(screen.getByText('Mapbox Access Token')).toBeInTheDocument();
expect(screen.getByText('Map Style')).toBeInTheDocument();
expect(screen.queryByText('Map Template')).not.toBeInTheDocument();
// The preview is lazy-loaded, so the first read of it has to await the chunk.
expect(await screen.findByTestId('gl-preview')).toHaveAttribute('data-provider', 'mapbox-gl');
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-COMP-MAP-019: the style picker lists presets with their tag chips and applies the pick', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
const dropdown = styleDropdown();
await user.click(within(dropdown).getByRole('button'));
expect(within(dropdown).getByText('Satellite Streets')).toBeInTheDocument();
expect(within(dropdown).getAllByText('Apple-like').length).toBeGreaterThan(0);
await user.click(within(dropdown).getByText('Streets'));
expect(screen.getByDisplayValue('mapbox://styles/mapbox/streets-v12')).toBeInTheDocument();
expect(within(dropdown).queryByText('Satellite Streets')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-020: a mousedown outside the style picker closes it', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
const dropdown = styleDropdown();
await user.click(within(dropdown).getByRole('button'));
expect(within(dropdown).getByText('Satellite Streets')).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(within(dropdown).queryByText('Satellite Streets')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-021: a custom style falls back to the picker placeholder', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
const styleInput = screen.getByDisplayValue(MAPBOX_STANDARD);
await user.clear(styleInput);
await user.type(styleInput, 'mapbox://styles/me/custom');
expect(within(styleDropdown()).getByText('Select a Mapbox style')).toBeInTheDocument();
});
it('FE-COMP-MAP-022: MapLibre needs no token and swaps in the OpenFreeMap presets', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('MapLibre GL'));
expect(screen.queryByText('Mapbox Access Token')).not.toBeInTheDocument();
expect(screen.queryByText('3D Buildings & Terrain')).not.toBeInTheDocument();
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
expect(screen.getByText('Preset or OpenFreeMap style URL. OpenFreeMap styles work without a token.')).toBeInTheDocument();
expect(await screen.findByTestId('gl-preview')).toHaveAttribute('data-provider', 'maplibre-gl');
});
it('FE-COMP-MAP-023: switching from MapLibre back to Mapbox drops the OpenFreeMap style', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('MapLibre GL'));
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
await user.click(screen.getByText('Mapbox GL'));
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-COMP-MAP-024: switching back to Leaflet restores the tile-template fields', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
await user.click(screen.getByText('Leaflet'));
expect(screen.getByText('Map Template')).toBeInTheDocument();
expect(screen.queryByTestId('gl-preview')).not.toBeInTheDocument();
expect(screen.getByTestId('map-view')).toBeInTheDocument();
});
it('FE-COMP-MAP-025: the token typed into the field reaches the preview', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
await user.type(screen.getByPlaceholderText('pk.eyJ1Ijoi...'), 'pk.token');
expect(await screen.findByTestId('gl-preview')).toHaveAttribute('data-token', 'pk.token');
});
it('FE-COMP-MAP-026: the 3D and quality toggles drive the preview', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
await screen.findByTestId('gl-preview');
const preview = () => screen.getByTestId('gl-preview');
expect(preview()).toHaveAttribute('data-3d', 'true');
expect(preview()).toHaveAttribute('data-quality', 'false');
await user.click(toggleFor('3D Buildings & Terrain'));
expect(preview()).toHaveAttribute('data-3d', 'false');
await user.click(toggleFor('High Quality Mode'));
expect(preview()).toHaveAttribute('data-quality', 'true');
});
it('FE-COMP-MAP-027: saving under Mapbox writes the mapbox_style slot', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSettings });
render(<MapSettingsTab />);
await user.click(screen.getByText('Mapbox GL'));
await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
map_provider: 'mapbox-gl',
mapbox_style: MAPBOX_STANDARD,
}));
expect(updateSettings.mock.calls[0][0]).not.toHaveProperty('maplibre_style');
});
it('FE-COMP-MAP-028: saving under MapLibre writes the maplibre_style slot instead', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSettings });
render(<MapSettingsTab />);
await user.click(screen.getByText('MapLibre GL'));
await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
map_provider: 'maplibre-gl',
maplibre_style: OFM_LIBERTY,
}));
expect(updateSettings.mock.calls[0][0]).not.toHaveProperty('mapbox_style');
});
it('FE-COMP-MAP-029: a stored GL provider is restored from the settings', () => {
seedStore(useSettingsStore, {
settings: buildSettings({
map_provider: 'maplibre-gl',
maplibre_style: 'https://tiles.openfreemap.org/styles/bright',
}),
});
render(<MapSettingsTab />);
expect(screen.getByDisplayValue('https://tiles.openfreemap.org/styles/bright')).toBeInTheDocument();
expect(within(styleDropdown()).getByText('OpenFreeMap Bright')).toBeInTheDocument();
});
it('FE-COMP-MAP-030: picking a Leaflet template fills the tile URL input', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
await user.click(screen.getByText('Select template...'));
await user.click(await screen.findByText('CartoDB Dark'));
expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument();
});
});
// ── CARTO key (031–035) ─────────────────────────────────────────────
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
/** The key input sits under its label inside the field's own wrapper. */
function cartoInput(): HTMLInputElement {
return within(screen.getByText('CARTO API key').closest('div') as HTMLElement).getByRole('textbox');
}
describe('MapSettingsTab – CARTO key', () => {
it('FE-COMP-MAP-031: the key field belongs to Leaflet and goes away with the GL providers', async () => {
const user = userEvent.setup();
render(<MapSettingsTab />);
expect(screen.getByText('CARTO API key')).toBeInTheDocument();
await user.click(screen.getByText('Mapbox GL'));
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-032: a managed instance brings its own key, so the field is hidden', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
render(<MapSettingsTab />);
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
updateSettings,
});
render(<MapSettingsTab />);
await user.type(cartoInput(), 'demo-key');
await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
});
it('FE-COMP-MAP-033b: the preview draws the CARTO basemap being configured, not the app default', async () => {
// The fields hold what the user is editing, not what useTileUrl resolved, so
// the key has to be put back on before the preview reads the template.
// Without it the preview resolves a keyless CARTO url, silently falls back to
// the default vector style, and shows a basemap nobody chose.
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES, carto_api_key: 'demo-key' }),
});
render(<MapSettingsTab />);
await waitFor(() => {
expect(screen.getByTestId('map-view').getAttribute('data-tile-url')).toContain('key=demo-key');
});
await user.clear(cartoInput());
await waitFor(() => {
expect(screen.getByTestId('map-view').getAttribute('data-tile-url')).not.toContain('key=');
});
});
it('FE-COMP-MAP-034: a CARTO template without a key explains the watermark until one is typed', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
updateSettings: vi.fn().mockResolvedValue(undefined),
});
render(<MapSettingsTab />);
expect(screen.getByText(/API KEY REQUIRED/)).toBeInTheDocument();
await user.type(cartoInput(), 'demo-key');
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
});
it('FE-COMP-MAP-035: a template on another host never gets that notice', () => {
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }),
updateSettings: vi.fn().mockResolvedValue(undefined),
});
render(<MapSettingsTab />);
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
});
});