mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
993 lines
48 KiB
TypeScript
993 lines
48 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, render, screen } from '../../../helpers/render'
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
|
import { buildAssignment, buildPlace } from '../../../helpers/factories'
|
|
import type { AccessSpur, RoadtripDay } from '@trek/shared/roadtrip'
|
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
|
|
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../src/components/Map/MapLayerSwitcher'
|
|
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import { seedStore } from '../../../helpers/store'
|
|
import type { Day, Place, Reservation } from '../../../../src/types'
|
|
import { visibleRouteReservations, type RouteVisibilityOptions } from '../../../../src/utils/reservationRoutes'
|
|
import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alternativeOverlays'
|
|
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
|
import { openLeg } from '../../../helpers/legAlternatives'
|
|
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
|
|
import { corePoiCategories, pluginPoiCategories } from '../../../../src/components/Map/usePoiCategories'
|
|
|
|
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-049
|
|
//
|
|
// The stage's pins come out of the trip store rather than the planner's map list, so the
|
|
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
poi: {} as Record<string, unknown>,
|
|
/** The traveller's road trip preferences, read per trip by the area. */
|
|
prefs: {} as Record<string, unknown>,
|
|
/** Handed to onMapReady, standing in for a GL map that can rotate. */
|
|
glMap: null as CompassMap | null,
|
|
/** Last props the area handed the renderer, so its callbacks can be fired. */
|
|
props: {} as Record<string, unknown>,
|
|
}))
|
|
|
|
// The real renderer boots Leaflet/MapLibre; the area only ever hands it props.
|
|
vi.mock('../../../../src/components/Map/MapViewAuto', () => ({
|
|
MapViewAuto: (props: Record<string, unknown>) => {
|
|
mocks.props = props
|
|
;(props.onMapReady as ((map: CompassMap | null) => void) | undefined)?.(mocks.glMap)
|
|
return <div data-testid="map-renderer" />
|
|
},
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Map/usePoiExplore', () => ({
|
|
usePoiExplore: () => mocks.poi,
|
|
}))
|
|
|
|
vi.mock('../../../../src/hooks/useRoadtripSettings', () => ({
|
|
useRoadtripSettings: (select: (p: Record<string, unknown>) => unknown) => select(mocks.prefs),
|
|
}))
|
|
|
|
import MMapArea from '../../../../src/mobile/screens/trip/map/MMapArea'
|
|
|
|
const COMPASS: CompassMap = {
|
|
getBearing: () => 0,
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
easeTo: vi.fn(),
|
|
}
|
|
|
|
function renderArea(shellOver: Partial<MTripShellApi> = {}, plannerOver: Partial<TripPlanner> = {}) {
|
|
const planner = buildPlanner(plannerOver)
|
|
// `mapFront` is what the floating chrome keys off since the map became one
|
|
// instance shared by the plan tab and the road trip tab. `view` alone no longer
|
|
// says whether the map is the front layer.
|
|
const shell = buildShell({ view: 'map', mapFront: true, ...shellOver })
|
|
return { planner, shell, ...render(<MMapArea planner={planner} shell={shell} />) }
|
|
}
|
|
|
|
/** The compass wrapper is the only element carrying an inline bottom offset. */
|
|
const compassBand = (container: HTMLElement) =>
|
|
container.querySelector('[style*="--bottom-nav-h"]') as HTMLElement | null
|
|
|
|
/** One stage of a drive: two stops on day 1, one of them reached down a short spur. */
|
|
function stageDay(): RoadtripDay {
|
|
return {
|
|
dayId: 3, dayNumber: 1, date: '2026-05-01', title: null,
|
|
stops: [
|
|
{
|
|
assignmentId: 31, ownerDayId: 3, ownerIndex: 0, placeId: 11, name: 'Hamburg',
|
|
lat: 53.55, lng: 9.99, time: null, dwellMinutes: null,
|
|
legMode: null, incomingLegMode: null, stopType: null,
|
|
},
|
|
{
|
|
assignmentId: 32, ownerDayId: 3, ownerIndex: 1, placeId: 12, name: 'Lübeck',
|
|
lat: 53.87, lng: 10.69, time: null, dwellMinutes: null,
|
|
legMode: null, incomingLegMode: null, stopType: null,
|
|
},
|
|
],
|
|
legs: [], legVias: [[]], geometry: [], distance: 0, duration: 0,
|
|
schedule: { entries: [], warnings: [] }, driveWarnings: [], dayWarning: null,
|
|
} as unknown as RoadtripDay
|
|
}
|
|
|
|
/** The planner carrying that stage, plus a place from another day the stage leaves off. */
|
|
function stagePlanner(selectedDayId: number | null): TripPlanner {
|
|
const base = buildPlanner()
|
|
const spur: AccessSpur = { line: [[53.87, 10.69], [53.871, 10.692]], meters: 180, stopKey: '53.87000,10.69000,,' }
|
|
seedStore(useTripStore, { places: [buildPlace({ id: 11 }), buildPlace({ id: 12 }), buildPlace({ id: 99 })] })
|
|
return buildPlanner({
|
|
selectedDayId,
|
|
mapPlaces: [buildPlace({ id: 11 }), buildPlace({ id: 12 }), buildPlace({ id: 99 })],
|
|
// The stored visits, which are what the all days view draws: 99 is on no day.
|
|
storedAssignments: {
|
|
'3': [
|
|
buildAssignment({ day_id: 3, place: buildPlace({ id: 11 }) }),
|
|
buildAssignment({ day_id: 3, place: buildPlace({ id: 12 }) }),
|
|
],
|
|
},
|
|
roadtripRoutes: {
|
|
...base.roadtripRoutes,
|
|
days: [stageDay()],
|
|
lines: [[[53.55, 9.99], [53.87, 10.69]]],
|
|
lineDays: [1],
|
|
accessLines: [spur],
|
|
},
|
|
} as unknown as Partial<TripPlanner>)
|
|
}
|
|
|
|
/**
|
|
* Two routed days. Lübeck is passed on the day before the stage, and the stage itself is a
|
|
* loop that leaves Hamburg in the morning and comes back to it at night.
|
|
*/
|
|
function drivePlanner(selectedDayId: number | null, over: Partial<TripPlanner> = {}): TripPlanner {
|
|
const base = stagePlanner(selectedDayId)
|
|
const stage = stageDay()
|
|
const [hamburg, luebeck] = stage.stops
|
|
const before = { ...stage, dayId: 2, dayNumber: 1, stops: [{ ...luebeck, assignmentId: 21, ownerDayId: 2, ownerIndex: 0 }] }
|
|
const loop = { ...stage, dayNumber: 2, stops: [hamburg, luebeck, { ...hamburg, assignmentId: 33, ownerIndex: 2 }] }
|
|
return {
|
|
...base,
|
|
roadtripRoutes: { ...base.roadtripRoutes, days: [before, loop], lineDays: [2] },
|
|
...over,
|
|
} as TripPlanner
|
|
}
|
|
|
|
/** The shell with the road trip map in front. */
|
|
const stageShell = () => buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip', rtView: 'map' })
|
|
|
|
/** The pin handler the area last handed the renderer. */
|
|
const tapPin = (placeId?: number) => (mocks.props.onMarkerClick as (id?: number) => void)(placeId)
|
|
|
|
/** What the area handed the renderer that a fresh copy would make it draw again. */
|
|
const DRAWN = ['places', 'route', 'routeColors', 'accessLines', 'focusPoints'] as const
|
|
|
|
/** A picker open on leg 0 of day 3, with or without roads back yet. */
|
|
function withPicker(overlays: AlternativeOverlay[], over: Partial<TripPlanner> = {}): Partial<TripPlanner> {
|
|
const base = buildPlanner()
|
|
const open: LegAlternatives = openLeg({ dayId: 3, drive: { kind: 'leg', index: 0 }, loading: overlays.length === 0 })
|
|
return {
|
|
routeAlternatives: { ...base.routeAlternatives, open },
|
|
alternativeOverlays: overlays,
|
|
...over,
|
|
} as Partial<TripPlanner>
|
|
}
|
|
|
|
/** Two offered roads, as far as the map area reads them. */
|
|
const OFFERS = [
|
|
{ index: 0, coordinates: [[53.55, 9.99], [53.87, 10.69]], color: 'blue', label: '1 h', note: 'Current' },
|
|
{ index: 1, coordinates: [[53.55, 9.99], [53.7, 10.2], [53.87, 10.69]], color: 'pale', label: '1 h 10 min', note: '' },
|
|
] as unknown as AlternativeOverlay[]
|
|
|
|
/** The probe the area reads the safe area off, answering like a phone with a notch. */
|
|
function notchedPhone(top: string, bottom: string) {
|
|
const real = window.getComputedStyle.bind(window)
|
|
return vi.spyOn(window, 'getComputedStyle').mockImplementation((el: Element, pseudo?: string | null) => (
|
|
el.className.toString().includes('safe-area-inset-top')
|
|
? ({ paddingTop: top, paddingBottom: bottom } as CSSStyleDeclaration)
|
|
: real(el, pseudo)
|
|
))
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
beforeEach(() => {
|
|
mocks.glMap = COMPASS
|
|
mocks.prefs = {}
|
|
mocks.poi = {
|
|
categories: { core: corePoiCategories(key => key), plugin: [] },
|
|
active: new Set<string>(), pois: [], loadingKeys: new Set<string>(), errorKeys: new Set<string>(),
|
|
moved: false, toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(),
|
|
}
|
|
useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: true } }))
|
|
seedStore(useTripStore, { places: [], placesFilter: 'all', placesCategoryFilter: new Set<string>(), placesRatingFilter: 'all' })
|
|
})
|
|
|
|
describe('MMapArea', () => {
|
|
it('FE-MOB-MAPAREA-001: the POI bar takes the full width between the screen margins', () => {
|
|
renderArea()
|
|
|
|
const segment = screen.getAllByRole('button')[0]
|
|
expect(segment.style.flexGrow).toBe('1')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-002: the compass stands on top of the base-layer switcher', () => {
|
|
const { container } = renderArea()
|
|
|
|
// The switcher's band, raised by its size and one gap, on the switcher's own left edge.
|
|
expect(compassBand(container)?.style.bottom).toBe(`calc(var(--bottom-nav-h, 84px) + ${12 + MAP_ROUND_CONTROL_SIZE + 8}px)`)
|
|
expect(compassBand(container)?.style.left).toBe(`${MAP_LAYER_SWITCHER_INSET}px`)
|
|
expect(compassBand(container)?.className).not.toContain('left-3')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-003: the map layer stands those controls on the dock, with no credit row under them', () => {
|
|
const { container } = renderArea()
|
|
const layer = container.firstElementChild as HTMLElement
|
|
|
|
// The floor is the dock's top edge: 62px tall at safe-bottom + 12, raised by the stage lift.
|
|
expect(layer.className)
|
|
.toContain('[--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]')
|
|
// The band sits on that floor and the controls add their own 12 on top. It used to float
|
|
// a further 38px up to leave the corner under it to the credit; the phone map shows none,
|
|
// so that row would only be a gap over the dock.
|
|
expect(layer.className).toContain('[--bottom-nav-h:var(--m-map-floor)]')
|
|
expect(layer.className).not.toContain('38px')
|
|
// No lift off the stage: the plan tab has no bar in that band.
|
|
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-013: on the stage nothing stands over the dock, so the floor stays on it', () => {
|
|
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
|
|
|
|
// A 61px stage bar used to hold this slot whenever the map was up, and the floor rose
|
|
// 76px to clear it. It is gone, so the road trip tab lifts nothing by itself; only the
|
|
// picker's bar still raises the floor (FE-MOB-MAPAREA-030).
|
|
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-014: behind the chain there is no bar to clear, so nothing lifts', () => {
|
|
const { container } = renderArea({ trTab: 'roadtrip', mapFront: false })
|
|
|
|
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-004: a renderer that cannot rotate gets no compass', () => {
|
|
mocks.glMap = null
|
|
const { container } = renderArea()
|
|
|
|
expect(compassBand(container)).toBeNull()
|
|
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-005: turning the POI bar off leaves the compass alone', () => {
|
|
useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: false } }))
|
|
const { container } = renderArea()
|
|
|
|
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
|
expect(compassBand(container)).not.toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-006: no floating chrome while the timeline covers the map', () => {
|
|
const { container } = renderArea({ view: 'plan', mapFront: false })
|
|
|
|
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
|
expect(compassBand(container)).toBeNull()
|
|
// The renderer itself stays mounted so tiles and markers keep their warmth.
|
|
expect(screen.getByTestId('map-renderer')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-007: a transport overlay tap opens the mobile transport sheet', () => {
|
|
const { shell } = renderArea()
|
|
|
|
;(mocks.props.onReservationClick as (id: number) => void)(7)
|
|
|
|
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 7 })
|
|
})
|
|
it('FE-MOB-MAPAREA-008: on the stage the pins are the search and the fuel offers, and nothing else', () => {
|
|
const corridorHit = { osm_id: 'c1', name: 'Shell', lat: 53.5, lng: 9.8, category: 'fuel' }
|
|
const offered = { osm_id: 'r1', name: 'Aral', lat: 53.4, lng: 9.7, category: 'fuel' }
|
|
mocks.poi = { ...mocks.poi, pois: [{ osm_id: 'e1', name: 'Café', lat: 53.3, lng: 9.6, category: 'cafe' }] }
|
|
const base = buildPlanner()
|
|
renderArea({ trTab: 'roadtrip' }, {
|
|
roadtripCorridor: { ...base.roadtripCorridor, visible: [corridorHit] },
|
|
refuel: { ...base.refuel, offered: [offered] },
|
|
} as unknown as Partial<TripPlanner>)
|
|
|
|
const ids = (mocks.props.pois as Array<{ osm_id: string }>).map(p => p.osm_id)
|
|
expect(ids).toEqual(expect.arrayContaining(['c1', 'r1']))
|
|
// "What is around this piece of map" is a different question from "what is on the
|
|
// way", and its bar is off this tab, so its pins would be pins nobody could clear.
|
|
expect(ids).not.toContain('e1')
|
|
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-009: off the stage a corridor hit is not drawn at all', () => {
|
|
const base = buildPlanner()
|
|
renderArea({ trTab: 'plan' }, {
|
|
roadtripCorridor: { ...base.roadtripCorridor, visible: [{ osm_id: 'c1', lat: 53.5, lng: 9.8, category: 'fuel' }] },
|
|
} as unknown as Partial<TripPlanner>)
|
|
|
|
// A pin with no route under it cannot explain where on the drive it sits.
|
|
expect((mocks.props.pois as Array<{ osm_id: string }>).map(p => p.osm_id)).not.toContain('c1')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-010: a pin tapped on the stage goes through the road trip door', () => {
|
|
const { planner } = renderArea({ trTab: 'roadtrip' })
|
|
|
|
const marker = { osm_id: 'c1', name: 'Shell', lat: 53.5, lng: 9.8, category: 'fuel', alongKm: 82 }
|
|
;(mocks.props.onPoiClick as (m: unknown) => void)(marker)
|
|
|
|
expect(planner.handlePoiClick).toHaveBeenCalledWith(marker)
|
|
expect(planner.openAddPlaceFromPoi).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-011: on the plan tab it stays the plain place form, carrying the day', () => {
|
|
const { planner } = renderArea({ trTab: 'plan' }, { selectedDayId: 5 })
|
|
|
|
const marker = { osm_id: 'e1', name: 'Café', lat: 53.3, lng: 9.6, category: 'cafe' }
|
|
;(mocks.props.onPoiClick as (m: unknown) => void)(marker)
|
|
|
|
expect(planner.openAddPlaceFromPoi).toHaveBeenCalledWith(marker, 5)
|
|
expect(planner.handlePoiClick).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-045: a plugin POI is drawn from the search and tapped into the place form like any other', () => {
|
|
const trailhead = {
|
|
osm_id: 'plugin:trail-finder:th-1', name: 'Trailhead', lat: 53.3, lng: 9.6, category: 'plugin:trail-finder/trailheads',
|
|
source: 'plugin:trail-finder', pluginId: 'trail-finder', icon: 'Signpost', color: '#2f855a',
|
|
details: [{ label: 'Length', value: '12 km' }],
|
|
}
|
|
mocks.poi = { ...mocks.poi, pois: [trailhead] }
|
|
const { planner } = renderArea({ trTab: 'plan' }, { selectedDayId: 5 })
|
|
|
|
// Handed to the renderer whole, so the pin and the hover card can read its look and rows.
|
|
expect(mocks.props.pois).toEqual([trailhead])
|
|
;(mocks.props.onPoiClick as (m: unknown) => void)(trailhead)
|
|
expect(planner.openAddPlaceFromPoi).toHaveBeenCalledWith(trailhead, 5)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-012: a focused hit takes the camera; with nothing pending the stage frames itself', () => {
|
|
const focused = renderArea({ trTab: 'roadtrip' }, { mapFocusPoints: [[53.5, 9.8]] })
|
|
expect(mocks.props.focusPoints).toEqual([[53.5, 9.8]])
|
|
focused.unmount()
|
|
|
|
renderArea({ trTab: 'roadtrip' })
|
|
// The stage's own points, which is an array either way: going array → undefined →
|
|
// array is two dependency changes, and the second throws away the traveller's pan.
|
|
expect(Array.isArray(mocks.props.focusPoints)).toBe(true)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-015: the compass never takes the base-layer switcher\'s slot, on either tab', () => {
|
|
for (const shellOver of [{ trTab: 'plan' }, { trTab: 'roadtrip', mapFront: true }] as Partial<MTripShellApi>[]) {
|
|
const { container, unmount } = renderArea(shellOver)
|
|
|
|
// Above the switcher: raised past its full height, so the two never overlap.
|
|
expect(compassBand(container)?.style.bottom).toContain(`+ ${12 + MAP_ROUND_CONTROL_SIZE + 8}px`)
|
|
// With the renderer mocked, the compass is still the one element in this layer
|
|
// that sets --bottom-nav-h inline; its left offset joined the same style object.
|
|
expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1)
|
|
unmount()
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-016: a re-render with nothing new on the stage hands the map the same drawing', () => {
|
|
const planner = stagePlanner(3)
|
|
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip' })
|
|
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
|
|
const first = { ...mocks.props }
|
|
|
|
// The stage really is what is drawn, so the identities below are about something.
|
|
expect((first.places as Array<{ id: number }>).map(p => p.id)).toEqual([11, 12])
|
|
expect(first.route).toHaveLength(1)
|
|
expect(first.accessLines).toHaveLength(1)
|
|
expect(first.focusPoints).toEqual([[53.55, 9.99], [53.87, 10.69]])
|
|
|
|
// The shell re-renders on every store write, a satellite tap included. Fresh arrays
|
|
// here would refit the camera over the traveller's pan and set the map's sources
|
|
// again, which is also what kept the style too busy to draw the imagery.
|
|
rerender(<MMapArea planner={planner} shell={shell} />)
|
|
|
|
for (const key of DRAWN) expect(mocks.props[key]).toBe(first[key])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-017: the all days view keeps its day colours between renders too', () => {
|
|
mocks.prefs = { roadtrip_day_colors: true }
|
|
const planner = stagePlanner(null)
|
|
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip' })
|
|
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
|
|
const first = { ...mocks.props }
|
|
|
|
// One colour per line, worked out per call, so without the memo a new array each time.
|
|
expect(first.routeColors).toHaveLength(1)
|
|
// Still an array with no stage, which FE-MOB-MAPAREA-012 depends on.
|
|
expect(Array.isArray(first.focusPoints)).toBe(true)
|
|
|
|
rerender(<MMapArea planner={planner} shell={shell} />)
|
|
|
|
for (const key of DRAWN) expect(mocks.props[key]).toBe(first[key])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-018: what is kept still follows the stage, the day colours and the places', () => {
|
|
const planner = stagePlanner(3)
|
|
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip' })
|
|
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
|
|
expect(mocks.props.routeColors).toBeUndefined()
|
|
|
|
// Kept on its inputs, not frozen: each one below has to reach the map, or a memo
|
|
// missing it would leave the traveller looking at a stage they already left.
|
|
mocks.prefs = { roadtrip_day_colors: true }
|
|
rerender(<MMapArea planner={planner} shell={shell} />)
|
|
expect(mocks.props.routeColors).toHaveLength(1)
|
|
|
|
// The store is where the stage reads its places from, and a store write re-renders the area.
|
|
const renamed = buildPlace({ id: 12, name: 'Travemünde' })
|
|
const stored = useTripStore.getState().places
|
|
act(() => { useTripStore.setState({ places: [stored[0], renamed, stored[2]] }) })
|
|
expect(mocks.props.places).toContain(renamed)
|
|
|
|
const allDays = { ...planner, selectedDayId: null }
|
|
rerender(<MMapArea planner={allDays} shell={shell} />)
|
|
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 12])
|
|
expect(mocks.props.places).toContain(renamed)
|
|
expect(mocks.props.focusPoints).toEqual([])
|
|
|
|
// Off the tab the stage hands nothing through, so the plan tab frames itself again.
|
|
rerender(<MMapArea planner={allDays} shell={{ ...shell, trTab: 'plan' }} />)
|
|
expect(mocks.props.accessLines).toBeUndefined()
|
|
expect(mocks.props.focusPoints).toBeUndefined()
|
|
expect(mocks.props.places).toBe(allDays.mapPlaces)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-019: a pin on the stage opens its stop on this card, never the place inspector', () => {
|
|
const planner = drivePlanner(3)
|
|
const shell = stageShell()
|
|
render(<MMapArea planner={planner} shell={shell} />)
|
|
|
|
// Lübeck is also a stop the day before; the stage on screen is the one it opens on.
|
|
tapPin(12)
|
|
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 32 })
|
|
// Hamburg is left in the morning and come back to at night: the pin opens the first visit.
|
|
tapPin(11)
|
|
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 })
|
|
|
|
// The planner's selection is what the place inspector opens off, so it is never moved.
|
|
expect(planner.handleMarkerClick).not.toHaveBeenCalled()
|
|
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled()
|
|
expect(planner.selectAssignment).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-020: over the whole drive a pin opens its first routed visit, and a place no day stops at gets the inspector', () => {
|
|
const planner = drivePlanner(null)
|
|
const shell = stageShell()
|
|
render(<MMapArea planner={planner} shell={shell} />)
|
|
|
|
tapPin(12)
|
|
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 2, assignmentId: 21 })
|
|
tapPin(11)
|
|
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 })
|
|
|
|
tapPin(99)
|
|
expect(planner.handleMarkerClick).toHaveBeenCalledWith(99)
|
|
expect(shell.openSheet).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-021: a picked day with no stage to show searches the whole drive, and falls back the same way', () => {
|
|
// A quiet day the routing round has no card for.
|
|
const quiet = drivePlanner(7)
|
|
const shell = stageShell()
|
|
const { unmount } = render(<MMapArea planner={quiet} shell={shell} />)
|
|
tapPin(12)
|
|
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 2, assignmentId: 21 })
|
|
tapPin(99)
|
|
expect(quiet.handleMarkerClick).toHaveBeenCalledWith(99)
|
|
unmount()
|
|
|
|
// Still routing: a day is picked, nothing has come back yet, and the inspector is all there is.
|
|
const base = drivePlanner(3)
|
|
const routing = { ...base, roadtripRoutes: { ...base.roadtripRoutes, days: [], loading: true } } as TripPlanner
|
|
const waiting = stageShell()
|
|
render(<MMapArea planner={routing} shell={waiting} />)
|
|
tapPin(11)
|
|
expect(routing.handleMarkerClick).toHaveBeenCalledWith(11)
|
|
expect(waiting.openSheet).not.toHaveBeenCalled()
|
|
// A handler called without a place clears the way the planner's own does.
|
|
tapPin(undefined)
|
|
expect(routing.handleMarkerClick).toHaveBeenLastCalledWith(undefined)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-022: the plan tab keeps the planner\'s own marker door, map tap and selection', () => {
|
|
const planner = drivePlanner(3, { selectedPlaceId: 12 })
|
|
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'plan' })
|
|
render(<MMapArea planner={planner} shell={shell} />)
|
|
|
|
expect(mocks.props.onMarkerClick).toBe(planner.handleMarkerClick)
|
|
expect(mocks.props.onMapClick).toBe(planner.handleMapClick)
|
|
expect(mocks.props.selectedPlaceId).toBe(12)
|
|
|
|
tapPin(12)
|
|
expect(planner.handleMarkerClick).toHaveBeenCalledWith(12)
|
|
expect(shell.openSheet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-023: the stage\'s pin handler keeps its identity across renders and reads the latest stage', () => {
|
|
const planner = drivePlanner(3)
|
|
const shell = stageShell()
|
|
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
|
|
const first = mocks.props.onMarkerClick
|
|
|
|
// New planner and shell objects, the way the shell hands them over on every store write.
|
|
// Leaflet rebuilds every marker when this identity moves.
|
|
rerender(<MMapArea planner={{ ...planner }} shell={stageShell()} />)
|
|
expect(mocks.props.onMarkerClick).toBe(first)
|
|
|
|
// Swiped to the day before: the same handler now answers for that stage and that shell.
|
|
const next = stageShell()
|
|
rerender(<MMapArea planner={{ ...planner, selectedDayId: 2 }} shell={next} />)
|
|
expect(mocks.props.onMarkerClick).toBe(first)
|
|
tapPin(12)
|
|
expect(next.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 21 })
|
|
expect(shell.openSheet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-024: stage pins are the stops of its chain, whatever the plan tab\'s declutter and filters hide', () => {
|
|
// A pump hidden from the day lists, and a category filter set in the places browser.
|
|
const pump = buildPlace({ id: 12, stop_type: 'fuel', category_id: 3 } as Partial<Place>)
|
|
seedStore(useTripStore, { placesFilter: 'unplanned', placesCategoryFilter: new Set(['9']) })
|
|
const planner = stagePlanner(3)
|
|
seedStore(useTripStore, { places: [buildPlace({ id: 11, category_id: 4 }), pump, buildPlace({ id: 99 })] })
|
|
// The plan tab's list after "all days" twice and a chip tap: its declutter still names
|
|
// the day before, so it carries none of this stage's places.
|
|
const decluttered = { ...planner, mapPlaces: [buildPlace({ id: 99 })] } as TripPlanner
|
|
render(<MMapArea planner={decluttered} shell={stageShell()} />)
|
|
|
|
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 12])
|
|
|
|
// A place without a position has nowhere to stand, on the stage as anywhere else.
|
|
act(() => {
|
|
useTripStore.setState({ places: [buildPlace({ id: 11 }), { ...pump, lat: null, lng: null } as unknown as Place] })
|
|
})
|
|
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-025: a shown point holds the camera on its own day only, and a day change hands the frame back', () => {
|
|
const planner = drivePlanner(3)
|
|
const shell = stageShell()
|
|
const point: [number, number][] = [[50, 8]]
|
|
const { rerender } = render(<MMapArea planner={{ ...planner, mapFocusPoints: point }} shell={shell} />)
|
|
expect(mocks.props.focusPoints).toBe(point)
|
|
|
|
// The planner keeps the point until its next routing round. The swipe frames its stage anyway.
|
|
rerender(<MMapArea planner={{ ...planner, selectedDayId: 2, mapFocusPoints: point }} shell={shell} />)
|
|
expect(mocks.props.focusPoints).toEqual([[53.87, 10.69]])
|
|
|
|
// Coming back does not bring the old point back: the camera belongs to the stage now.
|
|
rerender(<MMapArea planner={{ ...planner, selectedDayId: 3, mapFocusPoints: point }} shell={shell} />)
|
|
expect(mocks.props.focusPoints).not.toBe(point)
|
|
expect(mocks.props.focusPoints).toEqual(expect.arrayContaining([[53.55, 9.99], [53.87, 10.69]]))
|
|
|
|
// A new point arriving together with its day, as Show on map from another card does, is held.
|
|
const shown: [number, number][] = [[53.87, 10.69]]
|
|
rerender(<MMapArea planner={{ ...planner, selectedDayId: 2, mapFocusPoints: shown }} shell={shell} />)
|
|
expect(mocks.props.focusPoints).toBe(shown)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-036: a stage keeps its own night pause and drops the other days', () => {
|
|
// A night pause is the moon pill where a travel day ends. The markers are built from
|
|
// the whole drive, so day 1 used to carry day 2's pill as well, in the middle of a map
|
|
// that draws day 1 only. `stagePlanner(3)` is day 1 of the drive (stageDay).
|
|
const own = { lat: 53.87, lng: 10.69, tone: 'default' as const, nightPause: { day: 1, atPlace: false } }
|
|
const other = { lat: 53.55, lng: 9.99, tone: 'default' as const, nightPause: { day: 2, atPlace: false } }
|
|
const plain = { lat: 53.6, lng: 10.2, tone: 'default' as const, label: 'Rest stop' }
|
|
const vias = [own, other, plain]
|
|
|
|
const stage = renderArea(
|
|
{ trTab: 'roadtrip', mapFront: true },
|
|
{ ...stagePlanner(3), roadtripMapVias: vias } as unknown as Partial<TripPlanner>,
|
|
)
|
|
// Its own night and everything that is not one; day 2's pill stays off this map.
|
|
expect(mocks.props.routeVias).toEqual([own, plain])
|
|
stage.unmount()
|
|
|
|
// Off a stage the list is left alone: in the all-days view every night belongs to a
|
|
// line that is actually drawn.
|
|
renderArea({ trTab: 'plan' }, { routeVias: vias })
|
|
expect(mocks.props.routeVias).toBe(vias)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-026: the map layer carries no credit corner on either tab', () => {
|
|
const plan = renderArea()
|
|
const planLayer = plan.container.firstElementChild as HTMLElement
|
|
// The phone map shows no credit at all now (mobile.css), so the class that used to
|
|
// place one is gone rather than left behind pointing at nothing.
|
|
expect(planLayer.classList.contains('m-credit-corner')).toBe(false)
|
|
plan.unmount()
|
|
|
|
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
|
|
const stageLayer = container.firstElementChild as HTMLElement
|
|
expect(stageLayer.classList.contains('m-credit-corner')).toBe(false)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-028: on the stage the map draws the offered roads, and a tap on one only lights it', () => {
|
|
const { planner } = renderArea(
|
|
{ trTab: 'roadtrip', rtView: 'map' },
|
|
withPicker(OFFERS, { highlightedAlternative: 1 }),
|
|
)
|
|
|
|
expect(mocks.props.alternativeRoutes).toBe(planner.alternativeOverlays)
|
|
expect(mocks.props.activeAlternative).toBe(1)
|
|
|
|
;(mocks.props.onChooseAlternative as (index: number) => void)(0)
|
|
// Glass has no hover to preview with, so the desk's click that takes a road would save a
|
|
// via on the first touch. The bar's confirm is what takes it here.
|
|
expect(planner.setHighlightedAlternative).toHaveBeenCalledWith(0)
|
|
expect(planner.chooseRouteAlternative).not.toHaveBeenCalled()
|
|
// A touch fires emulated enter and leave events, and the leave would put the road out.
|
|
expect(mocks.props.onHighlightAlternative).toBeUndefined()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-029: the plan tab draws no offered roads and frames nothing for them', () => {
|
|
renderArea({ trTab: 'plan' }, withPicker(OFFERS, { highlightedAlternative: 1 }))
|
|
|
|
expect(mocks.props.alternativeRoutes).toBeUndefined()
|
|
expect(mocks.props.activeAlternative).toBeUndefined()
|
|
expect(mocks.props.onChooseAlternative).toBeUndefined()
|
|
expect(mocks.props.fitPadding).toBeUndefined()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-030: with the picker open the floor clears its taller bar, and the compass is still the one inline --bottom-nav-h', () => {
|
|
const { container, unmount } = renderArea({ trTab: 'roadtrip', mapFront: true }, withPicker([]))
|
|
const layer = container.firstElementChild as HTMLElement
|
|
|
|
// 174px of bar and the stage bar's 15px gap, as soon as the question is asked.
|
|
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe(`${RT_ALT_BAR_LIFT}px`)
|
|
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('189px')
|
|
expect(layer.getAttribute('style')).not.toContain('--bottom-nav-h')
|
|
expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1)
|
|
expect(compassBand(container)).not.toBeNull()
|
|
unmount()
|
|
|
|
// Behind the chain the bar is not on screen, so nothing lifts.
|
|
const behind = renderArea({ trTab: 'roadtrip', mapFront: false }, withPicker([]))
|
|
expect((behind.container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-031: the offered roads are framed between the day chips and the bar, once they are there', () => {
|
|
const shell = stageShell()
|
|
const asking = buildPlanner(withPicker([]))
|
|
const { rerender } = render(<MMapArea planner={asking} shell={shell} />)
|
|
// Nothing to frame while the router is asked: a padding handed over now would refit
|
|
// the stage into a smaller frame just before the answer moves the camera again.
|
|
expect(mocks.props.fitPadding).toBeUndefined()
|
|
|
|
const answered = { ...asking, ...withPicker(OFFERS) } as TripPlanner
|
|
rerender(<MMapArea planner={answered} shell={shell} />)
|
|
// Top: 12 of --m-safe-top, the rail's 50 offset and 42 height, a 12 gap. Bottom: the
|
|
// dock's 74 and the bar's lift. The sides keep the phone's 20.
|
|
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 74 + RT_ALT_BAR_LIFT, left: 20 })
|
|
const first = mocks.props.fitPadding
|
|
|
|
// Kept between renders, for the reason the stage data is.
|
|
rerender(<MMapArea planner={{ ...answered }} shell={shell} />)
|
|
expect(mocks.props.fitPadding).toBe(first)
|
|
|
|
// Closed, the stage frames itself with the engines' own margins again.
|
|
rerender(<MMapArea planner={{ ...answered, ...withPicker([]), routeAlternatives: buildPlanner().routeAlternatives }} shell={shell} />)
|
|
expect(mocks.props.fitPadding).toBeUndefined()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-032: on a phone with a notch the frame moves in by the safe area, and follows a turn of the phone', () => {
|
|
const style = notchedPhone('59px', '34px')
|
|
renderArea({ trTab: 'roadtrip', rtView: 'map' }, withPicker(OFFERS))
|
|
|
|
expect(mocks.props.fitPadding).toEqual({ top: 59 + 116, right: 20, bottom: 34 + 74 + RT_ALT_BAR_LIFT, left: 20 })
|
|
|
|
// Turned sideways the insets go to the edges, and the frame lets go of them.
|
|
style.mockRestore()
|
|
notchedPhone('0px', '21px')
|
|
act(() => { window.dispatchEvent(new Event('resize')) })
|
|
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 21 + 74 + RT_ALT_BAR_LIFT, left: 20 })
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-027: the layer sets only the lift inline, so the compass stays the one inline --bottom-nav-h', () => {
|
|
for (const shellOver of [{ trTab: 'plan' }, { trTab: 'roadtrip', mapFront: true }] as Partial<MTripShellApi>[]) {
|
|
const { container, unmount } = renderArea(shellOver)
|
|
const inline = (container.firstElementChild as HTMLElement).getAttribute('style') ?? ''
|
|
|
|
// The floor and the band are classes: a second element naming --bottom-nav-h inline
|
|
// would leave nothing to tell the compass band apart by.
|
|
expect(inline).toContain('--m-stage-lift')
|
|
expect(inline).not.toContain('--bottom-nav-h')
|
|
expect(inline).not.toContain('--m-map-floor')
|
|
expect(inline).not.toContain('--m-credit')
|
|
expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1)
|
|
expect(compassBand(container)).not.toBeNull()
|
|
unmount()
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-033: a point the planner hands back once offers or roads close does not take the camera again', () => {
|
|
const planner = drivePlanner(2)
|
|
const shell = stageShell()
|
|
const view = (dayId: number, mapFocusPoints: [number, number][], alternativeFocusPoints: [number, number][] = []) =>
|
|
<MMapArea planner={{ ...planner, selectedDayId: dayId, mapFocusPoints, alternativeFocusPoints }} shell={shell} />
|
|
const point: [number, number][] = [[50, 8]]
|
|
const { rerender } = render(view(2, point))
|
|
expect(mocks.props.focusPoints).toBe(point)
|
|
|
|
// Swiped on: the planner still keeps the point from day 2, and this stage frames itself.
|
|
rerender(view(3, point))
|
|
const stageFrame = mocks.props.focusPoints
|
|
expect(stageFrame).not.toBe(point)
|
|
|
|
// The fuel offers take the camera, and closing them hands the same point back. The chips
|
|
// still say day 3, so flying to day 2's stop would leave the map on a stage nobody picked.
|
|
const offers: [number, number][] = [[53.4, 9.7]]
|
|
rerender(view(3, offers))
|
|
expect(mocks.props.focusPoints).toBe(offers)
|
|
rerender(view(3, point))
|
|
expect(mocks.props.focusPoints).toBe(stageFrame)
|
|
|
|
// On its own day as well: the roads for a leg, cancelled, give the frame back to the stage.
|
|
const shown: [number, number][] = [[53.87, 10.69]]
|
|
rerender(view(3, shown))
|
|
expect(mocks.props.focusPoints).toBe(shown)
|
|
const roads: [number, number][] = [[53.55, 9.99], [53.7, 10.2], [53.87, 10.69]]
|
|
rerender(view(3, roads, roads))
|
|
expect(mocks.props.focusPoints).toBe(roads)
|
|
rerender(view(3, shown))
|
|
expect(mocks.props.focusPoints).toBe(stageFrame)
|
|
|
|
// And every time after: a second round of offers is new, the point behind it is not.
|
|
const again: [number, number][] = [[53.3, 9.6]]
|
|
rerender(view(3, again))
|
|
expect(mocks.props.focusPoints).toBe(again)
|
|
rerender(view(3, shown))
|
|
expect(mocks.props.focusPoints).toBe(stageFrame)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-034: fuel offers asked over an open picker give the camera back to its roads when they close', () => {
|
|
const planner = drivePlanner(3)
|
|
const shell = stageShell()
|
|
const roads: [number, number][] = [[53.55, 9.99], [53.7, 10.2], [53.87, 10.69]]
|
|
const view = (mapFocusPoints: [number, number][], alternativeFocusPoints: [number, number][]) =>
|
|
<MMapArea planner={{ ...planner, mapFocusPoints, alternativeFocusPoints }} shell={shell} />
|
|
const { rerender } = render(view(roads, roads))
|
|
expect(mocks.props.focusPoints).toBe(roads)
|
|
|
|
const offers: [number, number][] = [[53.4, 9.7]]
|
|
rerender(view(offers, roads))
|
|
expect(mocks.props.focusPoints).toBe(offers)
|
|
|
|
// The picker is still open and still asking about its leg, so its roads are framed again.
|
|
rerender(view(roads, roads))
|
|
expect(mocks.props.focusPoints).toBe(roads)
|
|
|
|
// Closed, nothing is pending and the stage frames itself.
|
|
const closed: [number, number][] = []
|
|
rerender(view(closed, closed))
|
|
expect(mocks.props.focusPoints).toEqual([[53.55, 9.99], [53.87, 10.69], [53.55, 9.99]])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-035: the all days view pins every stored visit, a booked night and a hidden service stop included', () => {
|
|
const hotel = buildPlace({ id: 20 })
|
|
const pump = buildPlace({ id: 21, stop_type: 'fuel' } as Partial<Place>)
|
|
const base = drivePlanner(null)
|
|
// The places browser on 'unplanned' with a category picked, which empties the plan tab's map.
|
|
seedStore(useTripStore, {
|
|
places: [buildPlace({ id: 11 }), hotel, pump, buildPlace({ id: 99 })],
|
|
placesFilter: 'unplanned',
|
|
placesCategoryFilter: new Set(['9']),
|
|
})
|
|
const planner = {
|
|
...base,
|
|
mapPlaces: [],
|
|
// The planner's planned list on a phone: its day lists leave the booked night and the
|
|
// hidden pump out, so it is not what the drive the routing round draws stops at.
|
|
roadtripMapPlaces: [buildPlace({ id: 11 })],
|
|
storedAssignments: {
|
|
'3': [
|
|
buildAssignment({ day_id: 3, place: buildPlace({ id: 11 }) }),
|
|
buildAssignment({ day_id: 3, place: pump }),
|
|
],
|
|
'4': [buildAssignment({ day_id: 4, place: hotel, accommodation_id: 5 })],
|
|
},
|
|
} as TripPlanner
|
|
render(<MMapArea planner={planner} shell={stageShell()} />)
|
|
|
|
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 20, 21])
|
|
expect(mocks.props.places).not.toBe(planner.roadtripMapPlaces)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-037: on the stage a seamed ride draws as its booking, whether or not it was toggled on', () => {
|
|
// The ride's own leg carries no geometry (carrierLeg), so the booking's arc is the
|
|
// only thing that joins the two terminals on the map. The planner folds the seamed
|
|
// bookings into `roadtripConnections`; the stage has to read that list, not the
|
|
// plan tab's toggles, or the road ends at the departure gate and picks up again at
|
|
// the arrival with nothing between.
|
|
const base = stagePlanner(3)
|
|
const [hamburg] = stageDay().stops
|
|
const terminal = (role: 'departure' | 'arrival') => ({
|
|
...hamburg,
|
|
assignmentId: role === 'departure' ? -3000000140 : -3000000141,
|
|
placeId: -70,
|
|
name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
|
|
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null },
|
|
})
|
|
const seamed = { ...stageDay(), stops: [hamburg, terminal('departure'), terminal('arrival')] } as unknown as RoadtripDay
|
|
const planner = {
|
|
...base,
|
|
roadtripRoutes: { ...base.roadtripRoutes, days: [seamed] },
|
|
visibleConnections: [],
|
|
roadtripConnections: [70],
|
|
} as TripPlanner
|
|
const shell = stageShell()
|
|
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
|
|
|
|
expect(mocks.props.visibleConnectionIds).toEqual([70])
|
|
// A seamed ride joins the road whatever day it was booked on, so the stage does not
|
|
// hold bookings to the selected day the way the plan map does.
|
|
expect(mocks.props.scopeConnectionsToDay).toBe(false)
|
|
|
|
// The plan tab keeps the traveller's own toggles, as the desktop does off road trip mode.
|
|
rerender(<MMapArea planner={planner} shell={{ ...shell, trTab: 'plan' }} />)
|
|
expect(mocks.props.visibleConnectionIds).toBe(planner.visibleConnections)
|
|
expect(mocks.props.visibleConnectionIds).toEqual([])
|
|
expect(mocks.props.scopeConnectionsToDay).toBe(true)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* #2456: the phone map is one day's picture. A day chip frames the day, draws its route
|
|
* and drops the other days' pins (MTripShell.selectDayOnMap), and the automated
|
|
* transports ride the day since #2019. A booking switched on through its own toggle
|
|
* ("On map") stayed on every day's map, though, because the per-booking branch of
|
|
* visibleRouteReservations is trip-wide.
|
|
*
|
|
* The renderer is mocked here, so what it WOULD draw is worked out the way both engines
|
|
* work it out: MapView and MapViewGL hand their own props to visibleRouteReservations.
|
|
* The whole prop bag goes in, so a day-scoping option added to the renderers under the
|
|
* util's own name is picked up as well as a filtered id list is.
|
|
*/
|
|
describe('MMapArea booking routes on the day-scoped plan map (#2456)', () => {
|
|
const DAYS = [
|
|
{ id: 10, trip_id: 1, day_number: 1, date: '2026-05-01' },
|
|
{ id: 11, trip_id: 1, day_number: 2, date: '2026-05-02' },
|
|
{ id: 12, trip_id: 1, day_number: 3, date: '2026-05-03' },
|
|
] as Day[]
|
|
|
|
function flight(over: Partial<Reservation> = {}): Reservation {
|
|
return {
|
|
id: 7, trip_id: 1, type: 'flight', title: 'FRA → LIS', status: 'confirmed',
|
|
day_id: 10, end_day_id: 10,
|
|
reservation_time: '2026-05-01T09:00', reservation_end_time: '2026-05-01T11:00',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57, timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS', lat: 38.77, lng: -9.13, timezone: null, local_time: null, local_date: null },
|
|
],
|
|
...over,
|
|
} as unknown as Reservation
|
|
}
|
|
|
|
/** The plan tab's map in front, a booking switched on, and the given day on the chips. */
|
|
function renderPlanMap(selectedDayId: number | null, booking: Reservation = flight()) {
|
|
const planner = buildPlanner({
|
|
days: DAYS,
|
|
selectedDayId,
|
|
reservations: [booking],
|
|
visibleConnections: [booking.id],
|
|
transitRoutesShown: false,
|
|
} as unknown as Partial<TripPlanner>)
|
|
render(<MMapArea planner={planner} shell={buildShell({ view: 'map', mapFront: true, trTab: 'plan' })} />)
|
|
}
|
|
|
|
/** The booking ids either renderer would draw a route for, from the props it was handed. */
|
|
const drawnBookingIds = () => visibleRouteReservations(
|
|
(mocks.props.reservations as Reservation[]) ?? [],
|
|
mocks.props as unknown as RouteVisibilityOptions,
|
|
).map(r => r.id)
|
|
|
|
it('FE-MOB-MAPAREA-038: a booking switched on for day 1 is not drawn on the map of day 3', () => {
|
|
renderPlanMap(12)
|
|
|
|
expect(drawnBookingIds()).toEqual([])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-039: the booking stays drawn on the day it runs on', () => {
|
|
renderPlanMap(10)
|
|
|
|
expect(drawnBookingIds()).toEqual([7])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-040: an overnight booking is drawn on both of its days and on no other', () => {
|
|
const overnight = flight({ day_id: 10, end_day_id: 11 })
|
|
|
|
renderPlanMap(11, overnight)
|
|
expect(drawnBookingIds()).toEqual([7])
|
|
|
|
renderPlanMap(12, overnight)
|
|
expect(drawnBookingIds()).toEqual([])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-041: the all days view keeps every switched-on booking', () => {
|
|
renderPlanMap(null)
|
|
|
|
expect(drawnBookingIds()).toEqual([7])
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-042: a booking bound to no day keeps drawing on every day', () => {
|
|
renderPlanMap(12, flight({ day_id: null, end_day_id: null }))
|
|
|
|
expect(drawnBookingIds()).toEqual([7])
|
|
})
|
|
})
|
|
|
|
describe('MMapArea and a booked night at the edge of the stage', () => {
|
|
it('FE-MOB-MAPAREA-043: a pin at the hotel opens its stored stop, never the bookend in front of it, else the inspector', () => {
|
|
const base = drivePlanner(3)
|
|
const [before, loop] = base.roadtripRoutes.days
|
|
const hotel = (placeId: number) => ({
|
|
...loop.stops[0],
|
|
assignmentId: -6_000_000_006,
|
|
placeId,
|
|
ownerIndex: 0,
|
|
stopType: 'hotel',
|
|
bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
|
})
|
|
const withBookend = (placeId: number) => ({
|
|
...base,
|
|
roadtripRoutes: { ...base.roadtripRoutes, days: [before, { ...loop, stops: [hotel(placeId), ...loop.stops] }] },
|
|
}) as TripPlanner
|
|
const shell = stageShell()
|
|
const { unmount } = render(<MMapArea planner={withBookend(11)} shell={shell} />)
|
|
tapPin(11)
|
|
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 })
|
|
unmount()
|
|
|
|
// A hotel the drive has as its bookend only: the road trip has no stop to open.
|
|
const only = withBookend(99)
|
|
const other = stageShell()
|
|
render(<MMapArea planner={only} shell={other} />)
|
|
tapPin(99)
|
|
expect(other.openSheet).not.toHaveBeenCalled()
|
|
expect(only.handleMarkerClick).toHaveBeenCalledWith(99)
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-044: the POI bar offers the plugin categories after its divider, without shrinking a segment', () => {
|
|
const plugin = pluginPoiCategories([{
|
|
id: 'trail-finder', name: 'Trail finder', type: 'integration', icon: null,
|
|
poiCategories: [{ id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' }],
|
|
}], 'en')
|
|
mocks.poi = { ...mocks.poi, categories: { core: corePoiCategories(key => key), plugin } }
|
|
renderArea()
|
|
|
|
const divider = screen.getByRole('separator')
|
|
const trailheads = screen.getByRole('button', { name: 'Trailheads' })
|
|
expect(divider.nextElementSibling).toBe(trailheads)
|
|
expect(trailheads.style.minWidth).toBe('34px')
|
|
expect((divider.parentElement as HTMLElement).style.overflowX).toBe('auto')
|
|
trailheads.click()
|
|
expect(mocks.poi.toggle).toHaveBeenCalledWith('plugin:trail-finder/trailheads')
|
|
})
|
|
})
|
|
|
|
describe('MMapArea — places filter', () => {
|
|
it('FE-MOB-MAPAREA-046: the plan map offers the places filter, and a tap opens its sheet', () => {
|
|
const { shell } = renderArea({ trTab: 'plan' })
|
|
const pill = screen.getByTestId('places-filter-pill')
|
|
expect(pill).toHaveAccessibleName('Filters')
|
|
// It opens a sheet: a dialog to announce, not an on/off state.
|
|
expect(pill).toHaveAttribute('aria-haspopup', 'dialog')
|
|
expect(pill).not.toHaveAttribute('aria-pressed')
|
|
act(() => { pill.click() })
|
|
expect(shell.openSheet).toHaveBeenCalledWith('placesFilter')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-047: the pill lights up and counts the filters narrowing the pins', () => {
|
|
seedStore(useTripStore, { placesFilter: 'unplanned', placesRatingFilter: 4 })
|
|
renderArea({ trTab: 'plan' })
|
|
const pill = screen.getByTestId('places-filter-pill')
|
|
expect(pill).toHaveClass('text-accent')
|
|
expect(pill).not.toHaveAttribute('aria-pressed')
|
|
expect(pill).toHaveAttribute('aria-haspopup', 'dialog')
|
|
expect(pill).toHaveAccessibleName('Filters (2)')
|
|
expect(screen.getByText('2')).toHaveAttribute('aria-hidden', 'true')
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-049: a road trip keeps the places filter on its plan map, Dawarich or not', () => {
|
|
// The stack used to stand down in road-trip mode unless Dawarich had a pill in
|
|
// it; the plan map still shows the trip's places there, so its filter stays.
|
|
renderArea({ trTab: 'plan' }, { roadtripActive: true, dawarichEnabled: false })
|
|
expect(screen.getByTestId('places-filter-pill')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-MAPAREA-048: the stage draws its own pins, so it has no places filter, and neither has a covered map', () => {
|
|
renderArea({ trTab: 'roadtrip', mapFront: true })
|
|
expect(screen.queryByTestId('places-filter-pill')).not.toBeInTheDocument()
|
|
renderArea({ view: 'plan', mapFront: false })
|
|
expect(screen.queryByTestId('places-filter-pill')).not.toBeInTheDocument()
|
|
})
|
|
})
|