mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
3681db6cd4
The map framed its first view for the browser window instead of its own box and then skipped the fit, so a list of cafes across Europe opened on northern France. It now fits the places in its own box with room for the label bar and the controls, fits again when the set of places changes, keeps a view the user moved, opens a single place at town level and keeps a list across the date line on its short arc.
600 lines
21 KiB
TypeScript
600 lines
21 KiB
TypeScript
import { act } from '@testing-library/react';
|
|
import * as maplibregl from 'maplibre-gl';
|
|
import React from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { render } from '../../../tests/helpers/render';
|
|
import { resetAllStores } from '../../../tests/helpers/store';
|
|
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
|
|
import { useSettingsStore } from '../../store/settingsStore';
|
|
|
|
// The planner-waypoint, location-search and antimeridian camera cases of MapViewGL, and
|
|
// the tour map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
|
|
|
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
|
// container is a single element so listeners registered by the component are
|
|
// reachable from tests via dispatchEvent.
|
|
const glCanvasContainer = vi.hoisted(() => document.createElement('div'));
|
|
const glMap = vi.hoisted(() => ({
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
once: vi.fn(),
|
|
loaded: vi.fn().mockReturnValue(true),
|
|
fitBounds: vi.fn(),
|
|
flyTo: vi.fn(),
|
|
jumpTo: vi.fn(),
|
|
getZoom: vi.fn().mockReturnValue(10),
|
|
getCenter: vi.fn(() => ({ lng: 0, lat: 0 })),
|
|
addControl: vi.fn(),
|
|
removeControl: vi.fn(),
|
|
remove: vi.fn(),
|
|
addSource: vi.fn(),
|
|
getSource: vi.fn().mockReturnValue(null),
|
|
addLayer: vi.fn(),
|
|
// Every overlay that adds a layer takes it away again on cleanup; a real
|
|
// MapLibre/Mapbox map has both halves, and a double with only the adding half
|
|
// fails the teardown rather than the behaviour under test.
|
|
removeLayer: vi.fn(),
|
|
removeSource: vi.fn(),
|
|
setLayoutProperty: vi.fn(),
|
|
setFilter: vi.fn(),
|
|
hasImage: vi.fn().mockReturnValue(false),
|
|
addImage: vi.fn(),
|
|
removeImage: vi.fn(),
|
|
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
|
isStyleLoaded: vi.fn().mockReturnValue(true),
|
|
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
|
getContainer: vi.fn(() => glCanvasContainer),
|
|
getLayer: vi.fn().mockReturnValue(null),
|
|
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
|
querySourceFeatures: vi.fn().mockReturnValue([]),
|
|
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
|
|
// The MapLibre place pins position themselves with this, so a mock without it
|
|
// never exercises the path the real map takes.
|
|
project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })),
|
|
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
|
|
easeTo: vi.fn(),
|
|
getCanvas: vi.fn(() => document.createElement('canvas')),
|
|
getBearing: vi.fn(() => 0),
|
|
setTerrain: vi.fn(),
|
|
setConfigProperty: vi.fn(),
|
|
queryTerrainElevation: vi.fn((): number | null => null),
|
|
}));
|
|
|
|
const glBounds = vi.hoisted(() => {
|
|
const state = {
|
|
instances: [] as Array<{ extend: ReturnType<typeof vi.fn> }>,
|
|
};
|
|
return {
|
|
get instances() {
|
|
return state.instances;
|
|
},
|
|
clear: () => {
|
|
state.instances = [];
|
|
},
|
|
create: () => {
|
|
const bounds = {
|
|
extend: vi.fn(() => bounds),
|
|
};
|
|
state.instances.push(bounds);
|
|
return bounds;
|
|
},
|
|
};
|
|
});
|
|
|
|
// Markers and popups are created through the mocked GL constructors, so the
|
|
// tests can reach the very instances the component built (their DOM element,
|
|
// the coordinates they were placed at, the popup content they were given).
|
|
interface FakeMarker {
|
|
element: HTMLElement;
|
|
lngLat: number[] | null;
|
|
setLngLat: (ll: number[]) => FakeMarker;
|
|
getLngLat: () => { lng: number; lat: number };
|
|
addTo: (map: unknown) => FakeMarker;
|
|
remove: () => void;
|
|
getElement: () => HTMLElement;
|
|
}
|
|
|
|
interface FakePopup {
|
|
setLngLat: (ll: unknown) => FakePopup;
|
|
setHTML: (html: string) => FakePopup;
|
|
setText: (text: string) => FakePopup;
|
|
setDOMContent: (node: HTMLElement) => FakePopup;
|
|
addTo: (map: unknown) => FakePopup;
|
|
remove: () => void;
|
|
}
|
|
|
|
const glMarkers = vi.hoisted(() => {
|
|
const created: FakeMarker[] = [];
|
|
return {
|
|
get created() {
|
|
return created;
|
|
},
|
|
clear: () => {
|
|
created.length = 0;
|
|
},
|
|
make: (opts?: { element?: HTMLElement }): FakeMarker => {
|
|
const element = opts?.element ?? document.createElement('div');
|
|
const marker: FakeMarker = {
|
|
element,
|
|
lngLat: null,
|
|
setLngLat: vi.fn((ll: number[]) => {
|
|
marker.lngLat = ll;
|
|
return marker;
|
|
}),
|
|
getLngLat: vi.fn(() => ({ lng: Number(marker.lngLat?.[0] ?? 0), lat: Number(marker.lngLat?.[1] ?? 0) })),
|
|
addTo: vi.fn((_map: unknown) => marker),
|
|
remove: vi.fn(),
|
|
getElement: vi.fn(() => element),
|
|
};
|
|
created.push(marker);
|
|
return marker;
|
|
},
|
|
};
|
|
});
|
|
|
|
const glPopup = vi.hoisted((): FakePopup => {
|
|
const popup: FakePopup = {
|
|
setLngLat: vi.fn((_ll: unknown) => popup),
|
|
setHTML: vi.fn((_html: string) => popup),
|
|
setText: vi.fn((_text: string) => popup),
|
|
setDOMContent: vi.fn((_node: HTMLElement) => popup),
|
|
addTo: vi.fn((_map: unknown) => popup),
|
|
remove: vi.fn(),
|
|
};
|
|
return popup;
|
|
});
|
|
|
|
vi.mock('mapbox-gl', () => ({
|
|
default: {
|
|
accessToken: '',
|
|
Map: vi.fn(function () {
|
|
return glMap;
|
|
}),
|
|
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
|
return glMarkers.make(opts);
|
|
}),
|
|
LngLatBounds: vi.fn(function () {
|
|
return glBounds.create();
|
|
}),
|
|
NavigationControl: vi.fn(),
|
|
Popup: vi.fn(function () {
|
|
return glPopup;
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
|
|
|
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
|
vi.mock('maplibre-gl', () => ({
|
|
Map: vi.fn(function () {
|
|
return glMap;
|
|
}),
|
|
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
|
return glMarkers.make(opts);
|
|
}),
|
|
LngLatBounds: vi.fn(function () {
|
|
return glBounds.create();
|
|
}),
|
|
NavigationControl: vi.fn(),
|
|
Popup: vi.fn(function () {
|
|
return glPopup;
|
|
}),
|
|
}));
|
|
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
|
|
|
vi.mock('./mapboxSetup', () => ({
|
|
isStandardFamily: vi.fn(() => false),
|
|
supportsCustom3d: vi.fn(() => false),
|
|
wantsTerrain: vi.fn(() => false),
|
|
addCustom3dBuildings: vi.fn(),
|
|
addTerrainAndSky: vi.fn(),
|
|
}));
|
|
|
|
const locationMarker = vi.hoisted(() => ({ update: vi.fn(), destroy: vi.fn() }));
|
|
|
|
vi.mock('./locationMarkerMapbox', () => ({
|
|
attachLocationMarker: vi.fn(() => locationMarker),
|
|
}));
|
|
|
|
const reservationOverlay = vi.hoisted(() => ({
|
|
update: vi.fn((_reservations: unknown, _opts: unknown, _routes: unknown) => {}),
|
|
destroy: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./reservationsMapbox', () => ({
|
|
ReservationMapboxOverlay: vi.fn(function () {
|
|
return reservationOverlay;
|
|
}),
|
|
}));
|
|
|
|
const geoStub = vi.hoisted(() => ({
|
|
position: null as GeoPosition | null,
|
|
mode: 'off' as TrackingMode,
|
|
error: null as string | null,
|
|
errorCode: null as string | null,
|
|
cycleMode: vi.fn(async () => {}),
|
|
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
|
|
}));
|
|
|
|
vi.mock('../../hooks/useGeolocation', () => ({
|
|
useGeolocation: vi.fn(() => geoStub),
|
|
}));
|
|
|
|
vi.mock('../../services/photoService', () => ({
|
|
getCached: vi.fn(() => null),
|
|
isLoading: vi.fn(() => false),
|
|
fetchPhoto: vi.fn(),
|
|
onThumbReady: vi.fn(() => () => {}),
|
|
getAllThumbs: vi.fn(() => ({})),
|
|
}));
|
|
|
|
import mapboxgl from 'mapbox-gl';
|
|
import * as photoService from '../../services/photoService';
|
|
import { groupFerratas } from '../Ferratas/ferrataGroups';
|
|
import { MapViewGL as MapViewGLWithEngine } from './MapViewGL';
|
|
import * as mapboxSetup from './mapboxSetup';
|
|
|
|
// The engine is a prop, not a module import (see MapViewGL.test.tsx). This shim makes
|
|
// the same choice glLazy.tsx makes in production.
|
|
function MapViewGL(props: Omit<React.ComponentProps<typeof MapViewGLWithEngine>, 'gl'>) {
|
|
return <MapViewGLWithEngine {...props} gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl} />;
|
|
}
|
|
|
|
function pointer(type: string, x: number, y: number): Event {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
Object.defineProperties(event, {
|
|
button: { value: 0 },
|
|
clientX: { value: x },
|
|
clientY: { value: y },
|
|
pointerId: { value: 1 },
|
|
});
|
|
return event;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
glMap.on.mockImplementation(() => glMap);
|
|
glMap.off.mockImplementation(() => glMap);
|
|
glMap.once.mockImplementation(() => glMap);
|
|
glMap.loaded.mockReturnValue(true);
|
|
glMap.getSource.mockReturnValue(null);
|
|
glMap.getLayer.mockReturnValue(null);
|
|
glMap.queryRenderedFeatures.mockReturnValue([]);
|
|
glMap.querySourceFeatures.mockReturnValue([]);
|
|
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'));
|
|
glMap.getBearing.mockReturnValue(0);
|
|
glMap.queryTerrainElevation.mockReturnValue(null);
|
|
// A busy style, a refused source and a custom layer list are each set by one test
|
|
// for the case it covers. Put back here, or every test after it would silently run
|
|
// against a map that never settles.
|
|
glMap.isStyleLoaded.mockReturnValue(true);
|
|
glMap.getStyle.mockReturnValue({ layers: [] });
|
|
glMap.addSource.mockImplementation(() => undefined);
|
|
glMarkers.clear();
|
|
// clearAllMocks() wipes call history but keeps implementations, so anything a
|
|
// test overrides with mockReturnValue has to be put back here.
|
|
vi.mocked(mapboxSetup.isStandardFamily).mockReturnValue(false);
|
|
vi.mocked(mapboxSetup.supportsCustom3d).mockReturnValue(false);
|
|
vi.mocked(mapboxSetup.wantsTerrain).mockReturnValue(false);
|
|
vi.mocked(photoService.getCached).mockReturnValue(undefined);
|
|
vi.mocked(photoService.isLoading).mockReturnValue(false);
|
|
vi.mocked(photoService.onThumbReady).mockReturnValue(() => {});
|
|
vi.mocked(photoService.getAllThumbs).mockReturnValue({});
|
|
geoStub.position = null;
|
|
geoStub.mode = 'off';
|
|
geoStub.error = null;
|
|
useSettingsStore.setState({
|
|
settings: {
|
|
...useSettingsStore.getState().settings,
|
|
map_provider: 'mapbox-gl',
|
|
mapbox_access_token: 'pk.test_token',
|
|
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
|
|
mapbox_3d_enabled: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
glBounds.clear();
|
|
resetAllStores();
|
|
});
|
|
|
|
describe('MapViewGL', () => {
|
|
function loadOnAttach() {
|
|
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
|
|
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)();
|
|
return glMap;
|
|
});
|
|
}
|
|
|
|
it('honors a location-search camera intent and preserves it through later route updates', async () => {
|
|
loadOnAttach();
|
|
const searchPoint: [number, number] = [35.0116, 135.7681];
|
|
const { rerender } = render(
|
|
<MapViewGL
|
|
places={[]}
|
|
route={null}
|
|
followSelection={false}
|
|
focusKey="tour:0:search:1"
|
|
focusPoints={[searchPoint]}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
|
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
|
|
const mapConstructions = vi.mocked(maplibregl.Map).mock.calls.length;
|
|
glMap.fitBounds.mockClear();
|
|
|
|
rerender(
|
|
<MapViewGL
|
|
places={[]}
|
|
route={[[searchPoint, [35.02, 135.77]]]}
|
|
followSelection={false}
|
|
focusKey="tour:0:search:1"
|
|
focusPoints={[searchPoint, [35.02, 135.77]]}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
|
expect(vi.mocked(maplibregl.Map).mock.calls).toHaveLength(mapConstructions);
|
|
|
|
rerender(
|
|
<MapViewGL
|
|
places={[]}
|
|
followSelection={false}
|
|
focusKey="tour:1"
|
|
focusPoints={[
|
|
[46.8, 9.5],
|
|
[47.1, 10.1],
|
|
]}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('frames handed-over points across the antimeridian the short way, as the opening camera does', async () => {
|
|
loadOnAttach();
|
|
// Fiji, Tonga and Samoa, a collection's list: a box from plain min and max would run
|
|
// from -175 to 178, round the far side of the world, with each pin on an edge.
|
|
render(
|
|
<MapViewGL
|
|
places={[]}
|
|
followSelection={false}
|
|
focusKey="1,2,3"
|
|
focusPoints={[
|
|
[-18.14, 178.44],
|
|
[-21.14, -175.2],
|
|
[-13.83, -171.76],
|
|
]}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
|
|
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
|
const framed = glBounds.instances.find((bounds) => bounds === glMap.fitBounds.mock.calls[0][0]);
|
|
const lngs = framed?.extend.mock.calls.map(([lngLat]) => Math.round(lngLat[0] * 100) / 100);
|
|
expect(lngs).toEqual([178.44, 184.8, 188.24]);
|
|
});
|
|
|
|
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
|
|
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
|
|
async (provider) => {
|
|
loadOnAttach();
|
|
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }));
|
|
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 });
|
|
glMap.getContainer.mockReturnValue(glCanvasContainer);
|
|
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const };
|
|
const waypoints = [waypoint];
|
|
const onMove = vi.fn(() => true);
|
|
const onSelect = vi.fn();
|
|
const onMapClick = vi.fn();
|
|
const view = render(
|
|
<MapViewGL
|
|
places={[]}
|
|
plannerWaypoints={waypoints}
|
|
onPlannerWaypointClick={onSelect}
|
|
onPlannerWaypointMove={onMove}
|
|
onMapClick={onMapClick}
|
|
focusKey="tour:1"
|
|
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
|
routeProfileFocus={null}
|
|
glProvider={provider}
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
glMap.fitBounds.mockClear();
|
|
const pin =
|
|
provider === 'maplibre-gl'
|
|
? (glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement)
|
|
: (glMarkers.created.find((marker) => marker.element.getAttribute('aria-label') === 'Waypoint 1')
|
|
?.element as HTMLButtonElement);
|
|
const startTransform = pin.style.transform;
|
|
const libraryMarker =
|
|
provider === 'mapbox-gl' ? glMarkers.created.find((marker) => marker.element === pin) : undefined;
|
|
const startLngLat = libraryMarker?.lngLat;
|
|
|
|
act(() => {
|
|
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
|
pin.dispatchEvent(pointer('pointermove', 30, 40));
|
|
});
|
|
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
|
|
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
expect(waypoints[0]).toEqual(waypoint);
|
|
|
|
view.rerender(
|
|
<MapViewGL
|
|
places={[]}
|
|
plannerWaypoints={waypoints}
|
|
onPlannerWaypointClick={onSelect}
|
|
onPlannerWaypointMove={onMove}
|
|
onMapClick={onMapClick}
|
|
focusKey="tour:1"
|
|
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
|
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
|
glProvider={provider}
|
|
/>
|
|
);
|
|
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform);
|
|
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat);
|
|
|
|
act(() => {
|
|
pin.dispatchEvent(pointer('pointerup', 30, 40));
|
|
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
});
|
|
expect(onMove).toHaveBeenCalledOnce();
|
|
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522);
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
expect(onMapClick).not.toHaveBeenCalled();
|
|
view.rerender(
|
|
<MapViewGL
|
|
places={[]}
|
|
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
|
|
onPlannerWaypointClick={onSelect}
|
|
onPlannerWaypointMove={onMove}
|
|
onMapClick={onMapClick}
|
|
focusKey="tour:1"
|
|
focusPoints={[[48.4, 11.3]]}
|
|
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
|
glProvider={provider}
|
|
/>
|
|
);
|
|
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
|
expect(glMap.flyTo).not.toHaveBeenCalled();
|
|
expect(glMap.jumpTo).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
|
|
loadOnAttach();
|
|
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const };
|
|
const onMove = vi.fn(() => true);
|
|
const onSelect = vi.fn();
|
|
const onMapClick = vi.fn();
|
|
render(
|
|
<MapViewGL
|
|
places={[]}
|
|
plannerWaypoints={[waypoint]}
|
|
onPlannerWaypointClick={onSelect}
|
|
onPlannerWaypointMove={onMove}
|
|
onMapClick={onMapClick}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement;
|
|
const initialTransform = pin.style.transform;
|
|
act(() => {
|
|
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
|
pin.dispatchEvent(pointer('pointermove', 30, 40));
|
|
pin.dispatchEvent(pointer('pointercancel', 30, 40));
|
|
});
|
|
expect(pin.style.transform).toBe(initialTransform);
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
pin.dispatchEvent(pointer('pointerdown', 10, 10));
|
|
pin.dispatchEvent(pointer('pointerup', 10, 10));
|
|
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
});
|
|
expect(onSelect).toHaveBeenCalledOnce();
|
|
expect(onSelect).toHaveBeenCalledWith(waypoint.id);
|
|
expect(onMapClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
const FERRATA = {
|
|
id: 'way/42',
|
|
name: 'Via delle Trincee',
|
|
grade: { raw: '4', level: 4, plus: null },
|
|
routeName: null,
|
|
geometry: [
|
|
[46.52, 11.8],
|
|
[46.53, 11.81],
|
|
] as [number, number][],
|
|
osmUrl: 'https://www.openstreetmap.org/way/42',
|
|
};
|
|
const FERRATA_GROUPS = groupFerratas([FERRATA]);
|
|
const ferrataLayer = (active: boolean) => ({
|
|
active,
|
|
onToggle: vi.fn(),
|
|
status: active ? ('ready' as const) : ('idle' as const),
|
|
count: active ? 1 : 0,
|
|
groups: FERRATA_GROUPS,
|
|
selectedId: null,
|
|
onSelect: vi.fn(),
|
|
});
|
|
const addedFerratas = () => glMap.addSource.mock.calls.filter(([id]) => id === 'trek-ferratas');
|
|
|
|
it('RC-10: the via ferrata layer is drawn on the ready map only while it is on, its switch in the tour pill', async () => {
|
|
loadOnAttach();
|
|
const off = ferrataLayer(false);
|
|
const view = render(
|
|
<MapViewGL
|
|
places={[]}
|
|
viewBaseLayer="topo"
|
|
onViewBaseLayerChange={vi.fn()}
|
|
ferrataLayer={off}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
expect(addedFerratas()).toHaveLength(0);
|
|
const toggle = view.getByRole('button', { name: 'Via ferratas' });
|
|
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
|
act(() => toggle.click());
|
|
expect(off.onToggle).toHaveBeenCalledOnce();
|
|
|
|
view.rerender(
|
|
<MapViewGL
|
|
places={[]}
|
|
viewBaseLayer="topo"
|
|
onViewBaseLayerChange={vi.fn()}
|
|
ferrataLayer={ferrataLayer(true)}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
expect(addedFerratas()).toHaveLength(1);
|
|
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1');
|
|
});
|
|
|
|
it('RC-10: a click on a drawn ferrata opens it and is not a map click, so no control point lands under it', async () => {
|
|
loadOnAttach();
|
|
glMap.getLayer.mockImplementation((id: string) => (id === 'trek-ferratas-hit' ? { id } : null));
|
|
glMap.queryRenderedFeatures.mockImplementation((_point: unknown, options?: { layers?: string[] }) =>
|
|
options?.layers?.includes('trek-ferratas-hit') ? [{ properties: { id: FERRATA.id } }] : []
|
|
);
|
|
const layer = ferrataLayer(true);
|
|
const onMapClick = vi.fn();
|
|
render(
|
|
<MapViewGL
|
|
places={[]}
|
|
viewBaseLayer="topo"
|
|
onViewBaseLayerChange={vi.fn()}
|
|
ferrataLayer={layer}
|
|
onMapClick={onMapClick}
|
|
glProvider="maplibre-gl"
|
|
/>
|
|
);
|
|
await act(async () => {});
|
|
const mapClicks = glMap.on.mock.calls
|
|
.filter(([event, handler]) => event === 'click' && typeof handler === 'function')
|
|
.map(([, handler]) => handler as (event: unknown) => void);
|
|
expect(mapClicks.length).toBeGreaterThan(0);
|
|
const click = {
|
|
point: { x: 5, y: 5 },
|
|
lngLat: { lat: 46.52, lng: 11.8 },
|
|
originalEvent: { target: document.createElement('div') },
|
|
};
|
|
act(() => mapClicks.forEach((handler) => handler(click)));
|
|
expect(onMapClick).not.toHaveBeenCalled();
|
|
expect(layer.onSelect).toHaveBeenCalledWith(FERRATA_GROUPS[0], FERRATA, { lat: 46.52, lng: 11.8 });
|
|
});
|
|
});
|