mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
57ea219a43
This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev. Included feature blocks: - RC-01: identify Tours in PLAN day rows and show compact Tour facts; - RC-06: search for a city or address in TOUR-PLANNER without hidden writes; - RC-07: reorder Tour waypoints in the left sidebar; - RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior; - RS-01: persist Tour descriptions and HTTPS information links with local provider classification; - RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics. The implementation preserves: - ordinary Place and Legacy Track behavior; - Tours-off fallback behavior; - existing GPX/import behavior; - Tour assignment and deletion behavior; - calculated routing duration as a separate value; - existing Tour provenance fields; - keyboard/list alternatives for waypoint editing. Not included: - PR1-09 trip-wide Tour summary; - Wanderer integration; - GPX import changes; - new Tour types; - Tour-to-Day drag-and-drop; - further roadmap or Post-Release features. Validation: - focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed; - affected shared/server/client typechecks passed; - migration and generated DB type checks passed; - scoped ESLint passed; - staged diff checks passed; - owner manual tests passed for all six feature blocks.
1705 lines
79 KiB
TypeScript
1705 lines
79 KiB
TypeScript
import React from 'react'
|
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
import { render, screen } from '../../../tests/helpers/render'
|
|
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { resetAllStores } from '../../../tests/helpers/store'
|
|
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
|
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
|
import * as photoService from '../../services/photoService'
|
|
import { Coffee, MapPin, Mountain, Signpost, type LucideIcon } from 'lucide-react'
|
|
import { renderIconMarkup } from '../../utils/iconMarkup'
|
|
|
|
const mapMock = vi.hoisted(() => ({
|
|
getContainer: vi.fn(() => document.createElement('div')),
|
|
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
|
panTo: vi.fn(),
|
|
setView: vi.fn(),
|
|
fitBounds: vi.fn(),
|
|
getZoom: vi.fn().mockReturnValue(10),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
panBy: vi.fn(),
|
|
// A flat projection, 1000 px per degree: far enough apart that every
|
|
// booking line clears its declutter floor, which is measured along the
|
|
// projected line (#2275) rather than read off a canned distanceTo.
|
|
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
|
|
x: lng * 1000, y: lat * 1000,
|
|
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
|
|
})),
|
|
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
|
|
panes: new Map<string, HTMLElement>(),
|
|
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
|
|
createPane: vi.fn(function (this: void, name: string) {
|
|
const el = document.createElement('div')
|
|
mapMock.panes.set(name, el)
|
|
return el
|
|
}),
|
|
getBounds: vi.fn(() => ({ getSouth: () => 47, getWest: () => 1, getNorth: () => 49, getEast: () => 3 })),
|
|
whenReady: vi.fn((cb: () => void) => { cb() }),
|
|
}))
|
|
|
|
// Live-location fixture; tests flip this before rendering.
|
|
const geoMock = vi.hoisted(() => ({
|
|
position: null as { lat: number; lng: number; accuracy: number; heading: number | null } | null,
|
|
mode: 'off' as 'off' | 'show' | 'follow',
|
|
error: null as string | null,
|
|
errorCode: null as string | null,
|
|
cycleMode: vi.fn(),
|
|
setMode: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../../hooks/useGeolocation', () => ({
|
|
useGeolocation: () => geoMock,
|
|
}))
|
|
|
|
// onThumbReady callbacks are captured so a test can play back a photo landing
|
|
// after the map already rendered.
|
|
const thumbCallbacks = vi.hoisted(() => new Map<string, (thumb: string) => void>())
|
|
|
|
// The cluster group and the Leaflet markers MapView reaches for when a selection lands
|
|
// on a stop that shares its coordinates and so has no pin of its own. `stacked` decides
|
|
// whether the group answers with a bubble or with the marker itself.
|
|
const clusterMock = vi.hoisted(() => {
|
|
const spiderfy = vi.fn()
|
|
return {
|
|
spiderfy,
|
|
stacked: false,
|
|
asked: [] as unknown[],
|
|
markers: new Map<Element, object>(),
|
|
/**
|
|
* One marker double per pin, not per coordinate. Stops that share a coordinate are
|
|
* the whole point here, and a registry keyed by place id only proves anything when
|
|
* the two markers on that coordinate can be told apart.
|
|
*/
|
|
stub(node: Element) {
|
|
const existing = clusterMock.markers.get(node)
|
|
if (existing) return existing
|
|
const fresh = { pin: node }
|
|
clusterMock.markers.set(node, fresh)
|
|
return fresh
|
|
},
|
|
group: {
|
|
getVisibleParent(marker: unknown) {
|
|
clusterMock.asked.push(marker)
|
|
return clusterMock.stacked ? { spiderfy } : marker
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('react-leaflet', () => ({
|
|
// center/zoom are surfaced so tests can assert the camera the map is built
|
|
// with; maxZoom because a cluster refuses to attach to a map without one.
|
|
MapContainer: ({ children, center, zoom, maxZoom, crs }: any) => (
|
|
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom} data-crs={crs?.code ?? 'EPSG:3857'}>{children}</div>
|
|
),
|
|
TileLayer: ({ url }: any) => <div data-testid="tile-layer" data-url={url} />,
|
|
Marker: ({ children, eventHandlers, position, icon, zIndexOffset, ref }: any) => (
|
|
<div
|
|
ref={node => {
|
|
if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } })
|
|
// Stands in for the L.Marker react-leaflet hands back, so MapView's registry has
|
|
// something to look a selected stop up by. Only the markers the cluster group
|
|
// holds get one: a place pin is all this double stands in for, and the route-via
|
|
// markers outside the group expect a Leaflet marker of their own.
|
|
ref?.(node?.closest('[data-testid="cluster-group"]') ? clusterMock.stub(node) : null)
|
|
}}
|
|
data-testid="marker"
|
|
data-lat={position[0]}
|
|
data-lng={position[1]}
|
|
data-zindex={zIndexOffset}
|
|
// The divIcon mock hands its options straight back, so the generated
|
|
// marker HTML is assertable without a real Leaflet.
|
|
data-icon-html={icon?.html ?? ''}
|
|
onClick={() => eventHandlers?.click?.()}
|
|
>
|
|
<button
|
|
data-testid="marker-hover-trigger"
|
|
// A real mouseover never bubbles as a click to the marker, so the
|
|
// hover-simulation must not trigger the marker's click handler.
|
|
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } }) }}
|
|
/>
|
|
<button
|
|
data-testid="marker-move-trigger"
|
|
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mousemove?.({ originalEvent: { clientX: 150, clientY: 160 } }) }}
|
|
/>
|
|
<button
|
|
data-testid="marker-out-trigger"
|
|
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mouseout?.() }}
|
|
/>
|
|
{children}
|
|
</div>
|
|
),
|
|
// pathOptions has to reach the DOM: the style props are what the track-colour
|
|
// feature (#776) actually asserts on, and the real Leaflet only repaints when
|
|
// that object's reference changes.
|
|
// bubblingMouseEvents and pane are surfaced too: a jsdom mock cannot reproduce
|
|
// Leaflet's event propagation or its pane stacking, so the tests assert the
|
|
// options that govern them instead.
|
|
Polyline: ({ positions, pathOptions, eventHandlers, bubblingMouseEvents, pane }: any) => (
|
|
<div
|
|
data-testid="polyline"
|
|
data-points={JSON.stringify(positions)}
|
|
data-path-options={JSON.stringify(pathOptions ?? null)}
|
|
data-bubbling={String(bubblingMouseEvents)}
|
|
data-pane={pane ?? ''}
|
|
onClick={() => eventHandlers?.click?.()}
|
|
/>
|
|
),
|
|
CircleMarker: ({ center, interactive, pathOptions }: any) => (
|
|
<div
|
|
data-testid={pathOptions?.className === 'tour-profile-focus-marker' ? 'profile-focus-marker' : 'circle-marker'}
|
|
data-center={JSON.stringify(center)}
|
|
data-interactive={String(interactive)}
|
|
data-path-options={JSON.stringify(pathOptions ?? null)}
|
|
/>
|
|
),
|
|
Circle: () => <div data-testid="circle" />,
|
|
Tooltip: ({ children }: any) => <>{children}</>,
|
|
useMap: () => mapMock,
|
|
useMapEvents: () => ({}),
|
|
}))
|
|
|
|
vi.mock('react-leaflet-cluster', () => ({
|
|
// The real cluster group calls iconCreateFunction itself; the probe button
|
|
// lets a test invoke it with a chosen child count.
|
|
default: ({ children, iconCreateFunction, ref }: any) => (
|
|
<div data-testid="cluster-group" ref={node => { ref?.(node ? clusterMock.group : null) }}>
|
|
<button
|
|
data-testid="cluster-icon-probe"
|
|
onClick={(e: any) => {
|
|
const count = Number(e.currentTarget.getAttribute('data-count') || 0)
|
|
const icon = iconCreateFunction?.({ getChildCount: () => count })
|
|
e.currentTarget.setAttribute('data-icon-html', icon?.html ?? '')
|
|
}}
|
|
/>
|
|
{children}
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('leaflet', () => {
|
|
const divIcon = vi.fn((options: any) => ({ ...options }))
|
|
const leaflet = {
|
|
divIcon,
|
|
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
|
|
latLngBounds: vi.fn(() => ({ isValid: () => true })),
|
|
point: vi.fn((x: number, y: number) => [x, y]),
|
|
}
|
|
return { default: leaflet, ...leaflet }
|
|
})
|
|
|
|
vi.mock('../../services/photoService', () => ({
|
|
getCached: vi.fn(() => null),
|
|
isLoading: vi.fn(() => false),
|
|
fetchPhoto: vi.fn(),
|
|
onThumbReady: vi.fn((key: string, cb: (thumb: string) => void) => {
|
|
thumbCallbacks.set(key, cb)
|
|
return () => { thumbCallbacks.delete(key) }
|
|
}),
|
|
getAllThumbs: vi.fn(() => ({})),
|
|
}))
|
|
|
|
vi.mock('./gcj02Crs', () => ({
|
|
crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined,
|
|
}))
|
|
|
|
import { MapView } from './MapView'
|
|
import type { RouteVia } from '../../types'
|
|
|
|
// Helper: build a place with the extra fields MapView uses (category_name/color/icon)
|
|
// that exist on joined DB rows but are not in the base Place TypeScript type.
|
|
function buildMapPlace(overrides: Record<string, any> = {}) {
|
|
return {
|
|
...buildPlace(),
|
|
category_name: null,
|
|
category_color: null,
|
|
category_icon: null,
|
|
...overrides,
|
|
} as any
|
|
}
|
|
|
|
const ORIGINAL_WIDTH = window.innerWidth
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
resetAllStores()
|
|
clusterMock.markers.clear()
|
|
clusterMock.asked.length = 0
|
|
clusterMock.stacked = false
|
|
mapMock.panes.clear()
|
|
thumbCallbacks.clear()
|
|
geoMock.position = null
|
|
geoMock.mode = 'off'
|
|
geoMock.error = null
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: ORIGINAL_WIDTH })
|
|
})
|
|
|
|
describe('MapView', () => {
|
|
it('FE-COMP-MAPVIEW-001: renders map container', () => {
|
|
render(<MapView />)
|
|
expect(screen.getByTestId('map-container')).toBeTruthy()
|
|
})
|
|
|
|
it('uses the CRS of the Tours-visible layer and preserves the viewport across datum switches', () => {
|
|
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
|
|
const beijing = buildMapPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 })
|
|
const onViewBaseLayerChange = vi.fn()
|
|
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
|
|
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
|
|
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 }
|
|
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
|
|
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
|
|
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
|
|
})
|
|
const { rerender } = render(<MapView {...props('default')} />)
|
|
const defaultMap = screen.getByTestId('map-container')
|
|
expect(defaultMap).toHaveAttribute('data-crs', 'TREK:GCJ02')
|
|
expect(screen.getAllByTestId('marker').some(marker => marker.getAttribute('data-lat') === '39.9042'
|
|
&& marker.getAttribute('data-lng') === '116.4074')).toBe(true)
|
|
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD)
|
|
|
|
const movedCenter = { lat: 39.91, lng: 116.41 }
|
|
mapMock.getCenter.mockReturnValue(movedCenter)
|
|
mapMock.getZoom.mockReturnValue(13)
|
|
const saveCamera = mapMock.on.mock.calls.find(([event]) => event === 'moveend zoomend')?.[1] as (() => void) | undefined
|
|
act(() => saveCamera?.())
|
|
|
|
rerender(<MapView {...props('topo')} />)
|
|
const topoMap = screen.getByTestId('map-container')
|
|
expect(topoMap).not.toBe(defaultMap)
|
|
expect(topoMap).toHaveAttribute('data-crs', 'EPSG:3857')
|
|
expect(topoMap).toHaveAttribute('data-center', JSON.stringify([movedCenter.lat, movedCenter.lng]))
|
|
expect(topoMap).toHaveAttribute('data-zoom', '13')
|
|
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', OPENTOPOMAP_TILE_URL)
|
|
expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true)
|
|
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng]))
|
|
|
|
rerender(<MapView {...props('satellite')} />)
|
|
const satelliteMap = screen.getByTestId('map-container')
|
|
expect(satelliteMap).not.toBe(topoMap)
|
|
expect(satelliteMap).toHaveAttribute('data-crs', 'TREK:GCJ02')
|
|
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_SATELLITE)
|
|
|
|
rerender(<MapView {...props('default')} />)
|
|
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'TREK:GCJ02')
|
|
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', AMAP_ROAD)
|
|
expect(useSettingsStore.getState().settings.map_base_layer).toBe('default')
|
|
expect(onViewBaseLayerChange).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
|
|
const places = [
|
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
|
buildMapPlace({ id: 2, name: 'Louvre', lat: 48.86, lng: 2.337 }),
|
|
]
|
|
render(<MapView places={places} />)
|
|
expect(screen.getAllByTestId('marker').length).toBe(2)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-003: marker click calls onMarkerClick with place id', () => {
|
|
const onMarkerClick = vi.fn()
|
|
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })]
|
|
render(<MapView places={places} onMarkerClick={onMarkerClick} />)
|
|
fireEvent.click(screen.getByTestId('marker'))
|
|
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
|
|
const user = userEvent.setup()
|
|
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
|
render(<MapView places={places} />)
|
|
await user.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
|
|
const user = userEvent.setup()
|
|
const places = [
|
|
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
|
|
]
|
|
render(<MapView places={places} />)
|
|
await user.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
|
// Apple-Maps style draws a casing + a core line per segment.
|
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
|
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
|
|
|
|
const [casing, core] = screen.getAllByTestId('polyline')
|
|
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
|
expect(casing.color).toBe('#c2740a')
|
|
expect(core.color).toBe('#ff9f0a')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
|
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
|
|
|
|
const [casing, core] = screen.getAllByTestId('polyline')
|
|
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
|
expect(casing.color).toBe('#0a5cc2')
|
|
expect(core.color).toBe('#0a84ff')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
|
render(<MapView route={null} />)
|
|
expect(screen.queryByTestId('polyline')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
|
|
render(<MapView route={[[[48.0, 2.0]]]} />)
|
|
expect(screen.queryByTestId('polyline')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-009: GPX geometry polyline rendered for place with route_geometry', () => {
|
|
const places = [
|
|
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }),
|
|
]
|
|
render(<MapView places={places} />)
|
|
// Three per track: casing, the visible line, and the invisible fat one
|
|
// that catches clicks. The casing is always mounted so that toggling a
|
|
// colour never remounts a path into the wrong stacking position.
|
|
expect(screen.getAllByTestId('polyline').length).toBe(3)
|
|
})
|
|
|
|
it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => {
|
|
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
|
|
render(<MapView places={places} clusterLoosely={roadtrip} />)
|
|
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-010b: the map carries a zoom ceiling of its own, whatever the basemap is', () => {
|
|
// Without it, a vector basemap leaves the map with none and the cluster
|
|
// above throws on attach, taking the planner to the error boundary. The
|
|
// ceiling has to sit on the map because only a GridLayer can contribute one.
|
|
render(<MapView places={[buildMapPlace({ lat: 48.8584, lng: 2.2945 })]} />)
|
|
expect(screen.getByTestId('map-container').getAttribute('data-maxzoom')).toBe(String(MAP_MAX_ZOOM))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
|
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
|
render(<MapView route={route} />)
|
|
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
|
const places = [
|
|
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: 'NOT_VALID_JSON' }),
|
|
]
|
|
// Should not throw; invalid JSON is caught silently
|
|
render(<MapView places={places} />)
|
|
expect(screen.queryByTestId('polyline')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-013: route_geometry with fewer than 2 coords skips polyline', () => {
|
|
const places = [
|
|
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0]]' }),
|
|
]
|
|
render(<MapView places={places} />)
|
|
expect(screen.queryByTestId('polyline')).toBeNull()
|
|
})
|
|
|
|
it('moves a passive semantic profile focus marker without changing the map camera', () => {
|
|
const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 }
|
|
const focusPoints: [number, number][] = [[48.1, 11.4], [48.2, 11.5]]
|
|
const route: [number, number][][] = [[[48.1, 11.4], [48.2, 11.5]]]
|
|
mapMock.fitBounds.mockClear()
|
|
mapMock.panTo.mockClear()
|
|
mapMock.setView.mockClear()
|
|
const { rerender } = render(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={focus} />)
|
|
const mapContainer = screen.getByTestId('map-container')
|
|
mapMock.fitBounds.mockClear()
|
|
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', '[48.123,11.456]')
|
|
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-interactive', 'false')
|
|
expect(JSON.parse(screen.getByTestId('profile-focus-marker').getAttribute('data-path-options')!)).toMatchObject({
|
|
color: 'var(--bg-card)', fillColor: 'var(--text-muted)',
|
|
})
|
|
|
|
const movedFocus = { ...focus, lat: 48.16, lng: 11.47 }
|
|
rerender(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={movedFocus} />)
|
|
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', '[48.16,11.47]')
|
|
expect(screen.getByTestId('map-container')).toBe(mapContainer)
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
expect(mapMock.panTo).not.toHaveBeenCalled()
|
|
expect(mapMock.setView).not.toHaveBeenCalled()
|
|
|
|
rerender(<MapView places={[]} route={route} fitKey={7} focusPoints={focusPoints} routeProfileFocus={null} />)
|
|
expect(screen.queryByTestId('profile-focus-marker')).toBeNull()
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
|
|
rerender(<MapView places={[]} fitKey={8} focusPoints={[[48, 11], [48.3, 11.8]]} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalled()
|
|
})
|
|
|
|
it('honors a location-search camera intent and preserves it through later route updates', () => {
|
|
const searchPoint: [number, number] = [35.0116, 135.7681];
|
|
const { rerender } = render(
|
|
<MapView
|
|
places={[]}
|
|
route={null}
|
|
followSelection={false}
|
|
focusKey="tour:0:search:1"
|
|
focusPoints={[searchPoint]}
|
|
/>
|
|
);
|
|
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
|
expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 }));
|
|
const mapContainer = screen.getByTestId('map-container');
|
|
mapMock.fitBounds.mockClear();
|
|
|
|
rerender(
|
|
<MapView
|
|
places={[]}
|
|
route={[[searchPoint, [35.02, 135.77]]]}
|
|
followSelection={false}
|
|
focusKey="tour:0:search:1"
|
|
focusPoints={[searchPoint, [35.02, 135.77]]}
|
|
/>
|
|
);
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('map-container')).toBe(mapContainer);
|
|
|
|
rerender(
|
|
<MapView
|
|
places={[]}
|
|
followSelection={false}
|
|
focusKey="tour:1"
|
|
focusPoints={[
|
|
[46.8, 9.5],
|
|
[47.1, 10.1],
|
|
]}
|
|
/>
|
|
);
|
|
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
|
|
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
|
const { rerender } = render(
|
|
<MapView places={[]} route={null} followSelection={false} focusKey={1} focusPoints={[]} />,
|
|
)
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
|
|
rerender(<MapView places={[]} route={route} followSelection={false} focusKey={1} focusPoints={[[48, 11], [48.2, 11.3]]} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
|
|
mapMock.fitBounds.mockClear()
|
|
|
|
rerender(
|
|
<MapView
|
|
places={[]}
|
|
route={[[[48, 11], [48.2, 11.3], [48.4, 11.6]]]}
|
|
followSelection={false}
|
|
focusKey={1}
|
|
focusPoints={[[48, 11], [48.2, 11.3], [48.4, 11.6]]}
|
|
/>,
|
|
)
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
|
|
const mapContainer = screen.getByTestId('map-container')
|
|
const retainedCenter = { lat: 35.2, lng: 135.8 }
|
|
mapMock.getCenter.mockReturnValue(retainedCenter)
|
|
mapMock.getZoom.mockReturnValue(12)
|
|
mapMock.setView([retainedCenter.lat, retainedCenter.lng], 12)
|
|
mapMock.fitBounds.mockClear()
|
|
mapMock.setView.mockClear()
|
|
mapMock.panTo.mockClear()
|
|
|
|
rerender(<MapView places={[]} followSelection={false} focusKey={2} focusPoints={[]} />)
|
|
expect(screen.getByTestId('map-container')).toBe(mapContainer)
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
expect(mapMock.setView).not.toHaveBeenCalled()
|
|
expect(mapMock.panTo).not.toHaveBeenCalled()
|
|
expect(mapMock.getCenter()).toEqual(retainedCenter)
|
|
expect(mapMock.getZoom()).toBe(12)
|
|
|
|
rerender(
|
|
<MapView places={[]} followSelection={false} focusKey={2} focusPoints={[[48, 11], [48.5, 11.8]]} />,
|
|
)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
|
|
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
|
|
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
|
const { rerender } = render(<MapView places={[]} route={null} fitKey={0} />)
|
|
mapMock.fitBounds.mockClear()
|
|
|
|
rerender(<MapView places={[selectedPlace]} route={null} fitKey={1} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalled()
|
|
mapMock.fitBounds.mockClear()
|
|
|
|
rerender(<MapView places={[selectedPlace]} route={route} fitKey={2} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
['two-point', [[48, 2], [49, 3]]],
|
|
['elevation', [[48, 2, 12.5], [49, 3, 22.75]]],
|
|
['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])],
|
|
['loop', [[48, 2], [48.1, 2.1], [48, 2]]],
|
|
['point-to-point Tour', [[48, 2], [48.5, 2.5], [49, 3]]],
|
|
])('renders valid %s geometry unchanged', (_label, geometry) => {
|
|
render(<MapView places={[buildMapPlace({ route_geometry: JSON.stringify(geometry) })]} />)
|
|
const lines = screen.getAllByTestId('polyline')
|
|
expect(lines).toHaveLength(3)
|
|
const expected = geometry.map(([lat, lng]) => [lat, lng])
|
|
expect(JSON.parse(lines[1].getAttribute('data-points') || '[]')).toEqual(expected)
|
|
})
|
|
|
|
it.each([
|
|
['malformed JSON', 'not json'],
|
|
['non-array JSON', '{"coordinates":[]}'],
|
|
['one point', '[[48,2]]'],
|
|
['missing coordinate dimension', '[[48],[49,3]]'],
|
|
['string coordinates', '[[48,"2"],[49,3]]'],
|
|
['NaN token', '[[48,2],[NaN,3]]'],
|
|
['Infinity token', '[[48,2],[49,Infinity]]'],
|
|
['out-of-range latitude', '[[48,2],[91,3]]'],
|
|
['out-of-range longitude', '[[48,2],[49,181]]'],
|
|
['all-invalid coordinates', '[[91,2],[49,181]]'],
|
|
])('skips %s geometry without passing invalid points to Leaflet', (_label, geometry) => {
|
|
expect(() => render(<MapView places={[buildMapPlace({ route_geometry: geometry })]} />)).not.toThrow()
|
|
expect(screen.queryByTestId('polyline')).toBeNull()
|
|
})
|
|
|
|
it('filters invalid points while preserving the original order of a renderable track', () => {
|
|
const geometry = '[[48,2],null,[91,4],[48.5,2.5],[49,3]]'
|
|
render(<MapView places={[buildMapPlace({ route_geometry: geometry })]} />)
|
|
const lines = screen.getAllByTestId('polyline')
|
|
expect(lines).toHaveLength(3)
|
|
expect(JSON.parse(lines[1].getAttribute('data-points') || '[]')).toEqual([[48, 2], [48.5, 2.5], [49, 3]])
|
|
})
|
|
|
|
// ── Track colours (#776) ──────────────────────────────────────────────────
|
|
// The style has to travel through pathOptions: react-leaflet only calls
|
|
// setStyle when that object's reference changes, so bare color/weight props
|
|
// would freeze at their mount-time value and a recolour would never show.
|
|
const trackOptions = () => screen.getAllByTestId('polyline')
|
|
.map(el => JSON.parse(el.getAttribute('data-path-options') || 'null'))
|
|
.filter(Boolean)
|
|
|
|
it('FE-COMP-MAPVIEW-025: a picked route_color beats the category colour', () => {
|
|
const places = [buildMapPlace({
|
|
lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]',
|
|
category_color: '#00ff00', route_color: '#e11d48',
|
|
})]
|
|
render(<MapView places={places} />)
|
|
expect(trackOptions().some(o => o.color === '#e11d48')).toBe(true)
|
|
expect(trackOptions().some(o => o.color === '#00ff00')).toBe(false)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-026: without route_color the category colour still wins', () => {
|
|
const places = [buildMapPlace({
|
|
lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]',
|
|
category_color: '#00ff00', route_color: null,
|
|
})]
|
|
render(<MapView places={places} />)
|
|
expect(trackOptions().some(o => o.color === '#00ff00')).toBe(true)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-027: with neither colour the track keeps the old blue', () => {
|
|
const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' })]
|
|
render(<MapView places={places} />)
|
|
expect(trackOptions().some(o => o.color === '#3b82f6' && o.weight === 3.5 && o.opacity === 0.75)).toBe(true)
|
|
// The casing is mounted but invisible — a track nobody coloured looks
|
|
// exactly as it did before.
|
|
expect(trackOptions().some(o => o.color === '#ffffff' && o.opacity === 0)).toBe(true)
|
|
expect(trackOptions().some(o => o.color === '#ffffff' && o.opacity > 0)).toBe(false)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-028: a coloured track gets a white casing underneath', () => {
|
|
const places = [buildMapPlace({
|
|
lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#059669',
|
|
})]
|
|
render(<MapView places={places} />)
|
|
expect(trackOptions().some(o => o.color === '#ffffff' && o.weight === 6.5 && o.opacity === 0.7)).toBe(true)
|
|
expect(trackOptions().some(o => o.color === '#059669' && o.opacity === 0.9)).toBe(true)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-029: clicking a track selects its place', () => {
|
|
const onMarkerClick = vi.fn()
|
|
const places = [buildMapPlace({
|
|
id: 77, lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]',
|
|
})]
|
|
render(<MapView places={places} onMarkerClick={onMarkerClick} />)
|
|
const hit = screen.getAllByTestId('polyline')
|
|
.find(el => JSON.parse(el.getAttribute('data-path-options') || '{}').weight === 14)
|
|
fireEvent.click(hit!)
|
|
expect(onMarkerClick).toHaveBeenCalledWith(77)
|
|
// Paths bubble to the map by default and the map click clears the selection
|
|
// this one just made — the mock cannot reproduce that, so assert the option.
|
|
expect(hit!.getAttribute('data-bubbling')).toBe('false')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-014: marker icon uses base64 image_url for photo places', () => {
|
|
const dataUrl = 'data:image/jpeg;base64,/9j/4AA'
|
|
const places = [buildMapPlace({ id: 10, lat: 48.0, lng: 2.0, image_url: dataUrl })]
|
|
render(<MapView places={places} />)
|
|
// Marker still renders; base64 path in createPlaceIcon should be exercised
|
|
expect(screen.getByTestId('marker')).toBeTruthy()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-015: uses cached photo thumb from photoService when available', () => {
|
|
vi.mocked(photoService.getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc' } as any)
|
|
const places = [
|
|
buildMapPlace({ id: 20, lat: 48.0, lng: 2.0, google_place_id: 'gplace_123' }),
|
|
]
|
|
render(<MapView places={places} />)
|
|
expect(screen.getByTestId('marker')).toBeTruthy()
|
|
vi.mocked(photoService.getCached).mockReturnValue(null)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
|
|
const user = userEvent.setup()
|
|
const places = [
|
|
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
|
|
]
|
|
render(<MapView places={places} />)
|
|
await user.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-017: renders selected marker with higher z-index offset', () => {
|
|
const places = [
|
|
buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 }),
|
|
]
|
|
render(<MapView places={places} selectedPlaceId={5} />)
|
|
expect(screen.getByTestId('marker')).toBeTruthy()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
|
|
const places = [
|
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
|
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
|
|
]
|
|
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
|
|
const initialCount = mapMock.fitBounds.mock.calls.length
|
|
|
|
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
|
|
// paddingOpts memo creates new object). fitBounds must NOT fire again.
|
|
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
|
|
|
|
// Toggle selectedPlaceId off — mimics closing inspector via X button.
|
|
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
|
|
const places = [
|
|
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
|
]
|
|
const { rerender } = render(<MapView places={places} fitKey={1} />)
|
|
const afterFirst = mapMock.fitBounds.mock.calls.length
|
|
|
|
rerender(<MapView places={places} fitKey={2} />)
|
|
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-021: clicking a marker clears the hover tooltip (#1404)', async () => {
|
|
const user = userEvent.setup()
|
|
const places = [buildMapPlace({ id: 3, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
|
render(<MapView places={places} onMarkerClick={vi.fn()} />)
|
|
await user.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
|
// The recenter that follows the click moves the marker out from under the
|
|
// cursor — no mouseout will ever fire, so the click itself must clear.
|
|
fireEvent.click(screen.getByTestId('marker'))
|
|
expect(screen.queryByTestId('tooltip')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-022: camera movement clears the tooltip and suppresses re-show until it ends (#1404)', async () => {
|
|
const user = userEvent.setup()
|
|
const places = [buildMapPlace({ id: 4, name: 'Louvre', lat: 48.86, lng: 2.337 })]
|
|
render(<MapView places={places} />)
|
|
await user.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
|
|
|
const findHandler = (event: string) =>
|
|
mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined
|
|
const start = findHandler('movestart zoomstart')
|
|
const end = findHandler('moveend zoomend')
|
|
expect(start).toBeTypeOf('function')
|
|
expect(end).toBeTypeOf('function')
|
|
|
|
fireEvent.click(screen.getByTestId('marker-hover-trigger')) // ensure hover is showing
|
|
start!()
|
|
await waitFor(() => expect(screen.queryByTestId('tooltip')).toBeNull())
|
|
// during the pan animation a mouseover must not re-show the card
|
|
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.queryByTestId('tooltip')).toBeNull()
|
|
// once the move ends, hover works again
|
|
end!()
|
|
await user.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
|
|
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
|
|
const dayPlaces = [
|
|
buildMapPlace({ id: 1, lat: 48.0, lng: 2.0 }),
|
|
buildMapPlace({ id: 2, lat: 48.1, lng: 2.1 }),
|
|
]
|
|
// The map opens already framed on its places, so nothing fits on mount.
|
|
const { rerender } = render(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[]} fitKey={5} />)
|
|
const lastBounds = () => { const c = L.latLngBounds.mock.calls; return c[c.length - 1][0] }
|
|
|
|
// Day selected, route not computed yet → first fit is the two destinations.
|
|
L.latLngBounds.mockClear()
|
|
rerender(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[]} fitKey={6} />)
|
|
expect(lastBounds()).toHaveLength(2)
|
|
|
|
// The day's route arrives → one-shot re-fit including the 3 route points.
|
|
L.latLngBounds.mockClear()
|
|
rerender(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[[[47.9, 1.9], [48.05, 2.05], [48.2, 2.2]]]} fitKey={6} />)
|
|
expect(L.latLngBounds).toHaveBeenCalled()
|
|
expect(lastBounds()).toHaveLength(5) // 2 destinations + 3 route points
|
|
})
|
|
|
|
describe('opening camera', () => {
|
|
const camera = () => {
|
|
const el = screen.getByTestId('map-container')
|
|
return {
|
|
center: JSON.parse(el.getAttribute('data-center')!) as [number, number],
|
|
zoom: Number(el.getAttribute('data-zoom')),
|
|
}
|
|
}
|
|
|
|
it('FE-COMP-MAPVIEW-021: builds the map framed on the places', () => {
|
|
render(<MapView places={[
|
|
buildMapPlace({ id: 1, lat: 35.01, lng: 135.76 }), // Kyoto
|
|
buildMapPlace({ id: 2, lat: 34.69, lng: 135.5 }), // Osaka
|
|
]} />)
|
|
|
|
const { center, zoom } = camera()
|
|
expect(center[0]).toBeCloseTo(34.85, 1)
|
|
expect(center[1]).toBeCloseTo(135.63, 1)
|
|
expect(zoom).toBeGreaterThan(7)
|
|
expect(zoom).toBeLessThan(13)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-022: does not fit on mount when it opened already framed', async () => {
|
|
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
|
|
L.latLngBounds.mockClear()
|
|
|
|
render(<MapView places={[buildMapPlace({ id: 1, lat: 35.01, lng: 135.76 })]} fitKey={1} />)
|
|
|
|
expect(L.latLngBounds).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-023: falls back to the world view when no place has coordinates', () => {
|
|
render(<MapView places={[buildMapPlace({ id: 1, lat: null, lng: null })]} />)
|
|
|
|
const { center, zoom } = camera()
|
|
expect(center).toEqual([0, 0])
|
|
expect(zoom).toBe(2)
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-023: a routable reservation not in visibleConnectionIds draws no route', () => {
|
|
const reservation = buildReservation({
|
|
id: 43,
|
|
type: 'flight',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
|
],
|
|
} as any)
|
|
render(<MapView reservations={[reservation]} visibleConnectionIds={[]} />)
|
|
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-024: a routable reservation in visibleConnectionIds draws its route', () => {
|
|
const reservation = buildReservation({
|
|
id: 42,
|
|
type: 'flight',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
|
],
|
|
} as any)
|
|
render(<MapView reservations={[reservation]} visibleConnectionIds={[42]} />)
|
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-082: a booking switched on for another day keeps drawing unless the map is held to the day', () => {
|
|
// The desktop passes no scope, so a booking switched on stays drawn on every day.
|
|
// Only a caller that asks for it drops the booking from a day it does not run on.
|
|
const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
|
|
const reservation = buildReservation({
|
|
id: 44,
|
|
type: 'flight',
|
|
day_id: 10,
|
|
end_day_id: 10,
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
|
],
|
|
})
|
|
const reservations = [reservation]
|
|
const ids = [44]
|
|
const { rerender } = render(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} />)
|
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
|
|
|
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={11} scopeConnectionsToDay />)
|
|
expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
|
|
|
|
rerender(<MapView reservations={reservations} visibleConnectionIds={ids} days={days} selectedDayId={10} scopeConnectionsToDay />)
|
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => {
|
|
// Icon components come from a lookup table a plugin can extend; one that
|
|
// throws must not take the whole map down, only its own glyph.
|
|
const key = '__ExplodingIcon'
|
|
CATEGORY_ICON_MAP[key] = (() => { throw new Error('icon blew up') }) as unknown as typeof CATEGORY_ICON_MAP['MapPin']
|
|
try {
|
|
render(<MapView places={[buildMapPlace({ id: 5, lat: 48, lng: 2, category_icon: key })]} />)
|
|
const html = screen.getByTestId('marker').getAttribute('data-icon-html') || ''
|
|
expect(html).toContain('border-radius:50%')
|
|
expect(html).not.toContain('<svg')
|
|
} finally {
|
|
delete CATEGORY_ICON_MAP[key]
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── FE-COMP-MAPVIEW-030 onwards ───────────────────────────────────────────────
|
|
|
|
const leafletMock = async () => ((await import('leaflet')).default) as unknown as {
|
|
divIcon: ReturnType<typeof vi.fn>
|
|
latLngBounds: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
const iconHtmlOf = (el: HTMLElement) => el.getAttribute('data-icon-html') || ''
|
|
const markersWithZ = (z: string) => screen.queryAllByTestId('marker').filter(m => m.getAttribute('data-zindex') === z)
|
|
const handlerFor = (event: string) => mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined
|
|
|
|
function buildPoi(overrides: Record<string, any> = {}) {
|
|
return {
|
|
osm_id: 'node/1',
|
|
name: 'Café Central',
|
|
lat: 48.21,
|
|
lng: 16.36,
|
|
category: 'cafe',
|
|
poi_type: 'cafe',
|
|
address: null,
|
|
website: null,
|
|
phone: null,
|
|
opening_hours: null,
|
|
cuisine: null,
|
|
source: 'openstreetmap',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('MapView clusters and badges', () => {
|
|
it('FE-COMP-MAPVIEW-030: the cluster icon grows with the number of markers it hides', () => {
|
|
render(<MapView places={[buildMapPlace({ id: 1, lat: 48, lng: 2 })]} />)
|
|
const probe = screen.getByTestId('cluster-icon-probe')
|
|
|
|
const iconFor = (count: number) => {
|
|
probe.setAttribute('data-count', String(count))
|
|
fireEvent.click(probe)
|
|
return probe.getAttribute('data-icon-html') || ''
|
|
}
|
|
|
|
expect(iconFor(4)).toContain('width:36px')
|
|
expect(iconFor(4)).toContain('<span>4</span>')
|
|
expect(iconFor(20)).toContain('width:42px')
|
|
expect(iconFor(120)).toContain('width:48px')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-031: a place on one day gets a single order badge', () => {
|
|
render(<MapView places={[buildMapPlace({ id: 3, lat: 48, lng: 2 })]} dayOrderMap={{ 3: [2] }} />)
|
|
const html = iconHtmlOf(screen.getAllByTestId('marker')[0])
|
|
expect(html).toContain('>2</span>')
|
|
expect(html).toContain('height:18px')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-032: a place visited on several days gets a wider combined badge', () => {
|
|
render(<MapView places={[buildMapPlace({ id: 4, lat: 48, lng: 2 })]} dayOrderMap={{ 4: [1, 3] }} />)
|
|
const html = iconHtmlOf(screen.getAllByTestId('marker')[0])
|
|
expect(html).toContain('>1 · 3</span>')
|
|
expect(html).toContain('height:16px')
|
|
})
|
|
})
|
|
|
|
describe('MapView explore POIs', () => {
|
|
it('FE-COMP-MAPVIEW-033: renders a pin per POI in its category colour with a name tooltip', () => {
|
|
render(<MapView pois={[buildPoi()]} />)
|
|
const poiMarker = markersWithZ('500')[0]
|
|
expect(poiMarker).toBeTruthy()
|
|
expect(iconHtmlOf(poiMarker)).toContain('#B45309') // cafe
|
|
expect(poiMarker.textContent).toContain('Café Central')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-034: clicking a POI hands the whole POI back to the caller', () => {
|
|
const onPoiClick = vi.fn()
|
|
const poi = buildPoi({ osm_id: 'node/7' })
|
|
render(<MapView pois={[poi]} onPoiClick={onPoiClick} />)
|
|
fireEvent.click(markersWithZ('500')[0])
|
|
expect(onPoiClick).toHaveBeenCalledWith(poi)
|
|
})
|
|
|
|
it('keeps native POI clicks working after a pan and uses the latest callback', () => {
|
|
const first = vi.fn()
|
|
const latest = vi.fn()
|
|
const poi = buildPoi({ osm_id: 'node/7' })
|
|
const { rerender } = render(<MapView pois={[poi]} onPoiClick={first} onPoiDropOnRoute={() => {}} />)
|
|
const marker = markersWithZ('500')[0]
|
|
expect(marker).toHaveAttribute('draggable', 'true')
|
|
fireEvent.mouseDown(marker)
|
|
fireEvent.mouseUp(marker)
|
|
fireEvent.click(marker)
|
|
expect(first).toHaveBeenCalledTimes(1)
|
|
rerender(<MapView pois={[poi]} onPoiClick={latest} onPoiDropOnRoute={() => {}} />)
|
|
fireEvent.click(markersWithZ('500')[0])
|
|
expect(latest).toHaveBeenCalledExactlyOnceWith(poi)
|
|
expect(first).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => {
|
|
render(<MapView pois={[buildPoi({ osm_id: 'node/8', category: 'not-a-category' })]} />)
|
|
const html = iconHtmlOf(markersWithZ('500')[0])
|
|
expect(html).toContain('#6b7280')
|
|
expect(html).not.toContain('<svg')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-036: POIs of one category share a single cached icon', async () => {
|
|
const L = await leafletMock()
|
|
render(<MapView pois={[
|
|
buildPoi({ osm_id: 'node/10', category: 'museum' }),
|
|
buildPoi({ osm_id: 'node/11', category: 'museum', lat: 48.3 }),
|
|
]} />)
|
|
const poiIcons = L.divIcon.mock.calls.filter(c => JSON.stringify((c[0] as { iconSize: number[] }).iconSize) === '[26,26]')
|
|
expect(markersWithZ('500')).toHaveLength(2)
|
|
expect(poiIcons).toHaveLength(1)
|
|
})
|
|
|
|
const pinGlyph = (Icon: LucideIcon) => React.createElement(Icon, { size: 13, color: 'white', strokeWidth: 2.5 })
|
|
const poiPinHtmls = async () => (await leafletMock()).divIcon.mock.calls
|
|
.filter(c => JSON.stringify((c[0] as { iconSize: number[] }).iconSize) === '[26,26]')
|
|
.map(c => (c[0] as { html: string }).html)
|
|
|
|
it('FE-COMP-MAPVIEW-084: a core POI pin is the disc it always was', () => {
|
|
render(<MapView pois={[buildPoi({ osm_id: 'node/20' })]} />)
|
|
expect(iconHtmlOf(markersWithZ('500')[0])).toBe(
|
|
'<div style="position:relative;width:26px;height:26px;border-radius:50%;background:#B45309;border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">'
|
|
+ `${renderIconMarkup(pinGlyph(Coffee))}</div>`,
|
|
)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-085: a plugin POI pin takes the colour and icon its POI carries, checked first', () => {
|
|
render(<MapView pois={[
|
|
buildPoi({ osm_id: 'plugin:trail-finder:1', category: 'plugin:trail-finder/pins-085', color: '#2f855a', icon: 'Signpost' }),
|
|
buildPoi({ osm_id: 'plugin:trail-finder:2', category: 'plugin:trail-finder/bad-085', color: 'red;background:url(https://evil.example/x)', icon: '<img src=x onerror=alert(1)>' }),
|
|
]} />)
|
|
const [good, bad] = markersWithZ('500').map(iconHtmlOf)
|
|
expect(good).toContain('background:#2f855a;')
|
|
expect(good).toContain(renderIconMarkup(pinGlyph(Signpost)))
|
|
expect(bad).toContain('background:#6b7280;')
|
|
expect(bad).toContain(renderIconMarkup(pinGlyph(MapPin)))
|
|
expect(bad).not.toContain('evil.example')
|
|
expect(bad).not.toContain('<img')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-086: two plugin looks never share a cached pin, one look is built once', async () => {
|
|
const trail = { category: 'plugin:trail-finder/trails-086', color: '#2f855a', icon: 'Signpost' }
|
|
const { rerender } = render(<MapView pois={[
|
|
buildPoi({ osm_id: 'plugin:trail-finder:a', ...trail }),
|
|
buildPoi({ osm_id: 'plugin:trail-finder:b', ...trail, lat: 48.3 }),
|
|
buildPoi({ osm_id: 'plugin:water:c', category: 'plugin:water/wells-086', color: '#3182ce', icon: 'Mountain' }),
|
|
]} />)
|
|
expect(await poiPinHtmls()).toHaveLength(2)
|
|
const [first, second, well] = markersWithZ('500').map(iconHtmlOf)
|
|
expect(second).toBe(first)
|
|
expect(first).toContain('#2f855a')
|
|
expect(well).toContain('#3182ce')
|
|
expect(well).toContain(renderIconMarkup(pinGlyph(Mountain)))
|
|
// The plugin changed its colour in an update: the same category gets a new pin.
|
|
rerender(<MapView pois={[buildPoi({ osm_id: 'plugin:trail-finder:a', ...trail, color: '#c53030' })]} />)
|
|
expect(iconHtmlOf(markersWithZ('500')[0])).toContain('background:#c53030;')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-087: a plugin POI tooltip lists its detail rows as text, a core one stays the bare name', () => {
|
|
render(<MapView pois={[
|
|
buildPoi({ osm_id: 'node/30', name: 'Café Central' }),
|
|
buildPoi({
|
|
osm_id: 'plugin:trail-finder:3', category: 'plugin:trail-finder/pins-087', color: '#2f855a', icon: 'Signpost',
|
|
name: '<img src=x onerror=alert(1)>',
|
|
details: [{ label: 'Length', value: '12.4 km' }, { label: '<b onclick=alert(2)>Fee</b>', value: '"><svg onload=alert(3)>' }],
|
|
}),
|
|
]} />)
|
|
const [core, plugin] = markersWithZ('500')
|
|
expect(core.querySelector('[data-testid="poi-details"]')).toBeNull()
|
|
const details = plugin.querySelector('[data-testid="poi-details"]')!
|
|
expect([...details.querySelectorAll('span')].map(el => el.textContent)).toEqual([
|
|
'Length', '12.4 km', '<b onclick=alert(2)>Fee</b>', '"><svg onload=alert(3)>',
|
|
])
|
|
expect(plugin.textContent).toContain('<img src=x onerror=alert(1)>')
|
|
expect(plugin.querySelectorAll('img, b, svg')).toHaveLength(0)
|
|
// Sized by its content up to the cap, since the 0px tooltip pane gives it nothing to fill.
|
|
expect([...(details.parentElement as HTMLElement).classList]).toEqual(expect.arrayContaining(['w-max', 'max-w-56']))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-088: a long tooltip label wraps inside a capped column and leaves its value the wider share', () => {
|
|
render(<MapView pois={[buildPoi({
|
|
osm_id: 'plugin:trail-finder:4', category: 'plugin:trail-finder/pins-088', color: '#2f855a', icon: 'Signpost',
|
|
details: [{ label: 'Wheelchair accessible toilet, ground fl', value: 'Step-free via the side door on the left' }],
|
|
})]} />)
|
|
const details = markersWithZ('500')[0].querySelector('[data-testid="poi-details"]')!
|
|
expect(details.classList).toContain('grid-cols-[fit-content(45%)_1fr]')
|
|
const [label, value] = [...details.querySelectorAll('span')]
|
|
expect(label.classList).toContain('[overflow-wrap:anywhere]')
|
|
expect(value.classList).toContain('[overflow-wrap:anywhere]')
|
|
})
|
|
})
|
|
|
|
describe('MapView plugin route vias', () => {
|
|
// Cast, not typed: one case hands in a tone outside RouteVia's union on purpose.
|
|
const via = (overrides: Partial<Record<keyof RouteVia, unknown>> = {}) =>
|
|
({ lat: 48.5, lng: 2.5, tone: 'default', ...overrides }) as RouteVia
|
|
|
|
it('FE-COMP-MAPVIEW-037: draws a tone dot for each via point', () => {
|
|
render(<MapView routeVias={[via({ tone: 'success' }), via({ tone: 'danger', lat: 48.7 })]} />)
|
|
const vias = markersWithZ('800')
|
|
expect(vias).toHaveLength(2)
|
|
expect(iconHtmlOf(vias[0])).toContain('#10b981')
|
|
expect(iconHtmlOf(vias[1])).toContain('#ef4444')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-038: an unknown tone falls back to the default indigo', () => {
|
|
render(<MapView routeVias={[via({ tone: 'chartreuse' })]} />)
|
|
expect(iconHtmlOf(markersWithZ('800')[0])).toContain('#4F46E5')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-039: vias of the same tone share one cached icon', async () => {
|
|
const L = await leafletMock()
|
|
render(<MapView routeVias={[via({ tone: 'warn' }), via({ tone: 'warn', lat: 48.9 })]} />)
|
|
const viaIcons = L.divIcon.mock.calls.filter(c => JSON.stringify((c[0] as { iconSize: number[] }).iconSize) === '[13,13]')
|
|
expect(markersWithZ('800')).toHaveLength(2)
|
|
expect(viaIcons).toHaveLength(1)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-040: a via tooltip joins its label and its dwell time', () => {
|
|
render(<MapView routeVias={[via({ label: 'Supercharger', dwellSeconds: 5400 })]} />)
|
|
expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min')
|
|
})
|
|
|
|
it('hides night badges at wide zoom and shows the full description when zoomed in', () => {
|
|
mapMock.getZoom.mockReturnValue(5)
|
|
const label = 'Tagesende von Tag 1 um 18:00 Uhr'
|
|
render(<MapView routeVias={[via({ label, hoverCard: true, nightPause: { day: 1, atPlace: true } })]} />)
|
|
expect(markersWithZ('800')).toHaveLength(0)
|
|
mapMock.getZoom.mockReturnValue(6)
|
|
act(() => { mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]() })
|
|
const badge = markersWithZ('800')[0]
|
|
expect(iconHtmlOf(badge)).toContain('data-night-pause="place"')
|
|
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByRole('tooltip')).toHaveTextContent(label)
|
|
mapMock.getZoom.mockReturnValue(5)
|
|
act(() => {
|
|
mapMock.on.mock.calls.find(([events]) => events === 'movestart zoomstart')![1]()
|
|
mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]()
|
|
})
|
|
expect(markersWithZ('800')).toHaveLength(0)
|
|
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
mapMock.getZoom.mockReturnValue(10)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => {
|
|
render(<MapView routeVias={[via({ dwellSeconds: 1500 })]} />)
|
|
expect(markersWithZ('800')[0].textContent).toContain('25 min')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-042: a bare via carries no tooltip at all', () => {
|
|
render(<MapView routeVias={[via()]} />)
|
|
expect(markersWithZ('800')[0].textContent).toBe('')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-074: a via with a label but no dwell shows the label alone', () => {
|
|
render(<MapView routeVias={[via({ label: 'Rest area' })]} />)
|
|
expect(markersWithZ('800')[0].textContent).toBe('Rest area')
|
|
})
|
|
})
|
|
|
|
describe('MapView map event wiring', () => {
|
|
it('FE-COMP-MAPVIEW-043: reports the viewport bbox once the map is ready and again on every move', () => {
|
|
const onViewportChange = vi.fn()
|
|
render(<MapView onViewportChange={onViewportChange} />)
|
|
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 })
|
|
|
|
const moveend = handlerFor('moveend')
|
|
const zoomend = handlerFor('zoomend')
|
|
expect(moveend).toBeTypeOf('function')
|
|
moveend!()
|
|
zoomend!()
|
|
expect(onViewportChange).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-044: no viewport callback means no listeners at all', () => {
|
|
render(<MapView />)
|
|
expect(mapMock.on.mock.calls.some(c => c[0] === 'moveend')).toBe(false)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-045: unmounting detaches the viewport listeners', () => {
|
|
const { unmount } = render(<MapView onViewportChange={vi.fn()} />)
|
|
unmount()
|
|
expect(mapMock.off).toHaveBeenCalledWith('moveend', expect.any(Function))
|
|
expect(mapMock.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
|
|
const onMapClick = vi.fn()
|
|
const { unmount } = render(<MapView onMapClick={onMapClick} />)
|
|
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
|
|
unmount()
|
|
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
|
|
render(<MapView onMapClick={null} />)
|
|
expect(mapMock.on.mock.calls.some(c => c[0] === 'click')).toBe(false)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
|
|
const onMapContextMenu = vi.fn()
|
|
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
|
|
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
|
|
unmount()
|
|
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
|
|
const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 })]
|
|
const { rerender } = render(<MapView places={places} center={[48, 2]} zoom={9} />)
|
|
mapMock.setView.mockClear()
|
|
|
|
rerender(<MapView places={places} center={[35.01, 135.76]} zoom={11} />)
|
|
expect(mapMock.setView).toHaveBeenCalledWith([35.01, 135.76], 11)
|
|
|
|
// The same center again must not re-issue the camera move.
|
|
mapMock.setView.mockClear()
|
|
rerender(<MapView places={places} center={[35.01, 135.76]} zoom={11} />)
|
|
expect(mapMock.setView).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('MapView track casing pane (#776)', () => {
|
|
it('FE-COMP-MAPVIEW-050: creates the casing pane under the overlay pane and draws casings into it', () => {
|
|
render(<MapView places={[buildMapPlace({
|
|
id: 1, lat: 48, lng: 2, route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#059669',
|
|
})]} />)
|
|
|
|
expect(mapMock.createPane).toHaveBeenCalledWith('trek-track-casing')
|
|
expect(mapMock.panes.get('trek-track-casing')!.style.zIndex).toBe('398')
|
|
|
|
const casing = screen.getAllByTestId('polyline')
|
|
.find(el => JSON.parse(el.getAttribute('data-path-options') || '{}').weight === 6.5)
|
|
expect(casing!.getAttribute('data-pane')).toBe('trek-track-casing')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-072: a renderer without pane support still draws every track', () => {
|
|
const paneless = mapMock as unknown as Record<string, unknown>
|
|
const { getPane, createPane } = paneless
|
|
delete paneless.getPane
|
|
delete paneless.createPane
|
|
try {
|
|
render(<MapView places={[buildMapPlace({
|
|
id: 1, lat: 48, lng: 2, route_geometry: '[[48.0,2.0],[49.0,3.0]]',
|
|
})]} />)
|
|
// Casing, line and hit area are all mounted — just in insertion order.
|
|
expect(screen.getAllByTestId('polyline')).toHaveLength(3)
|
|
const casing = screen.getAllByTestId('polyline')
|
|
.find(el => JSON.parse(el.getAttribute('data-path-options') || '{}').weight === 6.5)
|
|
expect(casing!.getAttribute('data-pane')).toBe('')
|
|
} finally {
|
|
paneless.getPane = getPane
|
|
paneless.createPane = createPane
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('MapView selection panning (#921)', () => {
|
|
it('FE-COMP-MAPVIEW-051: pans to the padding-corrected point when the map can project', () => {
|
|
const projectable = mapMock as unknown as Record<string, unknown>
|
|
const add = vi.fn(() => ({ x: 5, y: 6 }))
|
|
projectable.project = vi.fn(() => ({ add }))
|
|
projectable.unproject = vi.fn(() => [49, 4])
|
|
try {
|
|
const places = [buildMapPlace({ id: 8, lat: 48, lng: 2 })]
|
|
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
|
|
rerender(<MapView places={places} selectedPlaceId={8} />)
|
|
|
|
expect(projectable.project).toHaveBeenCalledWith([48, 2])
|
|
// The offset is half the difference between the two padding corners.
|
|
expect(add).toHaveBeenCalledWith([0, 0])
|
|
expect(mapMock.panTo).toHaveBeenCalledWith([49, 4], { animate: true })
|
|
} finally {
|
|
delete projectable.project
|
|
delete projectable.unproject
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-052: falls back to a plain pan when the map cannot project', () => {
|
|
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
|
|
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
|
|
rerender(<MapView places={places} selectedPlaceId={9} />)
|
|
expect(mapMock.panTo).toHaveBeenCalledWith([48, 2], { animate: true })
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-083: pans to a selected stay whose pin a filter keeps off the map', () => {
|
|
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
|
|
const stay = buildMapPlace({ id: 11, lat: 50, lng: 4 })
|
|
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
|
|
rerender(<MapView places={places} selectedPlaceId={11} selectedPlace={stay} />)
|
|
expect(mapMock.panTo).toHaveBeenCalledWith([50, 4], { animate: true })
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-053: a selected place without coordinates is not panned to', () => {
|
|
const places = [buildMapPlace({ id: 10, lat: null, lng: null })]
|
|
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
|
|
rerender(<MapView places={places} selectedPlaceId={10} />)
|
|
expect(mapMock.panTo).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('MapView hover card', () => {
|
|
const place = () => buildMapPlace({ id: 12, name: 'Eiffel Tower', lat: 48.85, lng: 2.29 })
|
|
|
|
it('FE-COMP-MAPVIEW-054: mousemove keeps the card following the cursor', () => {
|
|
render(<MapView places={[place()]} />)
|
|
fireEvent.click(screen.getByTestId('marker-move-trigger'))
|
|
const card = screen.getByTestId('tooltip')
|
|
expect(card.textContent).toContain('Eiffel Tower')
|
|
expect(card.style.left).toBe('164px') // 150 + 14
|
|
expect(card.style.top).toBe('150px') // 160 - 10
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-055: mouseout clears the card', () => {
|
|
render(<MapView places={[place()]} />)
|
|
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByTestId('marker-out-trigger'))
|
|
expect(screen.queryByTestId('tooltip')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-056: scrolling the page drops the card so it cannot get orphaned', () => {
|
|
render(<MapView places={[place()]} />)
|
|
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
|
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
|
|
|
fireEvent.scroll(window)
|
|
expect(screen.queryByTestId('tooltip')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-057: hoverDisabled suppresses the card entirely', () => {
|
|
render(<MapView places={[place()]} hoverDisabled />)
|
|
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
|
fireEvent.click(screen.getByTestId('marker-move-trigger'))
|
|
expect(screen.queryByTestId('tooltip')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('MapView live location', () => {
|
|
const fix = (over: Record<string, any> = {}) => ({ lat: 48.2, lng: 16.37, accuracy: 25, heading: null, ...over })
|
|
|
|
it('FE-COMP-MAPVIEW-058: no fix means no dot, circle or heading cone', () => {
|
|
render(<MapView />)
|
|
expect(screen.queryByTestId('circle-marker')).toBeNull()
|
|
expect(screen.queryByTestId('circle')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-059: a fix draws the blue dot and its accuracy circle', () => {
|
|
geoMock.position = fix()
|
|
geoMock.mode = 'show'
|
|
render(<MapView />)
|
|
expect(screen.getByTestId('circle-marker')).toBeTruthy()
|
|
expect(screen.getByTestId('circle')).toBeTruthy()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-060: a city-level fix draws the dot but no accuracy circle', () => {
|
|
geoMock.position = fix({ accuracy: 900 })
|
|
geoMock.mode = 'show'
|
|
render(<MapView />)
|
|
expect(screen.getByTestId('circle-marker')).toBeTruthy()
|
|
expect(screen.queryByTestId('circle')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-061: a compass heading adds a rotated cone marker', () => {
|
|
geoMock.position = fix({ heading: 135 })
|
|
geoMock.mode = 'show'
|
|
render(<MapView />)
|
|
const cone = screen.getAllByTestId('marker').find(m => iconHtmlOf(m).includes('rotate(135deg)'))
|
|
expect(cone).toBeTruthy()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-062: show mode recentres once and then leaves the camera alone', () => {
|
|
geoMock.position = fix()
|
|
geoMock.mode = 'show'
|
|
const { rerender } = render(<MapView />)
|
|
expect(mapMock.setView).toHaveBeenCalledWith([48.2, 16.37], 15)
|
|
|
|
mapMock.setView.mockClear()
|
|
geoMock.position = fix({ lat: 48.25 })
|
|
// A prop change forces the re-render the new fix would otherwise not cause.
|
|
rerender(<MapView hoverDisabled />)
|
|
expect(mapMock.setView).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-063: follow mode recentres on every fix at navigation zoom', () => {
|
|
geoMock.position = fix()
|
|
geoMock.mode = 'follow'
|
|
render(<MapView />)
|
|
expect(mapMock.setView).toHaveBeenCalledWith([48.2, 16.37], 16, { animate: true, duration: 0.35 })
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-064: a mobile viewport gets the location button, desktop does not', () => {
|
|
render(<MapView />)
|
|
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
|
|
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
|
geoMock.mode = 'show'
|
|
render(<MapView />)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Follow my location' }))
|
|
expect(geoMock.cycleMode).toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
|
geoMock.mode = 'off'
|
|
render(<MapView />)
|
|
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
|
|
|
|
// landscape crosses the 768px cut without remounting the map
|
|
act(() => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 })
|
|
window.dispatchEvent(new Event('resize'))
|
|
})
|
|
expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull()
|
|
|
|
act(() => {
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
|
window.dispatchEvent(new Event('resize'))
|
|
})
|
|
expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => {
|
|
const desktop = render(<MapView />)
|
|
// The desktop credit is Leaflet's own, in its own container; this component adds none.
|
|
expect(screen.queryByRole('button', { name: 'Map credits' })).toBeNull()
|
|
desktop.unmount()
|
|
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
|
const { container } = render(<MapView />)
|
|
// The phone used to get an (i) here that opened the credit through a class on the
|
|
// wrapper. The phone map now carries no visible credit at all (mobile.css), so neither
|
|
// the button nor the class it toggled is left behind.
|
|
expect(screen.queryByRole('button', { name: 'Map credits' })).toBeNull()
|
|
const wrapper = container.querySelector('div.w-full.h-full.relative') as HTMLElement
|
|
expect(wrapper.classList.contains('m-attrib-open')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('MapView bounds fitting', () => {
|
|
/*
|
|
* The day fit used to compensate for the day panel twice: once through the
|
|
* padding, and again 300ms later with panBy([0, 150]) (#1348). On a route
|
|
* running north to south the fit is height-bound, so the northernmost stop
|
|
* lands exactly on the top padding line and the nudge then pushed it off the
|
|
* canvas — the map framed everything and then drifted upwards (#1982).
|
|
*
|
|
* So this case is the inverse of what it used to assert: the padding does the
|
|
* work, and nothing moves the camera afterwards.
|
|
*/
|
|
it('FE-COMP-MAPVIEW-065: a day-detail fit reserves the panel in its padding and then leaves the camera alone (#1348, #1982)', async () => {
|
|
const places = [
|
|
buildMapPlace({ id: 1, lat: 48, lng: 2 }),
|
|
buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }),
|
|
]
|
|
const { rerender } = render(<MapView places={places} dayPlaces={places} fitKey={1} hasDayDetail />)
|
|
rerender(<MapView places={places} dayPlaces={places} fitKey={2} hasDayDetail />)
|
|
|
|
expect(mapMock.fitBounds).toHaveBeenCalled()
|
|
const calls = mapMock.fitBounds.mock.calls
|
|
const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] }
|
|
expect(opts?.paddingBottomRight?.[1]).toBe(280)
|
|
|
|
// Long enough that the old 300ms nudge would have fired by now.
|
|
await new Promise(r => setTimeout(r, 600))
|
|
expect(mapMock.panBy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-065b: reserves only the small margin when no day panel is open (#1982)', () => {
|
|
const places = [
|
|
buildMapPlace({ id: 1, lat: 48, lng: 2 }),
|
|
buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }),
|
|
]
|
|
const { rerender } = render(<MapView places={places} dayPlaces={places} fitKey={1} />)
|
|
rerender(<MapView places={places} dayPlaces={places} fitKey={2} />)
|
|
|
|
const calls = mapMock.fitBounds.mock.calls
|
|
const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] }
|
|
expect(opts?.paddingBottomRight?.[1]).toBe(60)
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-066: a fitKey that never changed is not re-fitted', async () => {
|
|
const L = await leafletMock()
|
|
L.latLngBounds.mockClear()
|
|
render(<MapView places={[buildMapPlace({ id: 1, lat: 48, lng: 2 })]} fitKey={-1} />)
|
|
expect(L.latLngBounds).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-067: an invalid bounds object is ignored instead of throwing', async () => {
|
|
const L = await leafletMock()
|
|
L.latLngBounds.mockImplementationOnce(() => ({ isValid: () => false }))
|
|
const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 })]
|
|
const { rerender } = render(<MapView places={places} fitKey={1} />)
|
|
mapMock.fitBounds.mockClear()
|
|
rerender(<MapView places={places} fitKey={2} />)
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-078: a caller padding frames the focus points instead of the phone margin, and the day fit keeps its own', () => {
|
|
// A phone shell lays a chip rail over the top of the map and a bar plus the dock over
|
|
// the bottom. The flat margin put the ends of a framed leg under either of them.
|
|
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
|
|
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
|
|
const chrome = { top: 120, right: 16, bottom: 290, left: 16 }
|
|
const { rerender } = render(<MapView places={[]} fitKey={1} />)
|
|
expect(mapMock.fitBounds).not.toHaveBeenCalled()
|
|
|
|
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={chrome} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
|
|
expect(mapMock.fitBounds.mock.calls[0][1]).toMatchObject({ paddingTopLeft: [16, 120], paddingBottomRight: [16, 290] })
|
|
|
|
// Picking a day is not the caller's frame, so that fit keeps the phone margin.
|
|
const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 }), buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 })]
|
|
rerender(<MapView places={places} fitKey={2} focusPoints={leg} fitPadding={chrome} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(2)
|
|
expect(mapMock.fitBounds.mock.calls[1][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
|
|
|
|
// And without one, the focus fit falls back to that margin too.
|
|
const stage: [number, number][] = [[47, 1], [47.5, 1.5]]
|
|
rerender(<MapView places={places} fitKey={2} focusPoints={stage} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(3)
|
|
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
|
|
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
|
|
const { rerender } = render(
|
|
<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />,
|
|
)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
|
|
|
|
// A parent that builds the object inline hands a new one on every render. The camera
|
|
// belongs to the traveller between fits, so that alone must not take it back.
|
|
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
|
|
|
|
// New numbers mean the chrome moved, so the same points are framed again around it.
|
|
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 98, left: 16 }} />)
|
|
expect(mapMock.fitBounds).toHaveBeenCalledTimes(2)
|
|
expect(mapMock.fitBounds.mock.calls[1][1]).toMatchObject({ paddingTopLeft: [16, 120], paddingBottomRight: [16, 98] })
|
|
})
|
|
})
|
|
|
|
describe('MapView photo thumbnails', () => {
|
|
const THUMB = 'data:image/jpeg;base64,THUMBDATA'
|
|
|
|
it('FE-COMP-MAPVIEW-068: a photo that arrives after mount is folded into the marker icon', async () => {
|
|
render(<MapView places={[buildMapPlace({ id: 21, lat: 48, lng: 2, google_place_id: 'gp-21' })]} />)
|
|
const deliver = thumbCallbacks.get('gp-21')
|
|
expect(deliver).toBeTypeOf('function')
|
|
|
|
// Two deliveries in one frame collapse into a single re-render.
|
|
act(() => { deliver!(THUMB); deliver!(THUMB) })
|
|
await waitFor(() => expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain(THUMB))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-069: re-delivering the same thumb does not change the icon', async () => {
|
|
render(<MapView places={[buildMapPlace({ id: 22, lat: 48, lng: 2, google_place_id: 'gp-22' })]} />)
|
|
const deliver = thumbCallbacks.get('gp-22')!
|
|
act(() => { deliver(THUMB) })
|
|
await waitFor(() => expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain(THUMB))
|
|
|
|
const before = iconHtmlOf(screen.getAllByTestId('marker')[0])
|
|
act(() => { deliver(THUMB) })
|
|
await waitFor(() => expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toBe(before))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-075: an unchanged batch keeps the previous photo state object', () => {
|
|
// Same case as above, but with the batching frame under the test's control so
|
|
// the "nothing changed" path in the state updater really runs before teardown.
|
|
const frames: FrameRequestCallback[] = []
|
|
const flush = () => { frames.splice(0).forEach(cb => cb(0)) }
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => frames.push(cb))
|
|
vi.stubGlobal('cancelAnimationFrame', () => { frames.length = 0 })
|
|
try {
|
|
render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />)
|
|
const deliver = thumbCallbacks.get('gp-24')!
|
|
|
|
act(() => { deliver(THUMB) })
|
|
act(flush)
|
|
const withThumb = iconHtmlOf(screen.getAllByTestId('marker')[0])
|
|
expect(withThumb).toContain(THUMB)
|
|
|
|
act(() => { deliver(THUMB) })
|
|
act(flush)
|
|
expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toBe(withThumb)
|
|
} finally {
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-070: a place with a custom uploaded image is never auto-fetched', () => {
|
|
render(<MapView places={[buildMapPlace({ id: 23, lat: 48, lng: 2, image_url: '/uploads/places/mine.jpg' })]} />)
|
|
expect(thumbCallbacks.size).toBe(0)
|
|
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
|
|
expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain('/uploads/places/mine.jpg')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-076: a place with neither provider id nor coordinates has no cache key and is skipped', () => {
|
|
render(<MapView places={[
|
|
buildMapPlace({ id: 25, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
|
|
buildMapPlace({ id: 26, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
|
|
]} />)
|
|
expect(thumbCallbacks.size).toBe(0)
|
|
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-071: photos are not fetched at all when the feature is off', () => {
|
|
useAuthStore.setState({ placesPhotosEnabled: false })
|
|
render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />)
|
|
expect(thumbCallbacks.size).toBe(0)
|
|
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-080: an uploaded photo fills its marker whatever its proportions', () => {
|
|
// Leaflet's marker pane sets `width: auto` on every img in it, so a photo sized
|
|
// by attributes was drawn at its full pixel size and the circle only ever showed
|
|
// the transparent corner of it, over the category colour.
|
|
render(<MapView places={[buildMapPlace({ id: 27, lat: 48, lng: 2, image_url: '/uploads/places/wide.jpg' })]} />)
|
|
const holder = document.createElement('div')
|
|
holder.innerHTML = iconHtmlOf(screen.getAllByTestId('marker')[0])
|
|
const img = holder.querySelector('img')!
|
|
|
|
expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg')
|
|
expect(img.style.width).toBe('100%')
|
|
expect(img.style.height).toBe('100%')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-081: taking the upload off a place asks for its auto photo again, without a reload', () => {
|
|
const place = buildMapPlace({ id: 28, lat: 48, lng: 2, google_place_id: 'gp-28', name: 'Tower', image_url: '/uploads/places/own.jpg' })
|
|
const { rerender } = render(<MapView places={[place]} />)
|
|
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
|
|
|
|
rerender(<MapView places={[{ ...place, image_url: null }]} />)
|
|
|
|
expect(vi.mocked(photoService.fetchPhoto)).toHaveBeenCalledWith('gp-28', 'gp-28', 48, 2, 'Tower')
|
|
expect(thumbCallbacks.has('gp-28')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// The marker HTML is a hand-built string handed to L.divIcon, i.e. innerHTML.
|
|
// Two of its interpolations carry values a user controls.
|
|
describe('MapView — untrusted values in the marker HTML', () => {
|
|
const iconHtml = () => screen.getAllByTestId('marker').map(el => el.getAttribute('data-icon-html') || '').join('')
|
|
|
|
it('FE-COMP-MAPVIEW-072: a category colour that is not a hex value is dropped, not escaped', () => {
|
|
// Escaping alone would stop the attribute breakout but still leave a working
|
|
// CSS value, so the colour is allow-listed and anything else falls back.
|
|
render(<MapView places={[buildMapPlace({
|
|
lat: 48, lng: 2, category_color: 'red" onmouseover="alert(1)',
|
|
})]} />)
|
|
expect(iconHtml()).not.toContain('onmouseover')
|
|
expect(iconHtml()).toContain('#6b7280')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-073: a CSS url() in the category colour never reaches the style attribute', () => {
|
|
render(<MapView places={[buildMapPlace({ lat: 48, lng: 2, category_color: 'url(https://evil.example/px)' })]} />)
|
|
expect(iconHtml()).not.toContain('evil.example')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-074: an image_url that passes the /uploads/ prefix check is still escaped', () => {
|
|
// The prefix check only looks at the start of the string, so a payload can
|
|
// satisfy it and then break out of the src="…" attribute.
|
|
render(<MapView places={[buildMapPlace({
|
|
lat: 48, lng: 2, image_url: '/uploads/x" onerror="alert(1)" y="',
|
|
})]} />)
|
|
expect(iconHtml()).not.toContain('onerror="alert(1)"')
|
|
expect(iconHtml()).toContain('"')
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-075: an ordinary hex colour is passed through untouched', () => {
|
|
render(<MapView places={[buildMapPlace({ lat: 48, lng: 2, category_color: '#00ff00' })]} />)
|
|
expect(iconHtml()).toContain('#00ff00')
|
|
})
|
|
})
|
|
|
|
// Stops modelling one building — drop the bags, check in, the museum inside it — all
|
|
// carry the same coordinates, so only the top pin of the pile is ever on screen. The
|
|
// places rail used to raise the chosen one with a z-index; inside a bubble there is no
|
|
// pin to raise, so the bubble has to open instead (#2344).
|
|
describe('MapView — picking a stop that shares its coordinates', () => {
|
|
const hotel = { lat: 48.8584, lng: 2.2945 }
|
|
const stacked = [
|
|
buildMapPlace({ id: 1, name: 'Drop the bags', ...hotel }),
|
|
buildMapPlace({ id: 2, name: 'Check in', ...hotel }),
|
|
buildMapPlace({ id: 3, name: 'Louvre', lat: 48.8606, lng: 2.3376 }),
|
|
]
|
|
/** The place pins, in the order the places were handed over. */
|
|
const pins = () => screen.getAllByTestId('marker').filter(node => node.closest('[data-testid="cluster-group"]'))
|
|
const markerOf = (index: number) => clusterMock.markers.get(pins()[index])
|
|
|
|
it('FE-COMP-MAPVIEW-077: the bubble it is hiding in is fanned open', () => {
|
|
clusterMock.stacked = true
|
|
const { rerender } = render(<MapView places={stacked} />)
|
|
expect(clusterMock.spiderfy).not.toHaveBeenCalled()
|
|
|
|
rerender(<MapView places={stacked} selectedPlaceId={2} />)
|
|
expect(clusterMock.spiderfy).toHaveBeenCalled()
|
|
// And it asked about the stop that was picked — not about the one stacked under it,
|
|
// which carries the same coordinates, and not about whichever registered first.
|
|
expect(clusterMock.asked).toContain(markerOf(1))
|
|
expect(clusterMock.asked).not.toContain(markerOf(0))
|
|
expect(clusterMock.asked).not.toContain(markerOf(2))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-079: a stop in another pile is looked up by its own id', () => {
|
|
clusterMock.stacked = true
|
|
const { rerender } = render(<MapView places={stacked} />)
|
|
rerender(<MapView places={stacked} selectedPlaceId={3} />)
|
|
|
|
expect(clusterMock.asked).toContain(markerOf(2))
|
|
expect(clusterMock.asked).not.toContain(markerOf(0))
|
|
expect(clusterMock.asked).not.toContain(markerOf(1))
|
|
})
|
|
|
|
it('FE-COMP-MAPVIEW-078: a stop with a pin of its own is left alone, camera included', () => {
|
|
clusterMock.stacked = false
|
|
const { rerender } = render(<MapView places={stacked} />)
|
|
rerender(<MapView places={stacked} selectedPlaceId={1} />)
|
|
|
|
expect(clusterMock.spiderfy).not.toHaveBeenCalled()
|
|
// The selection still only pans: the zoom belongs to the day fit.
|
|
expect(mapMock.panTo).toHaveBeenCalled()
|
|
expect(mapMock.setView).not.toHaveBeenCalled()
|
|
})
|
|
})
|