diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 76cc0ed0e..58f2d8588 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -463,6 +463,23 @@ describe('MapView', () => { ) 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() + 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( , ) diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index a9fc1d87c..d05411e1c 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -31,6 +31,7 @@ const glMap = vi.hoisted(() => ({ flyTo: vi.fn(), jumpTo: vi.fn(), getZoom: vi.fn().mockReturnValue(10), + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), addControl: vi.fn(), removeControl: vi.fn(), remove: vi.fn(), @@ -938,19 +939,6 @@ describe('MapViewGL', () => { route={null} followSelection={false} focusKey={1} - focusPoints={[]} - glProvider="maplibre-gl" - />, - ) - await act(async () => {}) - expect(glMap.fitBounds).not.toHaveBeenCalled() - - rerender( - , @@ -958,26 +946,35 @@ describe('MapViewGL', () => { await act(async () => {}) expect(glMap.fitBounds).toHaveBeenCalledOnce() glMap.fitBounds.mockClear() - - rerender( - , - ) - await act(async () => {}) - expect(glMap.fitBounds).not.toHaveBeenCalled() + const mapConstructions = (maplibregl.Map as any).mock.calls.length + const retainedCenter = { lng: 135.8, lat: 35.2 } + glMap.getCenter.mockReturnValue(retainedCenter) + glMap.getZoom.mockReturnValue(12) rerender( , + ) + await act(async () => {}) + expect(glMap.fitBounds).not.toHaveBeenCalled() + expect(glMap.flyTo).not.toHaveBeenCalled() + expect(glMap.jumpTo).not.toHaveBeenCalled() + expect(glMap.easeTo).not.toHaveBeenCalled() + expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions) + expect(glMap.getCenter()).toEqual(retainedCenter) + expect(glMap.getZoom()).toBe(12) + + rerender( + , ) diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index b34f76272..7410f93af 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -26,7 +26,11 @@ const plannerActions = vi.hoisted(() => ({ viewGpxTour: vi.fn(), addWaypoint: vi.fn(), openTour: vi.fn(), + startNewTour: vi.fn(), forgetDeletedTour: vi.fn((_placeId: number) => undefined), + saveOutcome: null as unknown, + mapFocusKey: 0, + newDraftGeneration: 0, mode: { type: 'neutral' } as unknown, isSaving: false, waypoints: [] as unknown, @@ -106,17 +110,18 @@ vi.mock('../components/Tours/planner/useTourPlanner', () => ({ waypoints: plannerActions.waypoints, routeProfileFocus: plannerActions.routeProfileFocus, name: '', - startNewTour: vi.fn(), + startNewTour: plannerActions.startNewTour, newTourConfirmationOpen: false, cancelNewTour: vi.fn(), elevationProfileExpanded: true, toggleElevationProfile: vi.fn(), - newDraftGeneration: 0, + newDraftGeneration: plannerActions.newDraftGeneration, + mapFocusKey: plannerActions.mapFocusKey, mode: plannerActions.mode, isSaving: plannerActions.isSaving, selectedWaypointId: null, editingPlaceId: null, - saveOutcome: null, + saveOutcome: plannerActions.saveOutcome, hasUnsavedChanges: false, setSelectedWaypointId: vi.fn(), mapBaseLayer: 'map', @@ -430,6 +435,16 @@ function renderPage(overrides: HookState = {}) { return render() } +function mockStartNewTourTransition() { + plannerActions.startNewTour.mockImplementation(() => { + plannerActions.mode = { type: 'new-draft' } + plannerActions.waypoints = [] + plannerActions.saveOutcome = null + plannerActions.mapFocusKey += 1 + plannerActions.newDraftGeneration += 1 + }) +} + beforeEach(() => { vi.clearAllMocks() resetAllStores() @@ -438,7 +453,11 @@ beforeEach(() => { plannerActions.viewGpxTour.mockReset() plannerActions.addWaypoint.mockReset() plannerActions.openTour.mockReset() + plannerActions.startNewTour.mockReset() plannerActions.forgetDeletedTour.mockReset() + plannerActions.saveOutcome = null + plannerActions.mapFocusKey = 0 + plannerActions.newDraftGeneration = 0 plannerActions.mode = { type: 'neutral' } plannerActions.isSaving = false plannerActions.waypoints = [] @@ -542,7 +561,83 @@ describe('TripPlannerPage — shell', () => { expect(plannerActions.openTour).not.toHaveBeenCalled() }) - it('FE-PAGE-TPW-063: uses the last normal Plan viewport before falling back to trip Places', () => { + it('preserves the visible TOUR-PLANNER camera when the Plan a Tour button starts an empty draft', async () => { + const place = buildPlace({ id: 81, lat: 35.01, lng: 135.76 }) + mockStartNewTourTransition() + const view = renderPage({ + activeTab: 'tour-planner', + enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, + toursEnabled: true, + places: [place], + }) + const mapElement = screen.getByTestId('map-view') + + expect(props('map').focusPoints).toEqual([[place.lat, place.lng]]) + fireEvent.click(await screen.findByRole('button', { name: 'Plan new tour' })) + expect(plannerActions.startNewTour).toHaveBeenCalledOnce() + view.rerender() + + expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument() + expect(props('map').focusPoints).toEqual([]) + expect(props('map').route).toBeNull() + expect(props('map').plannerWaypoints).toEqual([]) + expect(props('map').focusKey).toBe(1) + expect(screen.getByTestId('map-view')).toBe(mapElement) + expect(props('map').onMapClick).toBeTypeOf('function') + act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } })) + expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77) + }) + + it('preserves the visible TOUR-PLANNER camera when Plan another starts a new Tour', async () => { + const savedTour = { + place_id: 82, + name: 'Saved Kyoto tour', + tour_type: 'hike', + distance: 6, + elevation_gain: 300, + elevation_loss: 290, + duration: 120, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + tour_group_id: null, + max_hiking_difficulty: 2, + planned: false, + caution: false, + has_waypoints: true, + } + mockStartNewTourTransition() + plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id } + plannerActions.waypoints = [ + { id: 'start', lat: 35.01, lng: 135.76, role: 'start' }, + { id: 'end', lat: 35.02, lng: 135.77, role: 'end' }, + ] + plannerActions.mapFocusKey = 8 + plannerActions.saveOutcome = savedTour + const view = renderPage({ + activeTab: 'tour-planner', + enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, + toursEnabled: true, + tours: [savedTour], + places: [buildPlace({ id: savedTour.place_id, lat: 35.01, lng: 135.76 })], + }) + const mapElement = screen.getByTestId('map-view') + + expect(props('map').focusPoints).toEqual([[35.01, 135.76], [35.02, 135.77]]) + fireEvent.click(await screen.findByRole('button', { name: 'Plan another' })) + expect(plannerActions.startNewTour).toHaveBeenCalledOnce() + view.rerender() + + expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument() + expect(props('map').focusPoints).toEqual([]) + expect(props('map').route).toBeNull() + expect(props('map').plannerWaypoints).toEqual([]) + expect(props('map').focusKey).toBe(9) + expect(screen.getByTestId('map-view')).toBe(mapElement) + expect(props('map').onMapClick).toBeTypeOf('function') + }) + + it('FE-PAGE-TPW-063: uses the last TRIP-PLAN viewport for neutral TOUR-PLANNER fallback', () => { const first = buildPlace({ id: 81, lat: 47.1, lng: 11.2 }) const second = buildPlace({ id: 82, lat: 47.3, lng: 11.6 }) const view = renderPage({ activeTab: 'tour-planner', enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true }, toursEnabled: true, places: [first, second] }) @@ -566,6 +661,8 @@ describe('TripPlannerPage — shell', () => { hookState = { ...hookState, activeTab: 'plan' } view.rerender() act(() => props('map').onViewportChange({ south: 46.9, west: 10.8, north: 47.5, east: 12.1 })) + plannerActions.mode = { type: 'neutral' } + plannerActions.waypoints = [] hookState = { ...hookState, activeTab: 'tour-planner' } view.rerender() diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index d8a0b0568..e309b94be 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -60,7 +60,10 @@ export function useTripPlannerPage() { const focusPoints = useMemo<[number, number][]>(() => { if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? [] - if ((tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') && tourPlanner.waypoints.length) { + if (tourPlanner.mode.type === 'new-draft') { + return tourPlanner.waypoints.map(point => [point.lat, point.lng]) + } + if (tourPlanner.mode.type === 'edit-saved' && tourPlanner.waypoints.length) { return tourPlanner.waypoints.map(point => [point.lat, point.lng]) } return plannerFallbackFocusPoints