From a3b263017ff64813e35f04e653bf68730a56fbe8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 29 Jul 2026 09:44:00 +0200 Subject: [PATCH] test(planner): cover the day plan sidebar and the planner dialogs --- .../components/Planner/AddressInput.test.tsx | 227 +++ .../Planner/AirTrailImportModal.test.tsx | 141 +- .../components/Planner/AirportSelect.test.tsx | 334 ++++ .../Planner/BookingImportModal.test.tsx | 196 ++ .../Planner/DayDetailPanel.test.tsx | 187 +- .../Planner/DayPlanSidebar.test.tsx | 1576 ++++++++++++++++- ...ayPlanSidebarMobileAddPlaceButton.test.tsx | 111 ++ .../Planner/DayPlanSidebarToolbar.test.tsx | 357 ++++ ...ayPlanSidebarTransportDetailModal.test.tsx | 248 +++ .../Planner/DayReorderPopup.test.tsx | 140 ++ .../Planner/FileImportModal.test.tsx | 243 +++ .../Planner/IcsSubscribeModal.test.tsx | 157 ++ .../Planner/LocationSelect.test.tsx | 266 +++ .../Planner/PlaceFormModal.test.tsx | 553 +++++- .../Planner/PlaceInspector.test.tsx | 457 +++++ .../PlacesSidebarSelectionBar.test.tsx | 190 ++ .../Planner/ReservationModal.test.tsx | 573 +++++- .../Planner/ReservationsPanel.test.tsx | 364 +++- .../Planner/SubscribeLinks.test.tsx | 112 ++ .../Planner/TransitJourneyModal.test.tsx | 125 +- .../Planner/TransitSearchPanel.test.tsx | 314 +++- .../Planner/TransportModal.test.tsx | 577 +++++- .../Planner/TravelerPicker.test.tsx | 120 +- .../Planner/TripWarningsBanner.test.tsx | 156 ++ .../Planner/usePlacesSidebar.test.tsx | 664 +++++++ 25 files changed, 8363 insertions(+), 25 deletions(-) create mode 100644 client/src/components/Planner/AddressInput.test.tsx create mode 100644 client/src/components/Planner/AirportSelect.test.tsx create mode 100644 client/src/components/Planner/BookingImportModal.test.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.test.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarToolbar.test.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx create mode 100644 client/src/components/Planner/DayReorderPopup.test.tsx create mode 100644 client/src/components/Planner/FileImportModal.test.tsx create mode 100644 client/src/components/Planner/IcsSubscribeModal.test.tsx create mode 100644 client/src/components/Planner/LocationSelect.test.tsx create mode 100644 client/src/components/Planner/PlacesSidebarSelectionBar.test.tsx create mode 100644 client/src/components/Planner/SubscribeLinks.test.tsx create mode 100644 client/src/components/Planner/TripWarningsBanner.test.tsx create mode 100644 client/src/components/Planner/usePlacesSidebar.test.tsx diff --git a/client/src/components/Planner/AddressInput.test.tsx b/client/src/components/Planner/AddressInput.test.tsx new file mode 100644 index 000000000..e52d93c7c --- /dev/null +++ b/client/src/components/Planner/AddressInput.test.tsx @@ -0,0 +1,227 @@ +// FE-PLANNER-ADDRIN-001 to FE-PLANNER-ADDRIN-014 +import { useState } from 'react'; +import { delay, http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; +import { server } from '../../../tests/helpers/msw/server'; +import AddressInput from './AddressInput'; + +interface SearchHit { + name?: string; + address?: string | null; + osm_id?: string; + google_place_id?: string; +} + +const HOTEL = { name: 'Hotel Sacher', address: 'Philharmoniker Str. 4, Vienna', osm_id: 'n1' }; +const CAFE = { name: 'Cafe Central', address: 'Herrengasse 14, Vienna', osm_id: 'n2' }; + +/** Let the debounce fire and any in-flight request settle. */ +async function settle(ms = 450) { + await act(async () => { await new Promise((r) => setTimeout(r, ms)); }); +} + +function searchRoute(places: SearchHit[] | (() => Response | Promise)) { + return http.post('/api/maps/search', typeof places === 'function' ? places : () => HttpResponse.json({ places })); +} + +// The typed text is authoritative (#1496): the parent owns it and every +// keystroke has to arrive there, so the host mirrors that wiring. +function Host({ initial = '', onText }: { initial?: string; onText?: (v: string) => void }) { + const [value, setValue] = useState(initial); + return ( + { setValue(v); onText?.(v); }} + placeholder="Street and number" + className="addr" + /> + ); +} + +describe('AddressInput', () => { + it('FE-PLANNER-ADDRIN-001: renders the given value, placeholder and class', () => { + render(); + const input = screen.getByPlaceholderText('Address'); + expect(input).toHaveValue('Hauptstrasse 1'); + expect(input).toHaveClass('addr'); + }); + + it('FE-PLANNER-ADDRIN-002: every keystroke reaches the parent', async () => { + const user = userEvent.setup(); + const onText = vi.fn(); + server.use(searchRoute([])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Ab'); + + expect(onText).toHaveBeenNthCalledWith(1, 'A'); + expect(onText).toHaveBeenNthCalledWith(2, 'Ab'); + }); + + it('FE-PLANNER-ADDRIN-003: focusing a prefilled field does not search', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use(searchRoute(() => { calls++; return HttpResponse.json({ places: [HOTEL] }); })); + + render(); + await user.click(screen.getByPlaceholderText('Street and number')); + + await settle(); + expect(calls).toBe(0); + expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-ADDRIN-004: fewer than three characters never reach the API', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use(searchRoute(() => { calls++; return HttpResponse.json({ places: [HOTEL] }); })); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'He'); + + await settle(); + expect(calls).toBe(0); + }); + + it('FE-PLANNER-ADDRIN-005: typing three characters opens the suggestion list', async () => { + const user = userEvent.setup(); + server.use(searchRoute([HOTEL])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Sacher'); + + expect(await screen.findByText('Hotel Sacher')).toBeInTheDocument(); + expect(screen.getByText('Philharmoniker Str. 4, Vienna')).toBeInTheDocument(); + }); + + it('FE-PLANNER-ADDRIN-006: deleting back below three characters closes the list again', async () => { + const user = userEvent.setup(); + server.use(searchRoute([HOTEL])); + + render(); + const input = screen.getByPlaceholderText('Street and number'); + await user.type(input, 'Sacher'); + await screen.findByText('Hotel Sacher'); + + await user.clear(input); + await user.type(input, 'Sa'); + + await waitFor(() => expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument()); + }); + + it('FE-PLANNER-ADDRIN-007: shows the loading row while the request is in flight', async () => { + const user = userEvent.setup(); + server.use(searchRoute(async () => { await delay(200); return HttpResponse.json({ places: [HOTEL] }); })); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Sacher'); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Hotel Sacher')).toBeInTheDocument(); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-ADDRIN-008: picking a suggestion writes its address into the field', async () => { + const user = userEvent.setup(); + const onText = vi.fn(); + server.use(searchRoute([HOTEL])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Sacher'); + await user.click(await screen.findByText('Hotel Sacher')); + + expect(onText).toHaveBeenLastCalledWith('Philharmoniker Str. 4, Vienna'); + expect(screen.getByPlaceholderText('Street and number')).toHaveValue('Philharmoniker Str. 4, Vienna'); + expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-ADDRIN-009: a suggestion without an address falls back to its name', async () => { + const user = userEvent.setup(); + const onText = vi.fn(); + server.use(searchRoute([{ name: 'Stephansplatz', address: null, osm_id: 'n5' }])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Stephans'); + await user.click(await screen.findByText('Stephansplatz')); + + expect(onText).toHaveBeenLastCalledWith('Stephansplatz'); + }); + + it('FE-PLANNER-ADDRIN-010: a suggestion without name or address clears the field', async () => { + const user = userEvent.setup(); + const onText = vi.fn(); + server.use(searchRoute([HOTEL, { osm_id: 'n6' }])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Sacher'); + await screen.findByText('Hotel Sacher'); + + const rows = screen.getAllByRole('button'); + await user.click(rows[rows.length - 1]); + + expect(onText).toHaveBeenLastCalledWith(''); + }); + + it('FE-PLANNER-ADDRIN-011: ArrowDown/ArrowUp move the highlight and Enter picks it', async () => { + const user = userEvent.setup(); + const onText = vi.fn(); + server.use(searchRoute([HOTEL, CAFE])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Vienna'); + await screen.findByText('Hotel Sacher'); + + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + expect(onText).toHaveBeenLastCalledWith('Herrengasse 14, Vienna'); + }); + + it('FE-PLANNER-ADDRIN-012: hovering a row moves the highlight so Enter picks it', async () => { + const user = userEvent.setup(); + const onText = vi.fn(); + server.use(searchRoute([HOTEL, CAFE])); + + render(); + await user.type(screen.getByPlaceholderText('Street and number'), 'Vienna'); + const cafe = await screen.findByText('Cafe Central'); + + fireEvent.mouseEnter(cafe.closest('button')!); + await user.keyboard('{ArrowUp}{Enter}'); + + expect(onText).toHaveBeenLastCalledWith('Philharmoniker Str. 4, Vienna'); + }); + + it('FE-PLANNER-ADDRIN-013: Escape closes the list and keeps the typed text', async () => { + const user = userEvent.setup(); + server.use(searchRoute([HOTEL])); + + render(); + const input = screen.getByPlaceholderText('Street and number'); + await user.type(input, 'Sacher'); + await screen.findByText('Hotel Sacher'); + + await user.keyboard('{Escape}'); + + expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument(); + expect(input).toHaveValue('Sacher'); + }); + + it('FE-PLANNER-ADDRIN-014: a mousedown outside closes the list and a failing search empties it', async () => { + const user = userEvent.setup(); + server.use(searchRoute([HOTEL])); + + render(); + const input = screen.getByPlaceholderText('Street and number'); + await user.type(input, 'Sacher'); + await screen.findByText('Hotel Sacher'); + + fireEvent.mouseDown(document.body); + await waitFor(() => expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument()); + + server.use(searchRoute(() => HttpResponse.json({ error: 'upstream down' }, { status: 500 }))); + await user.type(input, 'torte'); + + await settle(); + expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Planner/AirTrailImportModal.test.tsx b/client/src/components/Planner/AirTrailImportModal.test.tsx index af7a8a9fc..e994898bc 100644 --- a/client/src/components/Planner/AirTrailImportModal.test.tsx +++ b/client/src/components/Planner/AirTrailImportModal.test.tsx @@ -1,5 +1,5 @@ -// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-008 -import { render, screen, waitFor } from '../../../tests/helpers/render'; +// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-020 +import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -10,6 +10,16 @@ import { buildUser, buildTrip, buildReservation } from '../../../tests/helpers/f import type { AirtrailFlight } from '@trek/shared'; import AirTrailImportModal, { detectConnections } from './AirTrailImportModal'; +const toasts: Array<[string, string]> = []; +vi.mock('../shared/Toast', () => ({ + useToast: () => ({ + success: (m: string) => { toasts.push(['success', m]); return 0; }, + warning: (m: string) => { toasts.push(['warning', m]); return 0; }, + error: (m: string) => { toasts.push(['error', m]); return 0; }, + info: (m: string) => { toasts.push(['info', m]); return 0; }, + }), +})); + const flight = (over: Partial = {}): AirtrailFlight => ({ id: '101', fromCode: 'BRU', @@ -86,6 +96,7 @@ describe('AirTrailImportModal', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 1 }; beforeEach(() => { + toasts.length = 0; resetAllStores(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { @@ -208,4 +219,130 @@ describe('AirTrailImportModal', () => { // A chain with an imported member cannot be joined again. expect(screen.queryByText(/one flight with a layover/i)).not.toBeInTheDocument(); }); + + it('FE-PLANNER-AIRTRAIL-012: a failing flight list shows the server error and no rows', async () => { + server.use( + http.get('/api/integrations/airtrail/flights', () => + HttpResponse.json({ error: 'AirTrail token expired' }, { status: 401 })), + ); + render(); + expect(await screen.findByText('AirTrail token expired')).toBeInTheDocument(); + expect(screen.queryByText('Finnair AY1502')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRTRAIL-013: a transport error without a message falls back to the generic load error', async () => { + server.use(http.get('/api/integrations/airtrail/flights', () => HttpResponse.error())); + render(); + expect(await screen.findByText('Could not load your AirTrail flights.')).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRTRAIL-014: an empty account shows the empty hint and a disabled import button', async () => { + server.use(http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [] }))); + render(); + expect(await screen.findByText('No flights found in your AirTrail account.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Import 0/i })).toBeDisabled(); + }); + + it('FE-PLANNER-AIRTRAIL-015: flights outside the trip range land in "Other flights" and start unselected', async () => { + const user = userEvent.setup(); + const later = flight({ id: '110', date: '2026-09-20', departure: '2026-09-20T06:00:00.000+00:00', arrival: '2026-09-20T09:30:00.000+00:00', flightNumber: 'AY900' }); + server.use( + http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight(), later] })), + ); + render(); + expect(await screen.findByText('During this trip')).toBeInTheDocument(); + expect(screen.getByText('Other flights')).toBeInTheDocument(); + // Only the in-range flight is pre-selected. + expect(screen.getByRole('button', { name: /Import 1/i })).toBeInTheDocument(); + // Selecting the out-of-range one manually brings the count up. + await user.click(screen.getByText('Finnair AY900')); + expect(screen.getByRole('button', { name: /Import 2/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRTRAIL-016: a flight without a date shows its route as the label and no date suffix', async () => { + server.use( + http.get('/api/integrations/airtrail/flights', () => + HttpResponse.json({ flights: [flight({ id: '201', flightNumber: null, airline: null, date: null, departure: null, arrival: null })] })), + ); + render(); + // Label falls back to the route, and the sub-line carries no ` · ` suffix. + const rows = await screen.findAllByText('BRU → HEL'); + expect(rows.length).toBe(2); + expect(rows[1].textContent).toBe('BRU → HEL'); + }); + + it('FE-PLANNER-AIRTRAIL-017: a successful import toasts, registers an undo and deletes the created bookings again', async () => { + const user = userEvent.setup(); + const pushUndo = vi.fn(); + const deleted: number[] = []; + server.use( + http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight()] })), + http.post('/api/trips/1/reservations/import/airtrail', () => + HttpResponse.json({ imported: ['101'], skipped: [{ id: '999', reason: 'already-in-trip' }] })), + http.get('/api/trips/1/reservations', () => HttpResponse.json({ + reservations: [buildReservation({ id: 55, type: 'flight', external_source: 'airtrail', external_id: '101' } as any)], + })), + http.delete('/api/trips/1/reservations/55', () => { deleted.push(55); return HttpResponse.json({ success: true }); }), + ); + render(); + await user.click(await screen.findByRole('button', { name: /Import 1/i })); + + await waitFor(() => expect(pushUndo).toHaveBeenCalled()); + expect(toasts).toContainEqual(['success', '1 flight(s) imported']); + expect(toasts).toContainEqual(['warning', '1 already in this trip, skipped']); + expect(pushUndo.mock.calls[0][0]).toBe('Import from AirTrail'); + + await pushUndo.mock.calls[0][1](); + expect(deleted).toEqual([55]); + }); + + it('FE-PLANNER-AIRTRAIL-018: an import that creates nothing warns instead of claiming success', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight()] })), + http.post('/api/trips/1/reservations/import/airtrail', () => HttpResponse.json({ imported: [], skipped: [] })), + ); + render(); + await user.click(await screen.findByRole('button', { name: /Import 1/i })); + await waitFor(() => expect(toasts).toContainEqual(['warning', 'Nothing to import.'])); + }); + + it('FE-PLANNER-AIRTRAIL-019: a failing import keeps the dialog open with the server error', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + server.use( + http.get('/api/integrations/airtrail/flights', () => HttpResponse.json({ flights: [flight()] })), + http.post('/api/trips/1/reservations/import/airtrail', () => + HttpResponse.json({ error: 'AirTrail unreachable' }, { status: 502 })), + ); + render(); + await user.click(await screen.findByRole('button', { name: /Import 1/i })); + expect(await screen.findByText('AirTrail unreachable')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-AIRTRAIL-020: a backdrop press-and-release closes, a press inside does not', async () => { + const onClose = vi.fn(); + render(); + await screen.findByText('Import from AirTrail'); + const backdrop = document.querySelector('[style*="z-index: 99999"]') as HTMLElement; + + fireEvent.mouseDown(screen.getByText('Import from AirTrail')); + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.mouseDown(backdrop); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-PLANNER-AIRTRAIL-021: reservations from other sources are not treated as AirTrail imports', async () => { + seedStore(useTripStore, { + trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }), + reservations: [buildReservation({ type: 'flight', external_source: 'kitinerary', external_id: '101' }) as any], + }); + render(); + await screen.findByText('Finnair AY1502'); + expect(screen.queryByText(/^Imported$/)).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/AirportSelect.test.tsx b/client/src/components/Planner/AirportSelect.test.tsx new file mode 100644 index 000000000..82a71d474 --- /dev/null +++ b/client/src/components/Planner/AirportSelect.test.tsx @@ -0,0 +1,334 @@ +// FE-PLANNER-AIRPORTSEL-001 to FE-PLANNER-AIRPORTSEL-022 +import { useState } from 'react'; +import { delay, http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; +import { server } from '../../../tests/helpers/msw/server'; +import AirportSelect, { type Airport } from './AirportSelect'; + +function buildAirport(overrides: Partial = {}): Airport { + return { + iata: 'FRA', + icao: 'EDDF', + name: 'Frankfurt Airport', + city: 'Frankfurt', + country: 'DE', + lat: 50.0333, + lng: 8.5706, + tz: 'Europe/Berlin', + ...overrides, + }; +} + +// The component is controlled: `value` feeds the input text and suppresses the +// search for the already-picked label. Driving it through a stateful host +// mirrors how ReservationModal/TransportModal use it. +function Host({ initial = null, onPick }: { initial?: Airport | null; onPick?: (a: Airport | null) => void }) { + const [value, setValue] = useState(initial); + return ( + { setValue(a); onPick?.(a); }} + /> + ); +} + +/** Let the debounce fire and any in-flight request settle. */ +async function settle(ms = 350) { + await act(async () => { await new Promise((r) => setTimeout(r, ms)); }); +} + +function airportRoute(handler: (q: string) => Response | Promise) { + return http.get('/api/airports/search', ({ request }) => { + const q = new URL(request.url).searchParams.get('q') || ''; + return handler(q); + }); +} + +describe('AirportSelect', () => { + it('FE-PLANNER-AIRPORTSEL-001: falls back to the translated placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Airport code or city (e.g. FRA)')).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-002: an explicit placeholder wins over the default', () => { + render(); + expect(screen.getByPlaceholderText('Departure airport')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Airport code or city (e.g. FRA)')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-003: an initial value renders as "City (IATA)" with a clear button', () => { + render(); + expect(screen.getByDisplayValue('Frankfurt (FRA)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-004: without a value there is no clear button', () => { + render(); + expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-005: a single character does not reach the API', async () => { + const user = userEvent.setup(); + const seen: string[] = []; + server.use(airportRoute((q) => { seen.push(q); return HttpResponse.json([buildAirport()]); })); + + render(); + await user.type(screen.getByRole('textbox'), 'F'); + + await settle(); + expect(seen).toEqual([]); + expect(screen.queryByText('Frankfurt Airport · Germany')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-006: two characters open the dropdown with code, city and country', async () => { + const user = userEvent.setup(); + server.use(airportRoute(() => HttpResponse.json([buildAirport()]))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + + expect(await screen.findByText('FRA')).toBeInTheDocument(); + expect(screen.getByText('Frankfurt')).toBeInTheDocument(); + expect(screen.getByText('Frankfurt Airport · Germany')).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-007: an airport without a city falls back to its name and drops the country suffix', async () => { + const user = userEvent.setup(); + server.use(airportRoute(() => HttpResponse.json([buildAirport({ city: '', country: '' })]))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + + // The heading falls back to `name`, and the subtitle carries no " · country" tail. + const rows = await screen.findAllByText('Frankfurt Airport'); + expect(rows).toHaveLength(2); + }); + + it('FE-PLANNER-AIRPORTSEL-008: an unknown region code is shown verbatim', async () => { + const user = userEvent.setup(); + server.use(airportRoute(() => HttpResponse.json([buildAirport({ country: 'XX' })]))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + + expect(await screen.findByText('Frankfurt Airport · XX')).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-009: shows the loading row while the request is in flight', async () => { + const user = userEvent.setup(); + server.use(airportRoute(async () => { await delay(200); return HttpResponse.json([buildAirport()]); })); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Frankfurt Airport · Germany')).toBeInTheDocument(); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-010: picking a result reports it upwards and closes the dropdown', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(airportRoute(() => HttpResponse.json([buildAirport()]))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + await user.click(await screen.findByText('Frankfurt Airport · Germany')); + + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ iata: 'FRA', tz: 'Europe/Berlin' })); + expect(screen.getByDisplayValue('Frankfurt (FRA)')).toBeInTheDocument(); + expect(screen.queryByText('Frankfurt Airport · Germany')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-011: the picked label does not trigger a follow-up search', async () => { + const user = userEvent.setup(); + const seen: string[] = []; + server.use(airportRoute((q) => { seen.push(q); return HttpResponse.json([buildAirport()]); })); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + await user.click(await screen.findByText('Frankfurt Airport · Germany')); + + await settle(); + expect(seen).toEqual(['FRA']); + }); + + it('FE-PLANNER-AIRPORTSEL-012: the clear button resets both the value and the text', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + + render(); + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(onPick).toHaveBeenCalledWith(null); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-013: typing over a picked airport drops the selection', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(airportRoute(() => HttpResponse.json([]))); + + render(); + await user.type(screen.getByRole('textbox'), 'X'); + + expect(onPick).toHaveBeenCalledWith(null); + }); + + it('FE-PLANNER-AIRPORTSEL-014: ArrowDown then Enter picks the highlighted airport', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(airportRoute(() => HttpResponse.json([ + buildAirport(), + buildAirport({ iata: 'MUC', name: 'Munich Airport', city: 'Munich' }), + ]))); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'ger'); + await screen.findByText('Frankfurt Airport · Germany'); + + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ iata: 'MUC' })); + expect(input).toHaveValue('Munich (MUC)'); + }); + + it('FE-PLANNER-AIRPORTSEL-015: ArrowUp cannot move the highlight above the first row', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(airportRoute(() => HttpResponse.json([ + buildAirport(), + buildAirport({ iata: 'MUC', name: 'Munich Airport', city: 'Munich' }), + ]))); + + render(); + await user.type(screen.getByRole('textbox'), 'ger'); + await screen.findByText('Frankfurt Airport · Germany'); + + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowUp}{ArrowUp}{ArrowUp}{Enter}'); + + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ iata: 'FRA' })); + }); + + it('FE-PLANNER-AIRPORTSEL-016: Enter without a highlight keeps the dropdown open', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(airportRoute(() => HttpResponse.json([buildAirport()]))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + await screen.findByText('Frankfurt Airport · Germany'); + + await user.keyboard('{Enter}'); + + expect(onPick).not.toHaveBeenCalled(); + expect(screen.getByText('Frankfurt Airport · Germany')).toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-017: Escape closes the dropdown without clearing the text', async () => { + const user = userEvent.setup(); + server.use(airportRoute(() => HttpResponse.json([buildAirport()]))); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'FRA'); + await screen.findByText('Frankfurt Airport · Germany'); + + await user.keyboard('{Escape}'); + + expect(screen.queryByText('Frankfurt Airport · Germany')).not.toBeInTheDocument(); + expect(input).toHaveValue('FRA'); + }); + + it('FE-PLANNER-AIRPORTSEL-018: hovering a row moves the highlight so Enter picks it', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(airportRoute(() => HttpResponse.json([ + buildAirport(), + buildAirport({ iata: 'MUC', name: 'Munich Airport', city: 'Munich' }), + ]))); + + render(); + await user.type(screen.getByRole('textbox'), 'ger'); + const munich = await screen.findByText('Munich Airport · Germany'); + + fireEvent.mouseEnter(munich.closest('button')!); + await user.keyboard('{Enter}'); + + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ iata: 'MUC' })); + }); + + it('FE-PLANNER-AIRPORTSEL-019: a mousedown outside the field closes the dropdown', async () => { + const user = userEvent.setup(); + server.use(airportRoute(() => HttpResponse.json([buildAirport()]))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + await screen.findByText('Frankfurt Airport · Germany'); + + fireEvent.mouseDown(document.body); + + await waitFor(() => { + expect(screen.queryByText('Frankfurt Airport · Germany')).not.toBeInTheDocument(); + }); + }); + + it('FE-PLANNER-AIRPORTSEL-020: a failing request drops the previous suggestions', async () => { + const user = userEvent.setup(); + server.use(airportRoute((q) => + q === 'FRA' + ? HttpResponse.json([buildAirport()]) + : HttpResponse.json({ error: 'lookup failed' }, { status: 500 }), + )); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'FRA'); + await screen.findByText('Frankfurt Airport · Germany'); + + await user.type(input, 'N'); + + await waitFor(() => { + expect(screen.queryByText('Frankfurt Airport · Germany')).not.toBeInTheDocument(); + }); + }); + + it('FE-PLANNER-AIRPORTSEL-021: a non-array payload yields no rows', async () => { + const user = userEvent.setup(); + server.use(airportRoute(() => HttpResponse.json({ airports: [buildAirport()] }))); + + render(); + await user.type(screen.getByRole('textbox'), 'FRA'); + + await settle(); + expect(screen.queryByText('Frankfurt Airport · Germany')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-AIRPORTSEL-022: a superseded slow request must not clobber the newer rows', async () => { + const user = userEvent.setup(); + server.use(airportRoute(async (q) => { + if (q === 'Fra') { + await delay(600); + return HttpResponse.json([buildAirport({ iata: 'STL', name: 'Stale Airport', city: 'Stale' })]); + } + return HttpResponse.json([buildAirport({ iata: 'MUC', name: 'Munich Airport', city: 'Munich' })]); + })); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'Fra'); + await settle(300); + + await user.clear(input); + await user.type(input, 'Mun'); + expect(await screen.findByText('Munich Airport · Germany')).toBeInTheDocument(); + + // The first request only lands now; its abort must keep the list untouched. + await settle(600); + expect(screen.getByText('Munich Airport · Germany')).toBeInTheDocument(); + expect(screen.queryByText('Stale Airport · Germany')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Planner/BookingImportModal.test.tsx b/client/src/components/Planner/BookingImportModal.test.tsx new file mode 100644 index 000000000..16ed99d4a --- /dev/null +++ b/client/src/components/Planner/BookingImportModal.test.tsx @@ -0,0 +1,196 @@ +// FE-PLANNER-BOOKIMP-001 to FE-PLANNER-BOOKIMP-014 +import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { saveImportFiles } from '../../db/offlineDb'; +import BookingImportModal from './BookingImportModal'; + +vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(async () => {}) })); + +const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 4 }; + +const eml = (name = 'booking.eml', size = 100) => { + const f = new File(['x'], name, { type: 'message/rfc822' }); + Object.defineProperty(f, 'size', { value: size }); + return f; +}; + +const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement; +const dropZone = () => screen.getByText(/Drop booking confirmation files here/).parentElement as HTMLElement; + +beforeEach(() => { + resetAllStores(); + vi.mocked(saveImportFiles).mockClear(); + server.use( + http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: false })), + http.post('/api/trips/4/reservations/import/booking/async', () => HttpResponse.json({ jobId: 'job-1' })), + ); +}); + +describe('BookingImportModal', () => { + it('FE-PLANNER-BOOKIMP-001: renders nothing while closed', () => { + render(); + expect(screen.queryByText('Import booking confirmations')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-002: shows title, accepted formats and a disabled import button', () => { + render(); + expect(screen.getByText('Import booking confirmations')).toBeInTheDocument(); + expect(screen.getByText(/Accepted: EML, PDF, PKPass/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); + }); + + it('FE-PLANNER-BOOKIMP-003: selecting a supported file lists its name', async () => { + render(); + await userEvent.upload(fileInput(), eml('flight.pdf')); + expect(await screen.findByText('flight.pdf')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled(); + }); + + it('FE-PLANNER-BOOKIMP-004: an unsupported extension is rejected with a message', () => { + render(); + // userEvent.upload honours the input's accept filter, so drive the change directly. + fireEvent.change(fileInput(), { target: { files: [new File(['x'], 'photo.png', { type: 'image/png' })] } }); + expect(screen.getByText(/Unsupported file format/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); + }); + + it('FE-PLANNER-BOOKIMP-005: a file over 10 MB is rejected by name', () => { + render(); + fireEvent.change(fileInput(), { target: { files: [eml('huge.eml', 11 * 1024 * 1024)] } }); + expect(screen.getByText('File "huge.eml" exceeds 10 MB limit.')).toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-006: a mixed selection keeps the valid files and still surfaces the error', () => { + render(); + fireEvent.change(fileInput(), { target: { files: [eml('ok.eml'), new File(['x'], 'bad.png')] } }); + expect(screen.getByText('ok.eml')).toBeInTheDocument(); + expect(screen.getByText(/Unsupported file format/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-007: only the first five files are taken', () => { + render(); + const many = Array.from({ length: 7 }, (_, i) => eml(`b${i}.eml`)); + fireEvent.change(fileInput(), { target: { files: many } }); + expect(screen.getByText('b0.eml, b1.eml, b2.eml, b3.eml, b4.eml')).toBeInTheDocument(); + expect(screen.queryByText(/b5\.eml/)).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-008: dragging over the drop zone switches to the drop label', () => { + render(); + const zone = dropZone(); + fireEvent.dragOver(zone); + expect(screen.getByText('Drop files to import')).toBeInTheDocument(); + fireEvent.dragLeave(zone); + expect(screen.getByText(/Drop booking confirmation files here/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-016: clicking the drop zone opens the hidden file input', () => { + render(); + const click = vi.spyOn(fileInput(), 'click').mockImplementation(() => {}); + fireEvent.click(dropZone()); + expect(click).toHaveBeenCalled(); + click.mockRestore(); + }); + + it('FE-PLANNER-BOOKIMP-009: dropping files selects them', () => { + render(); + fireEvent.drop(dropZone(), { dataTransfer: { files: [eml('dropped.eml')] } }); + expect(screen.getByText('dropped.eml')).toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-010: importing queues a background task, stores the files and closes', async () => { + const onClose = vi.fn(); + let sentMode: string | null = null; + server.use( + http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => { + const fd = await request.formData(); + sentMode = String(fd.get('mode')); + return HttpResponse.json({ jobId: 'job-42' }); + }), + ); + render(); + fireEvent.change(fileInput(), { target: { files: [eml('a.eml'), eml('b.pdf')] } }); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(sentMode).toBe('no-ai'); + expect(vi.mocked(saveImportFiles).mock.calls[0][0]).toBe('job-42'); + const task = useBackgroundTasksStore.getState().tasks.find(t => t.id === 'job-42'); + expect(task).toMatchObject({ tripId: '4', label: 'a.eml, b.pdf', total: 2, mode: 'no-ai' }); + }); + + it('FE-PLANNER-BOOKIMP-011: with AI parsing enabled the mode falls back on empty results', async () => { + let sentMode: string | null = null; + server.use( + http.get('/api/health/features', () => HttpResponse.json({ bookingImport: true, aiParsing: true })), + http.post('/api/trips/4/reservations/import/booking/async', async ({ request }) => { + const fd = await request.formData(); + sentMode = String(fd.get('mode')); + return HttpResponse.json({ jobId: 'job-ai' }); + }), + ); + render(); + // Wait for the feature probe to land before kicking off the import. + await waitFor(() => expect(screen.getByText('Import booking confirmations')).toBeInTheDocument()); + fireEvent.change(fileInput(), { target: { files: [eml()] } }); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + await waitFor(() => expect(sentMode).toBe('fallback-on-empty')); + }); + + it('FE-PLANNER-BOOKIMP-012: a failing upload surfaces the server error and keeps the modal open', async () => { + const onClose = vi.fn(); + server.use( + http.post('/api/trips/4/reservations/import/booking/async', () => + HttpResponse.json({ error: 'Storage full' }, { status: 500 })), + ); + render(); + fireEvent.change(fileInput(), { target: { files: [eml()] } }); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + expect(await screen.findByText('Storage full')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled(); + }); + + it('FE-PLANNER-BOOKIMP-013: an error without a server message falls back to the generic parse error', async () => { + server.use( + http.post('/api/trips/4/reservations/import/booking/async', () => HttpResponse.error()), + ); + render(); + fireEvent.change(fileInput(), { target: { files: [eml()] } }); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + expect(await screen.findByText(/Parsing failed/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-BOOKIMP-014: Cancel closes and reopening clears the previous selection', async () => { + const onClose = vi.fn(); + const { rerender } = render(); + fireEvent.change(fileInput(), { target: { files: [eml('stale.eml')] } }); + expect(screen.getByText('stale.eml')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + + rerender(); + rerender(); + await waitFor(() => expect(screen.queryByText('stale.eml')).not.toBeInTheDocument()); + }); + + it('FE-PLANNER-BOOKIMP-015: a backdrop press-and-release closes, a press inside does not', () => { + const onClose = vi.fn(); + render(); + const backdrop = document.querySelector('[style*="z-index: 99999"]') as HTMLElement; + const card = screen.getByText('Import booking confirmations').closest('div')!.parentElement!; + + // A drag that starts inside the dialog and ends on the backdrop must not close it. + fireEvent.mouseDown(card); + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.mouseDown(backdrop); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index eb0b47936..fecad1f63 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -1,6 +1,6 @@ -// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-025 +// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-079 import React from 'react'; -import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { render, screen, waitFor, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useSettingsStore } from '../../store/settingsStore'; import { usePermissionsStore } from '../../store/permissionsStore'; +import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'; import DayDetailPanel from './DayDetailPanel'; @@ -1252,4 +1253,186 @@ describe('DayDetailPanel', () => { }); }); + // ── Header buttons ────────────────────────────────────────────────────────── + + it('FE-PLANNER-DAYDETAIL-069: the collapse and close buttons reset their hover background on leave', async () => { + render(); + const buttons = screen.getAllByRole('button'); + const collapse = buttons.find(b => b.getAttribute('title') === 'Collapse')!; + const close = buttons[buttons.indexOf(collapse) + 1]; + for (const btn of [collapse, close]) { + act(() => { btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(btn.style.background).toBe('var(--bg-hover)'); + act(() => { btn.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(btn.style.background).toBe('var(--bg-secondary)'); + } + }); + + // ── Reservation list filtering ────────────────────────────────────────────── + + it('FE-PLANNER-DAYDETAIL-070: hotel bookings are kept out of the day reservation list', async () => { + const hotel = buildReservation({ id: 30, title: 'Hotel Stay', type: 'hotel', status: 'confirmed', day_id: 1 } as any); + const dinner = buildReservation({ id: 31, title: 'Dinner', type: 'restaurant', status: 'confirmed', day_id: 1 } as any); + render(); + await screen.findByText('Dinner'); + // The hotel belongs to the accommodation section, not the bookings list. + expect(screen.queryByText('Hotel Stay')).not.toBeInTheDocument(); + }); + + // ── Accommodation time formatting ─────────────────────────────────────────── + + it('FE-PLANNER-DAYDETAIL-071: an ISO check-in is rendered as a local time, not the raw string', async () => { + server.use( + http.get('/api/trips/1/accommodations', () => + HttpResponse.json({ + accommodations: [{ + id: 1, place_id: 5, place_name: 'ISO Hotel', place_address: null, + start_day_id: 1, end_day_id: 1, check_in: '2025-06-15T14:30:00Z', check_out: null, confirmation: null, + }], + })), + ); + render(); + await screen.findByText('ISO Hotel'); + expect(screen.getByText(/\d{1,2}:\d{2}/)).toBeInTheDocument(); + expect(screen.queryByText('2025-06-15T14:30:00Z')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-DAYDETAIL-072: a non-numeric check-in is passed through untouched', async () => { + seedStore(useSettingsStore, { + settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false }, + }); + server.use( + http.get('/api/trips/1/accommodations', () => + HttpResponse.json({ + accommodations: [{ + id: 1, place_id: 5, place_name: 'Odd Hotel', place_address: null, + start_day_id: 1, end_day_id: 1, check_in: 'on arrival', check_out: null, confirmation: null, + }], + })), + ); + render(); + await screen.findByText('Odd Hotel'); + expect(screen.getByText('on arrival')).toBeInTheDocument(); + }); + + it('FE-PLANNER-DAYDETAIL-073: an existing accommodation still offers to add another', async () => { + server.use( + http.get('/api/trips/1/accommodations', () => + HttpResponse.json({ + accommodations: [{ + id: 1, place_id: 5, place_name: 'First Hotel', place_address: null, + start_day_id: 1, end_day_id: 1, check_in: '14:00', check_out: '11:00', confirmation: 'X1', + }], + })), + ); + render(); + await screen.findByText('First Hotel'); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + await waitFor(() => expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument()); + }); + + // ── Hotel picker ──────────────────────────────────────────────────────────── + + it('FE-PLANNER-DAYDETAIL-074: the picker closes on its X button and on a backdrop click', async () => { + render(); + const open = async () => { + await userEvent.click(await screen.findByText(/Add accommodation/i)); + return await waitFor(() => document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement); + }; + let overlay = await open(); + // The X sits in the picker header, right after the title. + await userEvent.click(within(overlay).getByText(/Add accommodation/i).parentElement!.querySelector('button')!); + await waitFor(() => expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull()); + + overlay = await open(); + await userEvent.click(overlay); + await waitFor(() => expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull()); + }); + + it('FE-PLANNER-DAYDETAIL-075: check-in, check-in-until, check-out and confirmation feed the saved accommodation', async () => { + const place = buildPlace({ id: 70, name: 'Pension Anna' }); + let body: Record | null = null; + server.use( + http.post('/api/trips/1/accommodations', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ + accommodation: { id: 300, place_id: 70, place_name: 'Pension Anna', place_address: null, + start_day_id: 1, end_day_id: 1, check_in: '15:00', check_in_end: '20:00', check_out: '10:00', confirmation: 'ZZ-9' }, + }); + }), + ); + render(); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + await userEvent.click(await screen.findByRole('button', { name: /Pension Anna/i })); + + const overlay = document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement; + const timeInputs = within(overlay).getAllByPlaceholderText(/^(14:00|22:00|11:00)$/); + await userEvent.type(timeInputs[0], '15:00'); + await userEvent.type(timeInputs[1], '20:00'); + await userEvent.type(timeInputs[2], '10:00'); + await userEvent.type(within(overlay).getByPlaceholderText('ABC-12345'), 'ZZ-9'); + await userEvent.click(within(overlay).getByRole('button', { name: /^Save$/i })); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body).toMatchObject({ place_id: 70, check_in: '15:00', check_in_end: '20:00', check_out: '10:00', confirmation: 'ZZ-9' }); + }); + + it('FE-PLANNER-DAYDETAIL-076: the category filter narrows the picker list and "All days" clears it', async () => { + const categories = [ + { id: 1, name: 'Hotels', color: '#8b5cf6' }, + { id: 2, name: 'Sights', color: '#ef4444' }, + ] as any; + const places = [ + buildPlace({ id: 80, name: 'Hotel Adlon', category_id: 1 } as any), + buildPlace({ id: 81, name: 'Brandenburg Gate', category_id: 2 } as any), + ]; + render(); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + const overlay = document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement; + + await userEvent.click(within(overlay).getByRole('button', { name: 'Hotels' })); + expect(within(overlay).getByText('Hotel Adlon')).toBeInTheDocument(); + expect(within(overlay).queryByText('Brandenburg Gate')).not.toBeInTheDocument(); + + // A category with no places falls back to the empty hint. + await userEvent.click(within(overlay).getByRole('button', { name: 'Sights' })); + expect(within(overlay).queryByText('Hotel Adlon')).not.toBeInTheDocument(); + + await userEvent.click(within(overlay).getAllByRole('button', { name: /^All$/ })[1]); + expect(within(overlay).getByText('Hotel Adlon')).toBeInTheDocument(); + expect(within(overlay).getByText('Brandenburg Gate')).toBeInTheDocument(); + }); + + it('FE-PLANNER-DAYDETAIL-077: hovering an unselected place row highlights it and clears again', async () => { + const places = [buildPlace({ id: 90, name: 'Hostel One' })]; + render(); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + const row = (await screen.findByText('Hostel One')).closest('button') as HTMLButtonElement; + act(() => { row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(row.style.background).toBe('var(--bg-hover)'); + act(() => { row.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(row.style.background).toBe('none'); + }); + + it('FE-PLANNER-DAYDETAIL-078: the picker reports when the trip has no places to pick from', async () => { + render(); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + expect(await screen.findByText('Add places to your trip first')).toBeInTheDocument(); + }); + + // ── Day-detail plugin slot ────────────────────────────────────────────────── + + it('FE-PLANNER-DAYDETAIL-079: a day-detail widget plugin mounts a sandboxed frame scoped to the day', async () => { + seedStore(usePluginStore, { + plugins: [ + { id: 'day-notes', name: 'Day Notes', type: 'widget', icon: null, slot: 'day-detail' }, + { id: 'hero-thing', name: 'Hero', type: 'widget', icon: null, slot: 'hero' }, + ], + }); + render(); + const frame = await waitFor(() => document.querySelector('iframe[src*="day-notes"]') as HTMLIFrameElement); + expect(frame).not.toBeNull(); + expect(document.querySelector('iframe[src*="hero-thing"]')).toBeNull(); + }); + }); diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 3806433cd..aed464d62 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -1,16 +1,19 @@ -// FE-PLANNER-DAYPLAN-001 to FE-PLANNER-DAYPLAN-042 -import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' +// FE-PLANNER-DAYPLAN-001 to FE-PLANNER-DAYPLAN-155 +import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { server } from '../../../tests/helpers/msw/server' import { clearExchangeRateCache } from '../../hooks/useExchangeRates' import { useAuthStore } from '../../store/authStore' -import { useTripStore } from '../../store/tripStore' +import { useTripStore, type TripStoreState } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' +import { usePluginStore } from '../../store/pluginStore' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation, } from '../../../tests/helpers/factories' +import type { Accommodation, Reservation } from '../../types' +import { calculateRouteWithLegs, generateGoogleMapsUrl } from '../Map/RouteCalculator' import DayPlanSidebar from './DayPlanSidebar' // ── Hoisted mock state (accessible in vi.mock factories) ──────────────────── @@ -37,11 +40,16 @@ vi.mock('../../api/client', async (importOriginal) => { reorder: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue({}), updateTime: vi.fn().mockResolvedValue({}), + updateTransport: vi.fn().mockResolvedValue({}), }, reservationsApi: { list: vi.fn().mockResolvedValue({ reservations: [] }), updatePositions: vi.fn().mockResolvedValue({}), }, + daysApi: { + ...actual.daysApi, + updateTransport: vi.fn().mockResolvedValue({}), + }, } }) @@ -79,17 +87,23 @@ vi.mock('../Weather/WeatherWidget', () => ({ default: () => , })) +// A stable toast object so tests can assert on the messages the sidebar raises. +const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() })) + vi.mock('../shared/Toast', () => ({ - useToast: () => ({ error: vi.fn(), success: vi.fn() }), + useToast: () => mockToast, })) // ── Permissions mock ──────────────────────────────────────────────────────── +// Flipped per test to render the read-only variants of the day rows. +const mockPermissions = vi.hoisted(() => ({ canEdit: true })) + vi.mock('../../store/permissionsStore', async (importOriginal) => { const actual = await importOriginal() as any return { ...actual, - useCanDo: () => () => true, + useCanDo: () => () => mockPermissions.canEdit, } }) @@ -128,9 +142,52 @@ function makeDefaultProps(overrides = {}) { // ── Setup ─────────────────────────────────────────────────────────────────── +// ── Shared helpers for the newer tests ────────────────────────────────────── + +/** Replace store actions the sidebar captures once, before its first render. */ +function stubTripActions(actions: Record) { + useTripStore.setState(actions as unknown as Partial) +} + +function dayHeader(title: string) { + return screen.getByText(title).closest('[style*="cursor: pointer"]') as HTMLElement +} + +function dragRow(el: HTMLElement | null) { + return el!.closest('[draggable="true"]') as HTMLElement +} + +/** Transport and note rows share the same card margin; legs are not draggable. */ +function cardRow(el: HTMLElement | null) { + return el!.closest('[style*="margin: 1px 8px"]') as HTMLElement +} + +function lockToggle(row: HTMLElement) { + return row.querySelector('[style*="cursor: pointer"][style*="position: relative"]') as HTMLElement +} + +/** The context menu is portalled to body and shares labels with the route tools. */ +function contextMenu() { + return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement) +} + +const emptyDataTransfer = { setData: vi.fn(), effectAllowed: '', getData: vi.fn(() => '') } + beforeEach(() => { resetAllStores() vi.clearAllMocks() + mockPermissions.canEdit = true + // clearAllMocks keeps implementations, so tests that swap the router out would + // otherwise leak into the ones after them. + vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({ + coordinates: [], distance: 0, duration: 0, + legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({ + mid: [0, 0] as [number, number], from: [0, 0] as [number, number], to: [0, 0] as [number, number], + distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min', + drivingText: '10 min', walkingText: '25 min', + })), + })) + vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...') sessionStorage.clear() localStorage.clear() // Cost totals fetch FX rates for their base currency; keep the suite hermetic. @@ -2100,4 +2157,1513 @@ describe('DayPlanSidebar', () => { await user.click(screen.getByRole('button', { name: 'Route' })) expect(await screen.findByText('2 km')).toBeInTheDocument() }) + + // ── Persisted / externally driven state ────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-110: the saved collapse state wins over expanding every day', () => { + const place = buildPlace({ id: 1, name: 'Stored Place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + localStorage.setItem('day-expanded-1', JSON.stringify([])) + render() + expect(screen.getByText('Day 1')).toBeInTheDocument() + expect(screen.queryByText('Stored Place')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-111: an externally requested booking opens its detail modal and hands editing back', async () => { + const user = userEvent.setup() + const onExternalTransportDetailHandled = vi.fn() + const onEditTransport = vi.fn() + const res = buildReservation({ id: 300, type: 'flight', title: 'BER to CDG', reservation_time: '2025-06-01T08:30:00' }) + render() + expect(await screen.findByText('BER to CDG')).toBeInTheDocument() + expect(onExternalTransportDetailHandled).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: /^Edit$/ })) + expect(onEditTransport).toHaveBeenCalledWith(res) + await waitFor(() => expect(screen.queryByText('BER to CDG')).not.toBeInTheDocument()) + }) + + it('FE-PLANNER-DAYPLAN-112: the list restores its saved scroll offset and reports further scrolling', () => { + const onScrollTopChange = vi.fn() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + // jsdom has no layout, so scrollTop is a no-op accessor — back it with a real one. + const original = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')! + Object.defineProperty(Element.prototype, 'scrollTop', { + configurable: true, + get(this: Element & { _st?: number }) { return this._st ?? 0 }, + set(this: Element & { _st?: number }, v: number) { this._st = v }, + }) + try { + render() + const list = document.querySelector('.scroll-container') as HTMLElement + expect(list.scrollTop).toBe(140) + list.scrollTop = 260 + fireEvent.scroll(list) + expect(onScrollTopChange).toHaveBeenCalledWith(260) + } finally { + Object.defineProperty(Element.prototype, 'scrollTop', original) + } + }) + + it('FE-PLANNER-DAYPLAN-113: a day added later expands on its own and is remembered', () => { + const d1 = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const d2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }) + const props = makeDefaultProps({ days: [d1] }) + const { rerender } = render() + expect(screen.getAllByText('No places planned for this day')).toHaveLength(1) + rerender() + expect(screen.getAllByText('No places planned for this day')).toHaveLength(2) + expect(JSON.parse(localStorage.getItem('day-expanded-1')!).sort()).toEqual([10, 11]) + }) + + it('FE-PLANNER-DAYPLAN-114: bookings without a saved slot get one derived from their time', async () => { + const { reservationsApi } = await import('../../api/client') + const place = buildPlace({ id: 1, name: 'Cafe', place_time: '08:00' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + const late = buildReservation({ id: 301, type: 'flight', title: 'Late flight', reservation_time: '2025-06-01T18:00:00', day_id: 10 }) + const early = buildReservation({ id: 302, type: 'train', title: 'Early train', reservation_time: '2025-06-01T06:00:00', day_id: 10 }) + seedStore(useTripStore, { reservations: [late, early] }) + render() + await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled()) + const positions = vi.mocked(reservationsApi.updatePositions).mock.calls[0][1] + // Sorted by departure time, so the 06:00 train is initialised first. + expect(positions.map(p => p.id)).toEqual([302, 301]) + // The store is updated up front so the merged list sees the new slots. + const stored = useTripStore.getState().reservations.find(r => r.id === 301) + expect(stored!.day_plan_position).toBe(positions[1].day_plan_position) + }) + + it('FE-PLANNER-DAYPLAN-115: a multi-day cruise labels its start, middle and end days', () => { + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const cruise = buildReservation({ + id: 310, type: 'cruise', title: 'Baltic cruise', day_id: 10, end_day_id: 12, + reservation_time: '2025-06-01T10:00:00', + }) + render() + expect(screen.getByText('Start')).toBeInTheDocument() + expect(screen.getByText('Ongoing')).toBeInTheDocument() + expect(screen.getByText('End')).toBeInTheDocument() + }) + + // ── Moving a booking across days (computeMultiDayMove) ─────────────────── + + it('FE-PLANNER-DAYPLAN-116: dragging a span start past its end collapses the booking onto one day', () => { + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ updateReservation }) + const days = [9, 10, 11, 12].map((id, i) => buildDay({ id, date: `2025-06-0${i + 1}`, title: `Day ${i + 1}` })) + const flight = buildReservation({ id: 400, type: 'flight', title: 'Long flight', day_id: 10, end_day_id: 12, reservation_time: '2025-06-02T10:00:00' }) + render() + // Rows render on days 2..4; the first one is the start phase. + fireEvent.dragStart(dragRow(screen.getAllByText('Long flight')[0]), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 1'), { dataTransfer: { getData: vi.fn(() => '') } }) + // Day 1 sits before the span end, so only the start moves. + expect(updateReservation).toHaveBeenLastCalledWith(1, 400, { day_id: 9, end_day_id: 12 }) + }) + + it('FE-PLANNER-DAYPLAN-117: dragging a span end before its start collapses it, otherwise it just shortens', () => { + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ updateReservation }) + const days = [9, 10, 11, 12].map((id, i) => buildDay({ id, date: `2025-06-0${i + 1}`, title: `Day ${i + 1}` })) + const flight = buildReservation({ id: 400, type: 'flight', title: 'Long flight', day_id: 10, end_day_id: 12, reservation_time: '2025-06-02T10:00:00' }) + render() + const endRow = () => dragRow(screen.getAllByText('Long flight')[2]) + fireEvent.dragStart(endRow(), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 3'), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenLastCalledWith(1, 400, { day_id: 10, end_day_id: 11 }) + + fireEvent.dragStart(endRow(), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 1'), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenLastCalledWith(1, 400, { day_id: 9, end_day_id: 9 }) + }) + + it('FE-PLANNER-DAYPLAN-118: a single-day booking dragged to another day moves wholesale', () => { + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const dinner = buildReservation({ id: 401, type: 'train', title: 'Regional train', day_id: 10, reservation_time: '2025-06-01T10:00:00' }) + render() + fireEvent.dragStart(dragRow(screen.getByText('Regional train')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 401, { day_id: 11, end_day_id: 11 }) + }) + + it('FE-PLANNER-DAYPLAN-119: only a genuine multi-day rental with known days shows the active badge', () => { + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const sameDay = buildReservation({ id: 410, type: 'car', title: 'Day rental', day_id: 10, end_day_id: 10 }) + const unknownEnd = buildReservation({ id: 411, type: 'car', title: 'Ghost rental', day_id: 10, end_day_id: 999 }) + const real = buildReservation({ id: 412, type: 'car', title: 'Real rental', day_id: 10, end_day_id: 12 }) + render() + // The real rental: a pickup row, a return row and the "active" badge on day 2. + expect(screen.getAllByText('Real rental')).toHaveLength(3) + // A same-day rental and one whose end day is unknown never count as active, + // so each only shows its own timeline row. + expect(screen.getAllByText('Day rental')).toHaveLength(1) + expect(screen.getAllByText('Ghost rental')).toHaveLength(1) + }) + + // ── Route legs, hotel bookends, travel modes ───────────────────────────── + + it('FE-PLANNER-DAYPLAN-120: a routed day draws the hotel bookends around its stops', async () => { + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const accommodations: Accommodation[] = [{ + id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12, + place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia', + }] + const assignments = { + '11': [ + buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }), + buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }), + ], + } + render() + // Three day-header badges plus the morning departure and evening return connectors. + await waitFor(() => expect(screen.getAllByText('Hotel Lutetia')).toHaveLength(5)) + }) + + it('FE-PLANNER-DAYPLAN-121: a located booking splits the day into separate drive runs', async () => { + const { calculateRouteWithLegs } = await import('../Map/RouteCalculator') + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const flight = buildReservation({ + id: 420, type: 'flight', title: 'Hop over', day_id: 10, reservation_time: '2025-06-01T12:00:00', + endpoints: [ + { role: 'from', sequence: 0, name: 'ORY', code: null, lat: 48.72, lng: 2.36, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'NCE', code: null, lat: 43.66, lng: 7.21, timezone: null, local_date: null, local_time: null }, + ], + }) + const assignments = { + '10': [ + buildAssignment({ id: 1, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Morning stop', place_time: '09:00', lat: 48.86, lng: 2.34 }) }), + buildAssignment({ id: 2, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'Evening stop', place_time: '18:00', lat: 43.70, lng: 7.26 }) }), + ], + } + render() + await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalled()) + // Two runs: morning stop → departure airport, arrival airport → evening stop. + const pairs = vi.mocked(calculateRouteWithLegs).mock.calls.map(c => c[0]) + expect(pairs).toContainEqual([{ lat: 48.86, lng: 2.34 }, { lat: 48.72, lng: 2.36 }]) + expect(pairs).toContainEqual([{ lat: 43.66, lng: 7.21 }, { lat: 43.70, lng: 7.26 }]) + }) + + it('FE-PLANNER-DAYPLAN-122: a failing route lookup leaves the day without connectors', async () => { + const { calculateRouteWithLegs } = await import('../Map/RouteCalculator') + vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('router down')) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 1, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 2, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + render() + await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalled()) + expect(screen.queryByText('2 km')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-123: adding a note to a collapsed day expands it first', async () => { + const user = userEvent.setup() + mockDayNotesState.openAddNote.mockImplementation((dayId: number, _getMerged: unknown, expand: (id: number) => void) => expand(dayId)) + const place = buildPlace({ id: 1, name: 'Hidden Place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + localStorage.setItem('day-expanded-1', JSON.stringify([])) + render() + expect(screen.queryByText('Hidden Place')).not.toBeInTheDocument() + await user.click(screen.getByLabelText('Add Note')) + expect(await screen.findByText('Hidden Place')).toBeInTheDocument() + }) + + // ── applyMergedOrder ───────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-124: reordering a day persists booking slots, note order and an undo step', async () => { + const { reservationsApi } = await import('../../api/client') + const updateDayNote = vi.fn(async () => buildDayNote({ id: 70 })) + const reorderAssignments = vi.fn(async () => undefined) + stubTripActions({ updateDayNote, reorderAssignments }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const placeA = buildPlace({ id: 1, name: 'Place A' }) + const placeB = buildPlace({ id: 2, name: 'Place B' }) + const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) + const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB }) + const note = buildDayNote({ id: 70, day_id: 10, text: 'Pack sunscreen', sort_order: 1.4 }) + const bus = buildReservation({ id: 430, type: 'bus', title: 'Airport bus', day_id: 10, day_plan_position: 1.5 }) + mockDayNotesState.dayNotes = { '10': [note] } + seedStore(useTripStore, { reservations: [bus] }) + const onReorder = vi.fn(async () => undefined) + const onRouteRefresh = vi.fn() + const pushUndo = vi.fn() + render() + // Drag Place B above Place A so the note + bus end up as one trailing group. + fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } }) + + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11])) + expect(updateDayNote).toHaveBeenCalledWith(1, 10, 70, { sort_order: expect.any(Number) }) + await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalledWith( + 1, [{ id: 430, day_plan_position: expect.any(Number) }], 10, + )) + expect(onRouteRefresh).toHaveBeenCalled() + // The store carries the new slot per day so the merged list stays stable. + expect(useTripStore.getState().reservations[0].day_positions).toEqual({ 10: expect.any(Number) }) + // Undoing restores the original assignment order. + const undo = pushUndo.mock.calls[0][1] as () => Promise + await undo() + expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12]) + }) + + it('FE-PLANNER-DAYPLAN-125: a rejected reorder surfaces the error as a toast', async () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const placeA = buildPlace({ id: 1, name: 'Place A' }) + const placeB = buildPlace({ id: 2, name: 'Place B' }) + const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) + const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB }) + const onReorder = vi.fn().mockRejectedValue(new Error('server said no')) + render() + fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('server said no')) + }) + + it('FE-PLANNER-DAYPLAN-126: reordering around a multi-leg flight writes a position per leg', async () => { + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ updateReservation }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const placeA = buildPlace({ id: 1, name: 'Place A', place_time: '06:00' }) + const placeB = buildPlace({ id: 2, name: 'Place B' }) + const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) + const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB }) + const metadata = { + legs: [ + { dep_day_id: 10, arr_day_id: 10, dep_time: '10:00', arr_time: '11:00', from: 'BER', to: 'FRA', airline: 'LH', flight_number: 'LH1' }, + { dep_day_id: 10, arr_day_id: 10, dep_time: '12:00', arr_time: '15:00', from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH2' }, + ], + } + const flight = buildReservation({ id: 440, type: 'flight', title: 'Berlin to New York', day_id: 10, reservation_time: '2025-06-01T10:00:00' }) + const flightWithLegs = { ...flight, metadata } as unknown as Reservation + seedStore(useTripStore, { reservations: [flightWithLegs] }) + const onReorder = vi.fn(async () => undefined) + render() + // Both legs render as their own rows; drag the late place above them. + expect(screen.getAllByText('Berlin to New York')).toHaveLength(2) + fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(cardRow(screen.getAllByText('Berlin to New York')[0]), { dataTransfer: { getData: vi.fn(() => '') } }) + + await waitFor(() => expect(updateReservation).toHaveBeenCalled()) + const payload = (updateReservation.mock.calls[0] as unknown as unknown[])[2] as { metadata: { legs: Array<{ day_positions?: Record }> } } + expect(payload.metadata.legs).toHaveLength(2) + expect(payload.metadata.legs[0].day_positions).toEqual({ 10: expect.any(Number) }) + expect(payload.metadata.legs[1].day_positions).toEqual({ 10: expect.any(Number) }) + }) + + it('FE-PLANNER-DAYPLAN-127: a failing time removal aborts the reorder and reports the error', async () => { + const user = userEvent.setup() + const { assignmentsApi } = await import('../../api/client') + vi.mocked(assignmentsApi.updateTime).mockRejectedValueOnce(new Error('locked')) + const placeA = buildPlace({ id: 1, name: 'Morning Place', place_time: '08:00' }) + const placeB = buildPlace({ id: 2, name: 'Afternoon Place', place_time: '14:00' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) + const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(dragRow(screen.getByText('Afternoon Place')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dragRow(screen.getByText('Morning Place')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(screen.getByText('Remove time?')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('locked')) + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYPLAN-128: undoing a lock restores the previous locked set', async () => { + const user = userEvent.setup() + const place = buildPlace({ id: 1, name: 'Locked Place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + const pushUndo = vi.fn() + render() + const row = dragRow(screen.getByText('Locked Place')) + await user.click(lockToggle(row)) + await waitFor(() => expect(row.style.borderLeftColor).toBe('rgb(220, 38, 38)')) + const undo = pushUndo.mock.calls[0][1] as () => void + undo() + await waitFor(() => expect(dragRow(screen.getByText('Locked Place')).style.borderLeftColor).toBe('transparent')) + }) + + it('FE-PLANNER-DAYPLAN-129: optimising a day with fewer than three stops does nothing', async () => { + const user = userEvent.setup() + const placeA = buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) + const placeB = buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const onReorder = vi.fn(async () => undefined) + render() + await user.click(screen.getByRole('button', { name: 'Optimize' })) + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYPLAN-130: the weather badge falls back to any located trip place', () => { + const located = buildPlace({ id: 5, name: 'Somewhere', lat: 48.85, lng: 2.35 }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + render() + expect(screen.getByTestId('weather-widget')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-131: a read-only trip drops the edit affordances from day and note rows', () => { + mockPermissions.canEdit = false + const place = buildPlace({ id: 1, name: 'Read only place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + render() + expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument() + expect(dragRow(screen.getByText('Read only place'))).toBeNull() + expect(screen.getByText('A note')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => { + const user = userEvent.setup() + mockPermissions.canEdit = false + const place = buildPlace({ id: 1, name: 'Read only place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + render() + const chevron = dayHeader('Day 1').querySelector('button') as HTMLElement + await user.click(chevron) + expect(screen.queryByText('Read only place')).not.toBeInTheDocument() + }) + + // ── Drops onto the expanded day body ───────────────────────────────────── + + function expandedBody() { + return document.querySelector('[style*="padding-top: 6px"]') as HTMLElement + } + + function dayWithBusAndPlaces() { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const placeA = buildPlace({ id: 1, name: 'Place A' }) + const placeB = buildPlace({ id: 2, name: 'Place B' }) + const bus = buildReservation({ id: 500, type: 'bus', title: 'City bus', day_id: 10, day_plan_position: 1.5 }) + return { + day, placeA, placeB, bus, + assignments: { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB }), + ], + }, + } + } + + it('FE-PLANNER-DAYPLAN-133: a sidebar place dropped on a booking row lands on that day', () => { + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + const onAssignToDay = vi.fn() + render() + window.__dragData = { placeId: '77' } + fireEvent.dragOver(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(expandedBody(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(onAssignToDay).toHaveBeenCalledWith(77, 10) + window.__dragData = null + }) + + it('FE-PLANNER-DAYPLAN-134: a same-day place dropped on a booking row reorders around it', async () => { + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(expandedBody(), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [11, 12])) + }) + + it('FE-PLANNER-DAYPLAN-135: a same-day note dropped on a booking row is re-sorted, not moved', async () => { + const updateDayNote = vi.fn(async () => buildDayNote({ id: 70 })) + stubTripActions({ updateDayNote }) + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'Buy tickets', sort_order: 0.5 })] } + render( undefined), + })} />) + fireEvent.dragStart(cardRow(screen.getByText('Buy tickets')), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(expandedBody(), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(updateDayNote).toHaveBeenCalledWith(1, 10, 70, { sort_order: expect.any(Number) })) + }) + + it('FE-PLANNER-DAYPLAN-136: a booking from another day dropped on a booking row is re-dated', () => { + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const bus = buildReservation({ id: 500, type: 'bus', title: 'City bus', day_id: 11 }) + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + render() + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + // The second day's body is the one holding the bus row. + const bodies = document.querySelectorAll('[style*="padding-top: 6px"]') + fireEvent.drop(bodies[1], { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) + }) + + it('FE-PLANNER-DAYPLAN-137: cross-day places, notes and bookings dropped on the day body are moved', () => { + const moveAssignment = vi.fn(async () => undefined) + const moveDayNote = vi.fn(async () => undefined) + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ moveAssignment, moveDayNote, updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] } + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + render() + const target = () => document.querySelectorAll('[style*="padding-top: 6px"]')[1] + + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11) + + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveDayNote).toHaveBeenCalledWith(1, 10, 11, 70) + + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) + }) + + it('FE-PLANNER-DAYPLAN-138: a drop with no payload on the day body changes nothing', () => { + const onAssignToDay = vi.fn() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + render() + fireEvent.drop(expandedBody(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(onAssignToDay).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYPLAN-139: a same-day place dropped on the empty day body moves to the end', async () => { + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(expandedBody(), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11])) + }) + + it('FE-PLANNER-DAYPLAN-140: a same-day note dropped on the empty day body moves to the end', async () => { + const updateDayNote = vi.fn(async () => buildDayNote({ id: 70 })) + stubTripActions({ updateDayNote }) + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: -1 })] } + render( undefined), + })} />) + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(expandedBody(), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(updateDayNote).toHaveBeenCalledWith(1, 10, 70, { sort_order: expect.any(Number) })) + }) + + it('FE-PLANNER-DAYPLAN-141: dragging over the empty day body marks it as the end drop target', () => { + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + render() + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(expandedBody(), { dataTransfer: emptyDataTransfer }) + // The end-of-list marker is a 2px rule shown only while that target is active. + expect(document.querySelector('[style*="height: 2px"]')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-142: an empty day accepts a place dropped straight onto its placeholder', () => { + const onAssignToDay = vi.fn() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + render() + const placeholder = screen.getByText('No places planned for this day').parentElement as HTMLElement + fireEvent.dragOver(placeholder, { dataTransfer: emptyDataTransfer }) + expect(placeholder.className).toContain('bg-[rgba(17,24,39,0.05)]') + fireEvent.drop(placeholder, { dataTransfer: { getData: (k: string) => (k === 'placeId' ? '88' : '') } }) + expect(onAssignToDay).toHaveBeenCalledWith(88, 10) + }) + + // ── Drops onto the trailing drop zone ──────────────────────────────────── + + function endZones() { + return document.querySelectorAll('[style*="min-height: 12px"]') + } + + it('FE-PLANNER-DAYPLAN-143: cross-day items dropped on the end zone move to that day', () => { + const moveAssignment = vi.fn(async () => undefined) + const moveDayNote = vi.fn(async () => undefined) + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ moveAssignment, moveDayNote, updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] } + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + render() + + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(endZones()[1], { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11) + + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(endZones()[1], { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveDayNote).toHaveBeenCalledWith(1, 10, 11, 70) + + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(endZones()[1], { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) + }) + + it('FE-PLANNER-DAYPLAN-144: a same-day booking dropped on the end zone moves after the last item', async () => { + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + // Pinned between the two places, so the last timeline item is a place. + const earlyBus = { ...bus, day_positions: { 10: 0.5 } } + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(endZones()[0], { dataTransfer: emptyDataTransfer }) + fireEvent.drop(endZones()[0], { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [11, 12])) + }) + + it('FE-PLANNER-DAYPLAN-145: a payload-free drop on the end zone of an empty day is a no-op', () => { + const onAssignToDay = vi.fn() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + render() + fireEvent.drop(endZones()[0], { dataTransfer: { getData: vi.fn(() => '') } }) + expect(onAssignToDay).not.toHaveBeenCalled() + window.__dragData = { assignmentId: '11', fromDayId: '10' } + fireEvent.drop(endZones()[0], { dataTransfer: { getData: vi.fn(() => '') } }) + expect(onAssignToDay).not.toHaveBeenCalled() + window.__dragData = null + }) + + // ── Note rows ──────────────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-146: a sidebar place dropped on a note lands at the note position', () => { + const onAssignToDay = vi.fn() + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: 0.5 })] } + render() + window.__dragData = { placeId: '99' } + fireEvent.drop(cardRow(screen.getByText('A note')), { dataTransfer: { getData: vi.fn(() => '') } }) + // One place sits above the note, so the new one is inserted at index 1. + expect(onAssignToDay).toHaveBeenCalledWith(99, 10, 1) + window.__dragData = null + }) + + it('FE-PLANNER-DAYPLAN-147: a same-day booking or place dropped on a note reorders the day', async () => { + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + // The note leads the day, so dropping Place B on it puts B first. + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: -1 })] } + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(cardRow(screen.getByText('A note')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalled()) + + onReorder.mockClear() + fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(cardRow(screen.getByText('A note')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11])) + }) + + it('FE-PLANNER-DAYPLAN-148: cross-day items dropped on a note move onto the note day', () => { + const moveAssignment = vi.fn(async () => undefined) + const moveDayNote = vi.fn(async () => undefined) + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ moveAssignment, moveDayNote, updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] } + mockDayNotesState.dayNotes = { + '10': [buildDayNote({ id: 70, day_id: 10, text: 'Source note' })], + '11': [buildDayNote({ id: 71, day_id: 11, text: 'Target note' })], + } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + render() + const target = () => cardRow(screen.getByText('Target note')) + + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11, 0) + + fireEvent.dragStart(cardRow(screen.getByText('Source note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveDayNote).toHaveBeenCalledWith(1, 10, 11, 70, expect.any(Number)) + + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) + }) + + it('FE-PLANNER-DAYPLAN-149: the note context menu edits and asks before deleting', async () => { + const user = userEvent.setup() + const note = buildDayNote({ id: 70, day_id: 10, text: 'A note' }) + mockDayNotesState.dayNotes = { '10': [note] } + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + render() + fireEvent.contextMenu(cardRow(screen.getByText('A note'))) + await user.click(screen.getByRole('button', { name: 'Edit' })) + expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, note) + + fireEvent.contextMenu(cardRow(screen.getByText('A note'))) + await user.click(screen.getByRole('button', { name: 'Delete' })) + expect(await screen.findByText('Delete note?')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-150: the note move-up button reorders through the notes hook', async () => { + const user = userEvent.setup() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: 5 })] } + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + render() + const noteRow = cardRow(screen.getByText('A note')) + await user.click(noteRow.querySelectorAll('.reorder-buttons button')[0]) + expect(mockDayNotesState.moveNote).toHaveBeenCalledWith(10, 70, 'up', expect.any(Function)) + }) + + // ── Place rows ─────────────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-151: cross-day and same-day payloads dropped on a place row are handled apart', async () => { + const moveAssignment = vi.fn(async () => undefined) + const moveDayNote = vi.fn(async () => undefined) + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ moveAssignment, moveDayNote, updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const target = buildPlace({ id: 2, name: 'Target place' }) + const assignments = { + '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })], + '11': [buildAssignment({ id: 12, day_id: 11, order_index: 0, place: target })], + } + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + render() + const targetRow = () => dragRow(screen.getByText('Target place')) + + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) + + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveDayNote).toHaveBeenCalledWith(1, 10, 11, 70, expect.any(Number)) + + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11, 0) + }) + + it('FE-PLANNER-DAYPLAN-152: a same-day booking or note dropped on a place row reorders the day', async () => { + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: 1.2 })] } + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(cardRow(screen.getByText('City bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalled()) + + onReorder.mockClear() + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalled()) + }) + + it('FE-PLANNER-DAYPLAN-153: dropping a place onto itself leaves the order untouched', async () => { + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + const onReorder = vi.fn(async () => undefined) + render() + const row = dragRow(screen.getByText('Place A')) + fireEvent.dragStart(row, { dataTransfer: emptyDataTransfer }) + fireEvent.drop(row, { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(row.style.opacity).toBe('1')) + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYPLAN-154: an assignment without a place renders nothing for that row', () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const orphan = { ...buildAssignment({ id: 11, day_id: 10, order_index: 0 }), place: null } + render() + expect(screen.getByText('Day 1')).toBeInTheDocument() + expect(document.querySelectorAll('.dp-row')).toHaveLength(0) + }) + + it('FE-PLANNER-DAYPLAN-155: on a touch device place rows lose their drag handle', () => { + const place = buildPlace({ id: 1, name: 'Touch place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + render() + const row = screen.getByText('Touch place').closest('.dp-row') as HTMLElement + expect(row.getAttribute('draggable')).toBe('false') + expect(row.querySelector('.dp-grip')).toBeNull() + fireEvent.dragStart(row, { dataTransfer: emptyDataTransfer }) + expect(row.style.opacity).toBe('1') + }) + + // ── Booking rows ───────────────────────────────────────────────────────── + + const withEndpoints = (r: Reservation): Reservation => ({ + ...r, + endpoints: [ + { role: 'from', sequence: 0, name: 'A', code: null, lat: 52.3, lng: 13.5, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'B', code: null, lat: 49.0, lng: 2.5, timezone: null, local_date: null, local_time: null }, + ], + }) + + it('FE-PLANNER-DAYPLAN-156: a routable booking row carries a per-booking route toggle', async () => { + const user = userEvent.setup() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const flight = withEndpoints(buildReservation({ id: 510, type: 'flight', title: 'BER to CDG', day_id: 10 })) + const onToggleConnection = vi.fn() + const { rerender } = render() + const show = screen.getByTitle('Show booking routes') + await user.click(show) + expect(onToggleConnection).toHaveBeenCalledWith(510) + fireEvent.mouseEnter(show) + fireEvent.mouseLeave(show) + + rerender() + expect(screen.getByTitle('Hide booking routes')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-157: a non-transport booking row opens the reservation editor, unless read-only', async () => { + const user = userEvent.setup() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const event = buildReservation({ id: 511, type: 'event', title: 'Opera night', day_id: 10 }) + const onEditReservation = vi.fn() + render() + const row = cardRow(screen.getByText('Opera night')) + fireEvent.mouseEnter(row) + fireEvent.mouseLeave(row) + await user.click(row) + expect(onEditReservation).toHaveBeenCalledWith(event) + }) + + it('FE-PLANNER-DAYPLAN-158: a read-only booking row is inert and cannot start a drag', async () => { + const user = userEvent.setup() + mockPermissions.canEdit = false + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const event = buildReservation({ id: 511, type: 'flight', title: 'Opera night', day_id: 10 }) + const onEditTransport = vi.fn() + render() + const row = cardRow(screen.getByText('Opera night')) + await user.click(row) + expect(onEditTransport).not.toHaveBeenCalled() + fireEvent.dragStart(row, { dataTransfer: emptyDataTransfer }) + expect(row.style.opacity).toBe('1') + }) + + it('FE-PLANNER-DAYPLAN-159: dragging over a booking row marks it, and dragEnd clears the marker', () => { + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + render() + const row = cardRow(screen.getByText('City bus')) + fireEvent.dragStart(row, { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(row, { dataTransfer: emptyDataTransfer, clientY: 0 }) + expect(row.style.borderTop).toContain('2px') + fireEvent.dragEnd(row) + expect(row.style.borderTop).toBe('') + }) + + it('FE-PLANNER-DAYPLAN-160: cross-day payloads dropped on a booking row move onto its day', () => { + const moveAssignment = vi.fn(async () => undefined) + const moveDayNote = vi.fn(async () => undefined) + const updateReservation = vi.fn(async () => ({} as Reservation)) + stubTripActions({ moveAssignment, moveDayNote, updateReservation }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] } + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + const target = buildReservation({ id: 502, type: 'bus', title: 'Target bus', day_id: 11 }) + render() + const targetRow = () => cardRow(screen.getByText('Target bus')) + + window.__dragData = { placeId: '55' } + const onAssign = makeDefaultProps().onAssignToDay + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(onAssign).not.toHaveBeenCalled() + window.__dragData = null + + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11) + + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(moveDayNote).toHaveBeenCalledWith(1, 10, 11, 70) + + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targetRow(), { dataTransfer: { getData: vi.fn(() => '') } }) + expect(updateReservation).toHaveBeenCalledWith(1, 501, { day_id: 11, end_day_id: 11 }) + }) + + it('FE-PLANNER-DAYPLAN-161: same-day payloads dropped on a booking row reorder the day', async () => { + const updateDayNote = vi.fn(async () => buildDayNote({ id: 70 })) + stubTripActions({ updateDayNote }) + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: -1 })] } + const secondBus = buildReservation({ id: 501, type: 'bus', title: 'Shuttle', day_id: 10, day_positions: { 10: 0.5 } }) + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(cardRow(screen.getByText('Shuttle')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(cardRow(screen.getByText('City bus')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(onReorder).toHaveBeenCalled()) + + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(cardRow(screen.getByText('City bus')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(updateDayNote).toHaveBeenCalled()) + }) + + // ── Linked booking badge on a place row ────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-162: a linked flight shows its times, airline and a route toggle', async () => { + const user = userEvent.setup() + const place = buildPlace({ id: 1, name: 'Airport' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + const linked = withEndpoints(buildReservation({ + id: 520, type: 'flight', title: 'BER to CDG', status: 'confirmed', assignment_id: 11, + reservation_time: '2025-06-01T08:30:00', reservation_end_time: '2025-06-01T10:05:00', + metadata: JSON.stringify({ airline: 'Air France', flight_number: 'AF1235' }), + })) + const onToggleConnection = vi.fn() + const onEditTransport = vi.fn() + render() + expect(screen.getByText('08:30 – 10:05')).toBeInTheDocument() + expect(screen.getByText('Air France AF1235')).toBeInTheDocument() + await user.click(screen.getByTitle('Show booking routes')) + expect(onToggleConnection).toHaveBeenCalledWith(520) + const edit = screen.getByTitle('Edit') + fireEvent.mouseEnter(edit) + fireEvent.mouseLeave(edit) + await user.click(edit) + expect(onEditTransport).toHaveBeenCalledWith(linked) + }) + + it('FE-PLANNER-DAYPLAN-163: a linked train badge falls back to the train number alone', () => { + const place = buildPlace({ id: 1, name: 'Station' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + const linked = buildReservation({ + id: 521, type: 'train', title: 'ICE 599', status: 'pending', assignment_id: 11, + metadata: JSON.stringify({ train_number: 'ICE 599' }), + }) + render() + expect(screen.getAllByText('ICE 599').length).toBeGreaterThan(0) + expect(screen.getByText(/Reservation pending/)).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-164: travellers on a place row are shown as avatars with an overflow count', () => { + const place = buildPlace({ id: 1, name: 'Group visit' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const participants = Array.from({ length: 7 }, (_, i) => ({ + user_id: i + 1, username: `user${i + 1}`, avatar: i === 0 ? 'a.jpg' : null, + })) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place, participants }) + render() + // Five chips at most: the first has an avatar image, the rest fall back to an initial. + expect(screen.getByText('+2')).toBeInTheDocument() + expect(screen.getAllByText('U')).toHaveLength(4) + expect(document.querySelectorAll('img[src*="a.jpg"]')).toHaveLength(1) + }) + + it('FE-PLANNER-DAYPLAN-165: the add-booking shortcut appears while the row is hovered', async () => { + const user = userEvent.setup() + const place = buildPlace({ id: 1, name: 'Hover place' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + const onAddBookingToAssignment = vi.fn() + render() + const row = dragRow(screen.getByText('Hover place')) + expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument() + fireEvent.mouseEnter(row) + await user.click(screen.getByTitle('Add booking')) + expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11) + fireEvent.mouseLeave(row) + expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => { + const user = userEvent.setup() + const place = buildPlace({ id: 1, name: 'Pin me' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + render() + const row = dragRow(screen.getByText('Pin me')) + const toggle = lockToggle(row) + fireEvent.mouseEnter(toggle) + expect(screen.getByText('Keep position during route optimization')).toBeInTheDocument() + await user.click(toggle) + expect(screen.getByText('Click to unlock')).toBeInTheDocument() + await user.click(toggle) + await waitFor(() => expect(row.style.borderLeftColor).toBe('transparent')) + fireEvent.mouseLeave(toggle) + expect(screen.queryByText('Click to unlock')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-167: the place context menu opens the website, maps and collection actions', async () => { + const user = userEvent.setup() + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const place = buildPlace({ id: 1, name: 'Louvre', website: 'https://louvre.fr', google_place_id: 'abc' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) + render() + fireEvent.contextMenu(dragRow(screen.getByText('Louvre'))) + await user.click(screen.getByRole('button', { name: 'Open Website' })) + expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank') + + fireEvent.contextMenu(dragRow(screen.getByText('Louvre'))) + await user.click(screen.getByRole('button', { name: 'Google Maps' })) + expect(openSpy).toHaveBeenCalledTimes(2) + expect(openSpy.mock.calls[1][0]).toContain('google.com/maps') + openSpy.mockRestore() + }) + + // ── Route tools ────────────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-168: the Google Maps export bookends the stops with the day hotel', async () => { + const user = userEvent.setup() + const { generateGoogleMapsUrl } = await import('../Map/RouteCalculator') + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const accommodations: Accommodation[] = [{ + id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12, + place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia', + }] + const assignments = { + '11': [ + buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }), + buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }), + ], + } + render() + await user.click(screen.getByRole('button', { name: 'Open in Google Maps' })) + expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([ + { lat: 48.85, lng: 2.35 }, + { lat: 48.86, lng: 2.34 }, + { lat: 48.87, lng: 2.33 }, + { lat: 48.85, lng: 2.35 }, + ]) + expect(openSpy).toHaveBeenCalledWith('https://maps.google.com/...', '_blank', 'noopener,noreferrer') + openSpy.mockRestore() + }) + + it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => { + const user = userEvent.setup() + const { daysApi } = await import('../../api/client') + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + const onSetRouteProfile = vi.fn() + seedStore(useTripStore, { days: [day] }) + render() + await user.click(screen.getByRole('button', { name: 'Walking' })) + expect(onSetRouteProfile).toHaveBeenCalledWith('walking') + expect(vi.mocked(daysApi.updateTransport)).toHaveBeenCalledWith(1, 10, 'walking') + expect(useTripStore.getState().days[0].default_transport_mode).toBe('walking') + }) + + it('FE-PLANNER-DAYPLAN-170: a failing day-mode save is reported and the days are refetched', async () => { + const user = userEvent.setup() + const { daysApi } = await import('../../api/client') + vi.mocked(daysApi.updateTransport).mockRejectedValueOnce(new Error('offline')) + const refreshDays = vi.fn(async () => undefined) + stubTripActions({ refreshDays }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + render() + await user.click(screen.getByRole('button', { name: 'Walking' })) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('offline')) + expect(refreshDays).toHaveBeenCalledWith(1) + }) + + it('FE-PLANNER-DAYPLAN-171: a route connector opens the per-segment travel-mode menu', async () => { + const user = userEvent.setup() + const { assignmentsApi } = await import('../../api/client') + const setAssignments = vi.fn() + stubTripActions({ setAssignments }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + render() + const connector = await screen.findByTitle('Change travel mode') + await user.click(connector) + await user.click(contextMenu().getByRole('button', { name: 'Walking' })) + expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenCalledWith(1, 11, 'walking') + expect(setAssignments).toHaveBeenCalledWith({ + '10': [expect.objectContaining({ id: 11, leg_transport_mode: 'walking' }), expect.objectContaining({ id: 12 })], + }) + + await user.click(await screen.findByTitle('Change travel mode')) + await user.click(contextMenu().getByRole('button', { name: 'Use day default' })) + expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null) + }) + + it('FE-PLANNER-DAYPLAN-172: a failing per-segment save is reported and the days are refetched', async () => { + const user = userEvent.setup() + const { assignmentsApi } = await import('../../api/client') + vi.mocked(assignmentsApi.updateTransport).mockRejectedValueOnce(new Error('nope')) + const refreshDays = vi.fn(async () => undefined) + stubTripActions({ refreshDays }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + render() + await user.click(await screen.findByTitle('Change travel mode')) + await user.click(contextMenu().getByRole('button', { name: 'Driving' })) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('nope')) + expect(refreshDays).toHaveBeenCalledWith(1) + }) + + it('FE-PLANNER-DAYPLAN-173: a route-provider plugin adds its profile to the day and segment pickers', async () => { + const user = userEvent.setup() + usePluginStore.setState({ + plugins: [{ id: 'ev', name: 'EV Router', routeProfiles: [{ id: 'eco', label: 'EV eco' }] }], + } as never) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + const onSetRouteProfile = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'EV eco' })) + expect(onSetRouteProfile).toHaveBeenCalledWith('plugin:ev/eco') + await user.click(await screen.findByTitle('Change travel mode')) + expect(contextMenu().getByRole('button', { name: 'EV eco' })).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-174: the Route toggle of the selected day flips the shared route state', async () => { + const user = userEvent.setup() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + const onToggleRoute = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'Route' })) + expect(onToggleRoute).toHaveBeenCalledTimes(1) + }) + + // ── Plugin day schedule ────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-175: plugin schedule rows appear at the day edges, per stop and per booking', async () => { + server.use(http.get('/api/day-schedule/1', () => HttpResponse.json({ + items: [ + { pluginId: 'ev', id: 's1', dayId: 10, position: 'start', minutes: 15, label: 'Warm up', tone: 'default' }, + { pluginId: 'ev', id: 'e1', dayId: 10, position: 'end', minutes: 10, label: 'Wind down', tone: 'success' }, + { pluginId: 'ev', id: 'a1', dayId: 10, assignmentId: 11, minutes: 35, label: 'Charging', tone: 'warn' }, + { pluginId: 'ev', id: 'r1', dayId: 10, reservationId: 500, minutes: 45, label: 'Security', tone: 'danger' }, + ], + }))) + const { day, placeA, placeB, bus, assignments } = dayWithBusAndPlaces() + render() + expect(await screen.findByText('Warm up')).toBeInTheDocument() + expect(screen.getByText('Wind down')).toBeInTheDocument() + expect(screen.getByText('Charging')).toBeInTheDocument() + expect(screen.getByText('Security')).toBeInTheDocument() + // 105 contributed minutes roll up into the route footer. + expect(screen.getByText('+1 h 45 min')).toBeInTheDocument() + }) + + // ── Day header details ─────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-176: a transfer day lists the check-out hotel before the check-in one', async () => { + const user = userEvent.setup() + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const accommodations: Accommodation[] = [ + { id: 1, trip_id: 1, start_day_id: 11, end_day_id: 12, place_id: 9, place_lat: 51.5, place_lng: -0.12, place_name: 'Check-in Hotel' }, + { id: 2, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 8, place_lat: 48.85, place_lng: 2.35, place_name: 'Check-out Hotel' }, + ] + const onPlaceClick = vi.fn() + render() + const badges = dayHeader('Day 2').querySelectorAll('.bg-surface-hover') + expect(badges[0].textContent).toBe('Check-out Hotel') + expect(badges[1].textContent).toBe('Check-in Hotel') + await user.click(badges[0]) + expect(onPlaceClick).toHaveBeenCalledWith(8) + }) + + it('FE-PLANNER-DAYPLAN-177: clicking an active rental badge opens its booking detail', async () => { + const user = userEvent.setup() + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const car = buildReservation({ id: 530, type: 'car', title: 'Renault Clio', day_id: 10, end_day_id: 12, location: 'Gare du Nord' }) + render() + const badge = dayHeader('Day 2').querySelector('.bg-surface-hover') as HTMLElement + await user.click(badge) + expect(await screen.findByText('Gare du Nord')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-178: the add-transport shortcut targets the day it sits on', async () => { + const user = userEvent.setup() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const onAddTransport = vi.fn() + render() + await user.click(screen.getByTitle('Add transport')) + expect(onAddTransport).toHaveBeenCalledWith(10) + }) + + it('FE-PLANNER-DAYPLAN-179: hovering a day header tints it and clears the tint again', () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + render() + const header = dayHeader('Day 1') + fireEvent.mouseEnter(header) + expect(header.style.background).toBe('var(--bg-tertiary)') + fireEvent.mouseLeave(header) + expect(header.style.background).toBe('transparent') + }) + + // ── Chronology guards ──────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-180: the chronology check spans notes and bookings, not just stops', async () => { + const user = userEvent.setup() + const { assignmentsApi } = await import('../../api/client') + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const placeA = buildPlace({ id: 1, name: 'Morning Place', place_time: '08:00' }) + const placeB = buildPlace({ id: 2, name: 'Afternoon Place', place_time: '14:00' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB }), + ], + } + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: 0.5 })] } + const bus = buildReservation({ id: 540, type: 'bus', title: 'Midday bus', day_id: 10, reservation_time: '2025-06-01T12:00:00' }) + const onReorder = vi.fn(async () => undefined) + render() + fireEvent.dragStart(dragRow(screen.getByText('Afternoon Place')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dragRow(screen.getByText('Morning Place')), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(screen.getByText('Remove time?')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(vi.mocked(assignmentsApi.updateTime)).toHaveBeenCalledWith(1, 12, { place_time: null, end_time: null })) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11])) + // The booking keeps its slot in the rebuilt order. + expect(screen.getByText('Midday bus')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-181: an arrow reorder across a booking drops the time and re-slots the booking', async () => { + const user = userEvent.setup() + const { assignmentsApi, reservationsApi } = await import('../../api/client') + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const placeA = buildPlace({ id: 1, name: 'Morning Place', place_time: '08:00' }) + const placeB = buildPlace({ id: 2, name: 'Evening Place', place_time: '18:00' }) + const assignments = { + '10': [ + buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }), + buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB }), + ], + } + const bus = buildReservation({ id: 541, type: 'bus', title: 'Midday bus', day_id: 10, reservation_time: '2025-06-01T12:00:00' }) + seedStore(useTripStore, { reservations: [bus] }) + const onReorder = vi.fn(async () => undefined) + render() + const upBtn = dragRow(screen.getByText('Evening Place')).querySelectorAll('.reorder-buttons button')[0] + await user.click(upBtn) + await waitFor(() => expect(screen.getByText('Remove time?')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: /confirm/i })) + await waitFor(() => expect(vi.mocked(assignmentsApi.updateTime)).toHaveBeenCalledWith(1, 12, { place_time: null, end_time: null })) + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [11, 12])) + // The non-place rows keep their relative slot while the places are rewritten. + await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalledWith( + 1, [{ id: 541, day_plan_position: expect.any(Number) }], 10, + )) + }) + + // ── Undo hooks ─────────────────────────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-182: optimising registers an undo that restores the previous order', async () => { + const user = userEvent.setup() + const reorderAssignments = vi.fn(async () => undefined) + stubTripActions({ reorderAssignments }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignments = { + '10': [1, 2, 3].map((n, i) => buildAssignment({ + id: 10 + n, day_id: 10, order_index: i, + place: buildPlace({ id: n, name: `P${n}`, lat: 48.8 + n / 100, lng: 2.3 + n / 100 }), + })), + } + const pushUndo = vi.fn() + const onReorder = vi.fn(async () => undefined) + render() + await user.click(screen.getByRole('button', { name: 'Optimize' })) + await waitFor(() => expect(onReorder).toHaveBeenCalled()) + const undo = pushUndo.mock.calls[0][1] as () => Promise + await undo() + expect(reorderAssignments).toHaveBeenCalledWith(1, 10, [11, 12, 13]) + }) + + it('FE-PLANNER-DAYPLAN-183: moving a stop to another day registers an undo that moves it back', async () => { + const moveAssignment = vi.fn(async () => undefined) + stubTripActions({ moveAssignment }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 3, place }) + seedStore(useTripStore, { assignments: { '10': [assignment] } }) + const pushUndo = vi.fn() + render() + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(pushUndo).toHaveBeenCalled()) + const undo = pushUndo.mock.calls[0][1] as () => Promise + await undo() + expect(moveAssignment).toHaveBeenLastCalledWith(1, 11, 11, 10, 3) + }) + + it('FE-PLANNER-DAYPLAN-184: failed cross-day moves from the day header are reported', async () => { + stubTripActions({ + moveAssignment: vi.fn(async () => { throw new Error('move failed') }), + moveDayNote: vi.fn(async () => { throw new Error('note failed') }), + updateReservation: vi.fn(async () => { throw new Error('booking failed') }), + }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const place = buildPlace({ id: 1, name: 'Place A' }) + const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] } + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Airport taxi', day_id: 10 }) + render() + + fireEvent.dragStart(dragRow(screen.getByText('Place A')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('move failed')) + + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('note failed')) + + fireEvent.dragStart(cardRow(screen.getByText('Airport taxi')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(dayHeader('Day 2'), { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('booking failed')) + }) + + it('FE-PLANNER-DAYPLAN-185: every cross-day drop surface reports a failed move', async () => { + stubTripActions({ + moveAssignment: vi.fn(async () => { throw new Error('place failed') }), + moveDayNote: vi.fn(async () => { throw new Error('note failed') }), + updateReservation: vi.fn(async () => { throw new Error('booking failed') }), + }) + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + ] + const src = buildPlace({ id: 1, name: 'Source place' }) + const tgt = buildPlace({ id: 2, name: 'Target place' }) + const assignments = { + '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place: src })], + '11': [buildAssignment({ id: 12, day_id: 11, order_index: 0, place: tgt })], + } + mockDayNotesState.dayNotes = { + '10': [buildDayNote({ id: 70, day_id: 10, text: 'Source note' })], + '11': [buildDayNote({ id: 71, day_id: 11, text: 'Target note' })], + } + const taxi = buildReservation({ id: 501, type: 'taxi', title: 'Source taxi', day_id: 10 }) + const targetBus = buildReservation({ id: 502, type: 'bus', title: 'Target bus', day_id: 11 }) + render() + + const sources = [ + () => dragRow(screen.getByText('Source place')), + () => cardRow(screen.getByText('Source note')), + () => cardRow(screen.getByText('Source taxi')), + ] + const targets = [ + () => document.querySelectorAll('[style*="padding-top: 6px"]')[1] as HTMLElement, + () => endZones()[1] as HTMLElement, + () => cardRow(screen.getByText('Target note')), + () => dragRow(screen.getByText('Target place')), + () => cardRow(screen.getByText('Target bus')), + ] + for (const target of targets) { + for (const source of sources) { + fireEvent.dragStart(source(), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(target(), { dataTransfer: { getData: vi.fn(() => '') } }) + } + } + // The day body also routes drops through whichever row was hovered last. + for (const source of sources) { + fireEvent.dragStart(source(), { dataTransfer: emptyDataTransfer }) + fireEvent.dragOver(cardRow(screen.getByText('Target bus')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(targets[0](), { dataTransfer: { getData: vi.fn(() => '') } }) + } + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('place failed')) + expect(mockToast.error).toHaveBeenCalledWith('note failed') + expect(mockToast.error).toHaveBeenCalledWith('booking failed') + }) + + it('FE-PLANNER-DAYPLAN-186: a transit row without a journey view falls back to the booking detail', async () => { + const user = userEvent.setup() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const transit = buildReservation({ + id: 550, type: 'transit', title: 'U2 to Zoo', day_id: 10, location: 'Alexanderplatz', + metadata: JSON.stringify({ + transit: { duration: 900, transfers: 0, walk_seconds: 60, legs: [{ mode: 'WALK', duration: 300, from: { name: 'A' }, to: { name: 'B' } }] }, + }), + }) + render() + const expand = screen.getByLabelText('Expand') + fireEvent.mouseEnter(expand) + fireEvent.mouseLeave(expand) + await user.click(expand) + expect(screen.getByLabelText('Collapse')).toBeInTheDocument() + await user.click(cardRow(screen.getByText('U2 to Zoo'))) + expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-187: a same-day note dropped on the end zone moves after the last stop', async () => { + const updateDayNote = vi.fn(async () => buildDayNote({ id: 70 })) + stubTripActions({ updateDayNote }) + const { day, placeA, placeB, assignments } = dayWithBusAndPlaces() + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note', sort_order: -1 })] } + render( undefined), + })} />) + fireEvent.dragStart(cardRow(screen.getByText('A note')), { dataTransfer: emptyDataTransfer }) + fireEvent.drop(endZones()[0], { dataTransfer: { getData: vi.fn(() => '') } }) + await waitFor(() => expect(updateDayNote).toHaveBeenCalledWith(1, 10, 70, { sort_order: expect.any(Number) })) + }) + + it('FE-PLANNER-DAYPLAN-188: on a touch device note rows lose their grip and cannot be dragged', () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] } + render() + const row = cardRow(screen.getByText('A note')) + expect(row.getAttribute('draggable')).toBe('false') + expect(row.querySelector('.dp-grip')).toBeNull() + fireEvent.dragStart(row, { dataTransfer: emptyDataTransfer }) + expect(row.style.opacity).toBe('1') + fireEvent.dragEnd(row) + expect(row.style.opacity).toBe('1') + }) }) diff --git a/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.test.tsx b/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.test.tsx new file mode 100644 index 000000000..12028c998 --- /dev/null +++ b/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.test.tsx @@ -0,0 +1,111 @@ +// FE-PLANNER-DPADDPLACE-001 to FE-PLANNER-DPADDPLACE-009 +import { render, screen } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { buildAssignment, buildPlace } from '../../../tests/helpers/factories' +import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' +import type { AssignmentsMap, Place } from '../../types' + +const louvre = buildPlace({ id: 11, name: 'Louvre' }) +const eiffel = buildPlace({ id: 12, name: 'Eiffel Tower' }) + +function makeProps(overrides: Partial> = {}) { + return { + dayId: 5, + places: [louvre, eiffel] as Place[], + assignments: {} as AssignmentsMap, + onAssign: vi.fn(), + onAddNew: vi.fn(), + ...overrides, + } +} + +async function openPicker(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Add Place' })) +} + +describe('MobileAddPlaceButton', () => { + it('FE-PLANNER-DPADDPLACE-001: renders only the collapsed trigger initially', () => { + render() + expect(screen.getByRole('button', { name: 'Add Place' })).toBeInTheDocument() + expect(screen.queryByPlaceholderText('Search places...')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-002: opening lists every place not yet on the day', async () => { + const user = userEvent.setup() + render() + await openPicker(user) + expect(screen.getByPlaceholderText('Search places...')).toBeInTheDocument() + expect(screen.getByText('Louvre')).toBeInTheDocument() + expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-003: places already assigned to this day are filtered out', async () => { + const user = userEvent.setup() + const assignments = { '5': [buildAssignment({ id: 1, day_id: 5, place_id: 11, place: louvre })] } as AssignmentsMap + render() + await openPicker(user) + expect(screen.queryByText('Louvre')).not.toBeInTheDocument() + expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-004: the search box narrows the list case-insensitively', async () => { + const user = userEvent.setup() + render() + await openPicker(user) + await user.type(screen.getByPlaceholderText('Search places...'), 'eIFf') + expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() + expect(screen.queryByText('Louvre')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-005: a search with no hits shows the no-match hint', async () => { + const user = userEvent.setup() + render() + await openPicker(user) + await user.type(screen.getByPlaceholderText('Search places...'), 'zzz') + expect(screen.getByText('No match')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-006: with everything already assigned it shows the all-assigned hint', async () => { + const user = userEvent.setup() + const assignments = { + '5': [ + buildAssignment({ id: 1, day_id: 5, place_id: 11, place: louvre }), + buildAssignment({ id: 2, day_id: 5, place_id: 12, place: eiffel }), + ], + } as AssignmentsMap + render() + await openPicker(user) + expect(screen.getByText('All places assigned')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-007: picking a place assigns it to this day and closes the picker', async () => { + const user = userEvent.setup() + const onAssign = vi.fn() + render() + await openPicker(user) + await user.click(screen.getByText('Eiffel Tower')) + expect(onAssign).toHaveBeenCalledWith(12, 5) + expect(screen.getByRole('button', { name: 'Add Place' })).toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-008: "Create new place" calls onAddNew and closes the picker', async () => { + const user = userEvent.setup() + const onAddNew = vi.fn() + render() + await openPicker(user) + await user.click(screen.getByRole('button', { name: 'Create new place' })) + expect(onAddNew).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'Add Place' })).toBeInTheDocument() + }) + + it('FE-PLANNER-DPADDPLACE-009: without onAddNew there is no create row, and the X closes the picker', async () => { + const user = userEvent.setup() + render() + await openPicker(user) + expect(screen.queryByRole('button', { name: 'Create new place' })).not.toBeInTheDocument() + // The unlabelled button next to the search field is the close X. + const closeBtn = screen.getByPlaceholderText('Search places...').parentElement!.querySelector('button')! + await user.click(closeBtn) + expect(screen.getByRole('button', { name: 'Add Place' })).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx new file mode 100644 index 000000000..b0a3ec181 --- /dev/null +++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx @@ -0,0 +1,357 @@ +// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022 +import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { downloadTripPDF } from '../PDF/TripPDF' +import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories' +import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' +import type { Reservation } from '../../types' + +vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) })) + +// The subscribe dialog fetches its feed token on mount; it is exercised in its +// own test, here we only care that opening the menu entry mounts it. +vi.mock('./IcsSubscribeModal', () => ({ + IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => ( +
+ {title} + +
+ ), +})) + +const t = (key: string, params?: Record) => + params ? `${key}|${Object.values(params).join('|')}` : key + +const trip = buildTrip({ id: 1, title: 'Roadtrip' }) + +function makeToast() { + return { + success: vi.fn((_m: string) => {}), + error: vi.fn((_m: string) => {}), + warning: vi.fn((_m: string) => {}), + info: vi.fn((_m: string) => {}), + } +} + +function makeProps(overrides: Partial> = {}) { + return { + tripId: 1, + trip, + days: [], + places: [], + categories: [], + assignments: {}, + reservations: [] as Reservation[], + dayNotes: {}, + t, + locale: 'en-US', + toast: makeToast(), + pdfHover: false, + setPdfHover: vi.fn((_v: boolean) => {}), + icsHover: false, + setIcsHover: vi.fn((_v: boolean) => {}), + expandedDays: new Set(), + setExpandedDays: vi.fn((_v: Set) => {}), + canUndo: false, + undoHover: false, + setUndoHover: vi.fn((_v: boolean) => {}), + lastActionLabel: null, + ...overrides, + } +} + +const routableReservation = () => buildReservation({ + id: 5, type: 'flight', title: 'BER → CDG', + endpoints: [ + { role: 'from', sequence: 0, name: 'BER', code: null, lat: 52.3, lng: 13.5, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'CDG', code: null, lat: 49.0, lng: 2.5, timezone: null, local_date: null, local_time: null }, + ], +} as Partial) + +beforeEach(() => { + vi.clearAllMocks() + sessionStorage.clear() +}) + +describe('DayPlanSidebarToolbar', () => { + // ── PDF export ──────────────────────────────────────────────────────────── + + it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1' })] + const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] } + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1)) + expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ + trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })], + }) + }) + + it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => { + const user = userEvent.setup() + vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing')) + const toast = makeToast() + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing')) + }) + + it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button reports the hover state upward', async () => { + const user = userEvent.setup() + const setPdfHover = vi.fn((_v: boolean) => {}) + render() + await user.hover(screen.getByText('dayplan.pdf')) + expect(setPdfHover).toHaveBeenCalledWith(true) + await user.unhover(screen.getByText('dayplan.pdf')) + expect(setPdfHover).toHaveBeenCalledWith(false) + }) + + it('FE-PLANNER-DPTOOLBAR-004: pdfHover renders the export tooltip', () => { + const { rerender } = render() + expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument() + rerender() + expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument() + }) + + // ── ICS menu ────────────────────────────────────────────────────────────── + + it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => { + const user = userEvent.setup() + const setIcsHover = vi.fn((_v: boolean) => {}) + render() + expect(screen.queryByText('Download ICS')).not.toBeInTheDocument() + await user.hover(screen.getByText('ICS')) + expect(screen.getByText('Download ICS')).toBeInTheDocument() + expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument() + expect(setIcsHover).toHaveBeenCalledWith(true) + }) + + it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => { + const user = userEvent.setup() + const setIcsHover = vi.fn((_v: boolean) => {}) + render() + const icsButton = screen.getByText('ICS') + await user.hover(icsButton) + expect(screen.getByText('Download ICS')).toBeInTheDocument() + await user.unhover(icsButton) + await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()) + expect(setIcsHover).toHaveBeenLastCalledWith(false) + }) + + it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => { + const user = userEvent.setup() + render() + const icsButton = screen.getByText('ICS') + await user.hover(icsButton) + await user.unhover(icsButton) + await user.hover(icsButton) + // The close is scheduled 120ms out; coming back in has to cancel it. + await new Promise(r => setTimeout(r, 200)) + expect(screen.getByText('Download ICS')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', async () => { + const user = userEvent.setup() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), + } as unknown as Response) + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + render() + await user.hover(screen.getByText('ICS')) + await user.click(screen.getByText('Download ICS')) + await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' })) + await waitFor(() => expect(clickSpy).toHaveBeenCalled()) + expect(revokeObjURL).toHaveBeenCalledWith('blob:mock') + // The menu closes itself once the download starts. + expect(screen.queryByText('Download ICS')).not.toBeInTheDocument() + fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore() + }) + + it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => { + const user = userEvent.setup() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response) + const toast = makeToast() + render() + await user.hover(screen.getByText('ICS')) + await user.click(screen.getByText('Download ICS')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed')) + fetchSpy.mockRestore() + }) + + it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => { + const user = userEvent.setup() + render() + await user.hover(screen.getByText('ICS')) + await user.click(screen.getByText('Subscribe to calendar')) + expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument() + await user.click(screen.getByText('close-subscribe')) + expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument() + }) + + // ── Expand / collapse all ──────────────────────────────────────────────── + + it('FE-PLANNER-DPTOOLBAR-011: with days collapsed the button offers "expand all"', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10 }), buildDay({ id: 11 })] + const setExpandedDays = vi.fn((_v: Set) => {}) + render() + const btn = screen.getByRole('button', { name: 'dayplan.expandAll' }) + expect(btn).toHaveAttribute('aria-pressed', 'false') + await user.click(btn) + expect(setExpandedDays).toHaveBeenCalledWith(new Set([10, 11])) + expect(JSON.parse(sessionStorage.getItem('day-expanded-1')!)).toEqual([10, 11]) + }) + + it('FE-PLANNER-DPTOOLBAR-012: with every day expanded the button collapses them all', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10 }), buildDay({ id: 11 })] + const setExpandedDays = vi.fn((_v: Set) => {}) + render() + const btn = screen.getByRole('button', { name: 'dayplan.collapseAll' }) + expect(btn).toHaveAttribute('aria-pressed', 'true') + await user.click(btn) + expect(setExpandedDays).toHaveBeenCalledWith(new Set()) + expect(JSON.parse(sessionStorage.getItem('day-expanded-1')!)).toEqual([]) + }) + + it('FE-PLANNER-DPTOOLBAR-013: a trip with no days is never treated as fully expanded', () => { + render() + expect(screen.getByRole('button', { name: 'dayplan.expandAll' })).toBeInTheDocument() + }) + + // ── Undo ───────────────────────────────────────────────────────────────── + + it('FE-PLANNER-DPTOOLBAR-014: no undo control without an onUndo handler', () => { + render() + expect(screen.queryByRole('button', { name: 'undo.button' })).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPTOOLBAR-015: the undo button stays disabled while there is nothing to undo', async () => { + const user = userEvent.setup() + const onUndo = vi.fn() + render() + const btn = screen.getByRole('button', { name: 'undo.button' }) + expect(btn).toBeDisabled() + await user.click(btn) + expect(onUndo).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DPTOOLBAR-016: clicking undo fires the handler and hovering reports the hover state', async () => { + const user = userEvent.setup() + const onUndo = vi.fn() + const setUndoHover = vi.fn((_v: boolean) => {}) + render() + const btn = screen.getByRole('button', { name: 'undo.button' }) + await user.hover(btn) + expect(setUndoHover).toHaveBeenCalledWith(true) + await user.click(btn) + expect(onUndo).toHaveBeenCalledTimes(1) + await user.unhover(btn) + expect(setUndoHover).toHaveBeenCalledWith(false) + }) + + it('FE-PLANNER-DPTOOLBAR-017: the undo tooltip names the last action when there is one', () => { + render() + expect(screen.getByText('undo.tooltip|Reorder')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTOOLBAR-018: the undo tooltip falls back to the plain label with nothing to undo', () => { + render() + // Both the aria-label and the tooltip carry the same text here. + expect(screen.getAllByText('undo.button').length + screen.getAllByLabelText('undo.button').length).toBeGreaterThan(1) + expect(screen.queryByText('undo.tooltip|Reorder')).not.toBeInTheDocument() + }) + + // ── Reorder days ───────────────────────────────────────────────────────── + + it('FE-PLANNER-DPTOOLBAR-019: the reorder button toggles the day-reorder popup', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })] + render() + const btn = screen.getByRole('button', { name: 'dayplan.reorderDays' }) + expect(btn).toHaveAttribute('aria-pressed', 'false') + await user.click(btn) + expect(screen.getByText('dayplan.reorderHint')).toBeInTheDocument() + await user.click(screen.getByText('common.close')) + expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPTOOLBAR-020: the popup passes its add-day action straight through', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })] + const onAddDay = vi.fn((_p?: number) => {}) + render() + await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' })) + await user.click(screen.getByText('dayplan.addDay')) + expect(onAddDay).toHaveBeenCalledWith() + }) + + it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => { + const days = [buildDay({ id: 10 })] + const { rerender } = render() + expect(screen.queryByRole('button', { name: 'dayplan.reorderDays' })).not.toBeInTheDocument() + rerender() + expect(screen.queryByRole('button', { name: 'dayplan.reorderDays' })).not.toBeInTheDocument() + }) + + // ── Show/hide all booking routes ───────────────────────────────────────── + + it('FE-PLANNER-DPTOOLBAR-022: the connections toggle appears only for a routable reservation', async () => { + const user = userEvent.setup() + const onToggleAllConnections = vi.fn() + const { rerender } = render() + expect(screen.queryByRole('button', { name: 'map.showAllConnections' })).not.toBeInTheDocument() + + rerender() + const show = screen.getByRole('button', { name: 'map.showAllConnections' }) + expect(show).toHaveAttribute('aria-pressed', 'false') + await user.click(show) + expect(onToggleAllConnections).toHaveBeenCalledTimes(1) + + rerender() + expect(screen.getByRole('button', { name: 'map.hideAllConnections' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('FE-PLANNER-DPTOOLBAR-023: the icon buttons paint a hover background and clear it again', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10 })] + render() + + const expandBtn = screen.getByRole('button', { name: 'dayplan.expandAll' }) + await user.hover(expandBtn) + expect(expandBtn.style.background).toBe('var(--bg-hover)') + await user.unhover(expandBtn) + expect(expandBtn.style.background).toBe('transparent') + + const routeBtn = screen.getByRole('button', { name: 'map.showAllConnections' }) + await user.hover(routeBtn) + expect(routeBtn.style.background).toBe('var(--bg-hover)') + await user.unhover(routeBtn) + expect(routeBtn.style.background).toBe('transparent') + + await user.hover(screen.getByText('ICS')) + const download = screen.getByText('Download ICS') + const subscribe = screen.getByText('Subscribe to calendar') + await user.hover(download) + expect(download.style.background).toContain('var(--bg-hover') + // Sliding down to the next entry has to clear the one we left. + await user.hover(subscribe) + expect(download.style.background).toBe('transparent') + expect(subscribe.style.background).toContain('var(--bg-hover') + await user.unhover(subscribe) + expect(subscribe.style.background).toBe('transparent') + }) + + it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => { + const user = userEvent.setup() + vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom') + const toast = makeToast() + render() + await user.click(screen.getByText('dayplan.pdf')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom')) + }) +}) diff --git a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx new file mode 100644 index 000000000..3b1b319b6 --- /dev/null +++ b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx @@ -0,0 +1,248 @@ +// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-018 +import { render, screen, fireEvent } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useSettingsStore } from '../../store/settingsStore' +import { useTripStore } from '../../store/tripStore' +import { buildReservation, buildTripFile } from '../../../tests/helpers/factories' +import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' +import type { Reservation } from '../../types' + +// `t` arrives as a prop here, so echoing the key (plus its params) keeps the +// assertions independent of the translation catalogue. +const t = (key: string, params?: Record) => + params ? `${key}|${Object.values(params).join('|')}` : key + +function makeProps(overrides: Partial> = {}) { + return { + transportDetail: null as Reservation | null, + setTransportDetail: vi.fn(), + onNavigateToFiles: vi.fn(), + onEdit: vi.fn(), + t, + locale: 'en-US', + timeFormat: '24h', + ...overrides, + } +} + +const flight = () => buildReservation({ + id: 41, + type: 'flight', + title: 'BER → CDG', + status: 'confirmed', + reservation_time: '2025-06-15T08:30:00', + reservation_end_time: '2025-06-15T10:05:00', + confirmation_number: 'XY7Z9Q', + location: 'Terminal 1', + metadata: JSON.stringify({ + airline: 'Air France', flight_number: 'AF1235', + departure_airport: 'BER', arrival_airport: 'CDG', seat: '14A', + }), +} as Partial) + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false } }) +}) + +describe('DayPlanSidebarTransportDetailModal', () => { + it('FE-PLANNER-DPTRANSPORT-001: renders nothing without a reservation', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + expect(document.body.textContent).toBe('') + }) + + it('FE-PLANNER-DPTRANSPORT-002: shows the title, date and time range in the header', () => { + render() + expect(screen.getByText('BER → CDG')).toBeInTheDocument() + expect(screen.getByText(/Jun 15.*08:30 – 10:05/)).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-003: a confirmed booking shows the confirmed badge', () => { + render() + expect(screen.getByText('planner.resConfirmed')).toBeInTheDocument() + expect(screen.queryByText('planner.resPending')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-004: a pending booking shows the pending badge', () => { + const res = { ...flight(), status: 'pending' } as Reservation + render() + expect(screen.getByText('planner.resPending')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-005: flight metadata renders airline, number, route and seat', () => { + render() + expect(screen.getByText('Air France')).toBeInTheDocument() + expect(screen.getByText('AF1235')).toBeInTheDocument() + expect(screen.getByText('BER')).toBeInTheDocument() + expect(screen.getByText('CDG')).toBeInTheDocument() + expect(screen.getByText('14A')).toBeInTheDocument() + expect(screen.getByText('XY7Z9Q')).toBeInTheDocument() + expect(screen.getByText('Terminal 1')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-006: train metadata renders number, platform and seat', () => { + const res = buildReservation({ + id: 42, type: 'train', title: 'ICE 599', status: 'confirmed', + metadata: { train_number: 'ICE 599', platform: '7', seat: '31' }, + } as unknown as Partial) + render() + expect(screen.getByText('reservations.meta.trainNumber')).toBeInTheDocument() + expect(screen.getByText('7')).toBeInTheDocument() + expect(screen.getByText('31')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-007: a booking with no metadata and no codes renders no detail grid', () => { + const res = buildReservation({ id: 43, type: 'other', title: 'Something', metadata: null } as Partial) + render() + expect(screen.getByText('Something')).toBeInTheDocument() + expect(screen.queryByText('reservations.confirmationCode')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-008: the confirmation code is blurred when the setting is on', () => { + seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } }) + render() + const code = screen.getByText('XY7Z9Q') + expect(code).toHaveStyle({ filter: 'blur(5px)' }) + // The location is not sensitive, so it stays readable. + expect(screen.getByText('Terminal 1')).toHaveStyle({ filter: 'none' }) + }) + + it('FE-PLANNER-DPTRANSPORT-009: hovering and clicking a blurred code reveals and re-hides it', () => { + seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } }) + render() + const code = screen.getByText('XY7Z9Q') + fireEvent.mouseEnter(code) + expect(code.style.filter).toBe('none') + fireEvent.mouseLeave(code) + expect(code.style.filter).toBe('blur(5px)') + fireEvent.click(code) + expect(code.style.filter).toBe('none') + fireEvent.click(code) + expect(code.style.filter).toBe('blur(5px)') + }) + + it('FE-PLANNER-DPTRANSPORT-010: an unblurred field ignores hover and click', () => { + render() + const code = screen.getByText('XY7Z9Q') + fireEvent.mouseEnter(code) + fireEvent.click(code) + expect(code.style.filter).toBe('none') + }) + + it('FE-PLANNER-DPTRANSPORT-011: a transit journey renders its summary and itinerary legs', () => { + const res = buildReservation({ + id: 44, type: 'transit', title: 'Alexanderplatz → Zoo', status: 'confirmed', + metadata: { + transit: { + duration: 1800, transfers: 1, walk_seconds: 240, + legs: [ + { mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } }, + { + mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', + headsign: 'Ruhleben', duration: 1440, stops: 6, + from: { name: 'Alexanderplatz', time: '08:36' }, to: { name: 'Zoo', time: '09:00' }, + }, + ], + }, + }, + } as unknown as Partial) + render() + expect(screen.getByText('transit.min|30')).toBeInTheDocument() + expect(screen.getByText('transit.transfers|1')).toBeInTheDocument() + expect(screen.getByText('transit.itinerary')).toBeInTheDocument() + expect(screen.getByText('transit.walkTo|Alexanderplatz')).toBeInTheDocument() + expect(screen.getByText('U2')).toBeInTheDocument() + expect(screen.getByText('Zoo')).toBeInTheDocument() + expect(screen.getByText(/08:36 – 09:00 · transit\.min\|24 · transit\.stops\|6 · → Ruhleben/)).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-012: a direct, long, walk-free journey renders hours and the direct label', () => { + const res = buildReservation({ + id: 45, type: 'transit', title: 'Long haul', status: 'confirmed', + metadata: { + transit: { + duration: 7200, transfers: 0, walk_seconds: 30, + legs: [{ mode: 'BUS', duration: 7200, from: { name: 'A' }, to: { name: 'B' } }], + }, + }, + } as unknown as Partial) + render() + expect(screen.getByText('2 h 0 min')).toBeInTheDocument() + expect(screen.getByText('transit.direct')).toBeInTheDocument() + // Under a minute of walking is not worth a chip. + expect(screen.queryByText('transit.min|1')).not.toBeInTheDocument() + // No line name on this leg, so the badge falls back to the mode. + expect(screen.getByText('BUS')).toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-013: notes render as markdown', () => { + const res = { ...flight(), notes: 'Bring **passport**' } as Reservation + render() + expect(screen.getByText('reservations.notes')).toBeInTheDocument() + expect(screen.getByText('passport').tagName).toBe('STRONG') + }) + + it('FE-PLANNER-DPTRANSPORT-014: linked trip files are listed, deleted and unrelated ones are not', () => { + seedStore(useTripStore, { + files: [ + buildTripFile({ id: 1, original_name: 'boarding.pdf', reservation_id: 41 }), + buildTripFile({ id: 2, original_name: 'linked.pdf', reservation_id: null, linked_reservation_ids: [41] }), + buildTripFile({ id: 3, original_name: 'trashed.pdf', reservation_id: 41, deleted_at: '2025-06-01' }), + buildTripFile({ id: 4, original_name: 'other.pdf', reservation_id: 99 }), + ], + }) + render() + expect(screen.getByText('boarding.pdf')).toBeInTheDocument() + expect(screen.getByText('linked.pdf')).toBeInTheDocument() + expect(screen.queryByText('trashed.pdf')).not.toBeInTheDocument() + expect(screen.queryByText('other.pdf')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DPTRANSPORT-015: clicking a file closes the modal and navigates to the files view', async () => { + const user = userEvent.setup() + seedStore(useTripStore, { files: [buildTripFile({ id: 1, original_name: 'boarding.pdf', reservation_id: 41 })] }) + const setTransportDetail = vi.fn() + const onNavigateToFiles = vi.fn() + render() + const row = screen.getByText('boarding.pdf').parentElement! + fireEvent.mouseEnter(row) + expect(row.style.background).toBe('var(--bg-hover)') + fireEvent.mouseLeave(row) + expect(row.style.background).toBe('var(--bg-tertiary)') + await user.click(row) + expect(setTransportDetail).toHaveBeenCalledWith(null) + expect(onNavigateToFiles).toHaveBeenCalledTimes(1) + }) + + it('FE-PLANNER-DPTRANSPORT-016: the edit action hands the reservation back to the caller', async () => { + const user = userEvent.setup() + const onEdit = vi.fn() + const res = flight() + render() + await user.click(screen.getByRole('button', { name: /common\.edit/ })) + expect(onEdit).toHaveBeenCalledWith(res) + }) + + it('FE-PLANNER-DPTRANSPORT-017: without onEdit only the close action is offered', async () => { + const user = userEvent.setup() + const setTransportDetail = vi.fn() + render() + expect(screen.queryByRole('button', { name: /common\.edit/ })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.close' })) + expect(setTransportDetail).toHaveBeenCalledWith(null) + }) + + it('FE-PLANNER-DPTRANSPORT-018: clicking the backdrop closes, clicking the card does not', async () => { + const user = userEvent.setup() + const setTransportDetail = vi.fn() + render() + const card = screen.getByText('BER → CDG').closest('div.bg-surface-card')! + await user.click(card) + expect(setTransportDetail).not.toHaveBeenCalled() + await user.click(card.parentElement!) + expect(setTransportDetail).toHaveBeenCalledWith(null) + }) +}) diff --git a/client/src/components/Planner/DayReorderPopup.test.tsx b/client/src/components/Planner/DayReorderPopup.test.tsx new file mode 100644 index 000000000..a1e0566e5 --- /dev/null +++ b/client/src/components/Planner/DayReorderPopup.test.tsx @@ -0,0 +1,140 @@ +// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012 +import { render, screen, fireEvent } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { buildDay } from '../../../tests/helpers/factories' +import { DayReorderPopup } from './DayReorderPopup' +import type { Day } from '../../types' + +// The component takes `t` as a prop, so returning the key keeps assertions exact. +const t = (key: string) => key + +function makeProps(overrides: Partial> = {}) { + return { + isOpen: true, + days: [] as Day[], + t, + locale: 'en-US', + onReorder: vi.fn(), + onAddDay: vi.fn(), + onClose: vi.fn(), + ...overrides, + } +} + +function rows() { + return Array.from(document.querySelectorAll('[draggable="true"]')) +} + +const threeDays = () => [ + buildDay({ id: 3, title: 'Paris', day_number: 1 }), + buildDay({ id: 7, title: 'Lyon', day_number: 2 }), + buildDay({ id: 9, title: 'Nice', day_number: 3 }), +] + +describe('DayReorderPopup', () => { + it('FE-PLANNER-DAYREORDER-001: renders nothing while closed', () => { + render() + expect(screen.queryByText('Paris')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-DAYREORDER-002: renders one row per day with its position number', () => { + render() + expect(rows()).toHaveLength(3) + expect(screen.getByText('Paris')).toBeInTheDocument() + expect(screen.getByText('Nice')).toBeInTheDocument() + expect(screen.getByText('dayplan.reorderHint')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYREORDER-003: sorts rows by day_number, not by array order', () => { + const days = [ + buildDay({ id: 1, title: 'Third', day_number: 3 }), + buildDay({ id: 2, title: 'First', day_number: 1 }), + buildDay({ id: 3, title: 'Second', day_number: 2 }), + ] + render() + const labels = rows().map(r => r.querySelectorAll('span')[1].textContent) + expect(labels).toEqual(['First', 'Second', 'Third']) + }) + + it('FE-PLANNER-DAYREORDER-004: falls back to the formatted date when a day has no title', () => { + const days = [buildDay({ id: 1, title: null, date: '2025-06-15', day_number: 1 })] + render() + expect(screen.getByText(/Jun 15|Sun/)).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYREORDER-005: falls back to the day-number label with neither title nor date', () => { + const days = [{ ...buildDay({ id: 1, title: null, day_number: 1 }), date: '' } as unknown as Day] + render() + expect(screen.getByText('dayplan.dayN')).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYREORDER-006: the down arrow moves a day one slot later', async () => { + const user = userEvent.setup() + const onReorder = vi.fn() + render() + await user.click(screen.getAllByLabelText('dayplan.moveDown')[0]) + expect(onReorder).toHaveBeenCalledWith([7, 3, 9]) + }) + + it('FE-PLANNER-DAYREORDER-007: the up arrow moves a day one slot earlier', async () => { + const user = userEvent.setup() + const onReorder = vi.fn() + render() + await user.click(screen.getAllByLabelText('dayplan.moveUp')[2]) + expect(onReorder).toHaveBeenCalledWith([3, 9, 7]) + }) + + it('FE-PLANNER-DAYREORDER-008: the first up arrow and the last down arrow are disabled', () => { + render() + const ups = screen.getAllByLabelText('dayplan.moveUp') + const downs = screen.getAllByLabelText('dayplan.moveDown') + expect(ups[0]).toBeDisabled() + expect(ups[2]).not.toBeDisabled() + expect(downs[2]).toBeDisabled() + expect(downs[0]).not.toBeDisabled() + }) + + it('FE-PLANNER-DAYREORDER-009: dropping a dragged row onto another reorders to that slot', () => { + const onReorder = vi.fn() + render() + const [first, , third] = rows() + fireEvent.dragStart(first) + fireEvent.dragOver(third) + fireEvent.drop(third) + expect(onReorder).toHaveBeenCalledWith([7, 9, 3]) + }) + + it('FE-PLANNER-DAYREORDER-010: dropping a row onto itself does not reorder', () => { + const onReorder = vi.fn() + render() + const [first] = rows() + fireEvent.dragStart(first) + fireEvent.dragOver(first) + fireEvent.drop(first) + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-011: dragEnd clears the drag highlight without reordering', () => { + const onReorder = vi.fn() + render() + const [first, second] = rows() + fireEvent.dragStart(first) + fireEvent.dragOver(second) + // The hovered row is highlighted while a drag is in flight. + expect(second.style.outline).toContain('dashed') + fireEvent.dragEnd(first) + expect(second.style.outline).toBe('none') + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-012: the footer buttons close the popup and add a day', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onAddDay = vi.fn() + render() + await user.click(screen.getByText('dayplan.addDay')) + expect(onAddDay).toHaveBeenCalled() + await user.click(screen.getByText('common.close')) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Planner/FileImportModal.test.tsx b/client/src/components/Planner/FileImportModal.test.tsx new file mode 100644 index 000000000..d77c603bb --- /dev/null +++ b/client/src/components/Planner/FileImportModal.test.tsx @@ -0,0 +1,243 @@ +// FE-PLANNER-FILEIMP-001 to FE-PLANNER-FILEIMP-018 +import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { useTripStore } from '../../store/tripStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildTrip } from '../../../tests/helpers/factories'; +import FileImportModal from './FileImportModal'; + +const toastCalls: Array<[string, string]> = []; +vi.mock('../shared/Toast', () => ({ + useToast: () => ({ + success: (m: string) => { toastCalls.push(['success', m]); return 0; }, + error: (m: string) => { toastCalls.push(['error', m]); return 0; }, + warning: (m: string) => { toastCalls.push(['warning', m]); return 0; }, + info: (m: string) => { toastCalls.push(['info', m]); return 0; }, + }), +})); + +const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 3 }; + +const gpx = (name = 'route.gpx', size = 100) => { + const f = new File([''], name, { type: 'application/gpx+xml' }); + Object.defineProperty(f, 'size', { value: size }); + return f; +}; +const kml = (name = 'places.kml') => new File([''], name, { type: 'application/vnd.google-earth.kml+xml' }); + +const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement; +const dropZone = () => screen.getByText(/Click to select a file or drag and drop here/).parentElement as HTMLElement; +const importBtn = () => screen.getByRole('button', { name: 'Import' }); + +beforeEach(() => { + toastCalls.length = 0; + resetAllStores(); + seedStore(useTripStore, { trip: buildTrip({ id: 3 }) }); + server.use( + http.get('/api/trips/3', () => HttpResponse.json({ trip: buildTrip({ id: 3 }), days: [], places: [], assignments: {} })), + ); +}); + +describe('FileImportModal', () => { + it('FE-PLANNER-FILEIMP-001: renders nothing while closed', () => { + render(); + expect(screen.queryByText('Import file')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-002: shows title, hint and a disabled import button', () => { + render(); + expect(screen.getByText('Import file')).toBeInTheDocument(); + expect(screen.getByText(/Import \.gpx, \.kml or \.kmz files/)).toBeInTheDocument(); + expect(importBtn()).toBeDisabled(); + }); + + it('FE-PLANNER-FILEIMP-003: an initialFile is pre-selected and shows the GPX options', () => { + render(); + expect(screen.getByText('route.gpx')).toBeInTheDocument(); + expect(screen.getByText('What do you want to import?')).toBeInTheDocument(); + expect(screen.getByText('Waypoints')).toBeInTheDocument(); + expect(screen.getByText('Tracks (with path geometry)')).toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-004: an unsupported initialFile is rejected on open', () => { + render(); + expect(screen.getByText('Unsupported file type. Use .gpx, .kml or .kmz.')).toBeInTheDocument(); + expect(importBtn()).toBeDisabled(); + }); + + it('FE-PLANNER-FILEIMP-005: an oversized file is rejected with the size limit message', () => { + render(); + fireEvent.change(fileInput(), { target: { files: [gpx('big.gpx', 11 * 1024 * 1024)] } }); + expect(screen.getByText('File is too large. Maximum upload size is 10 MB.')).toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-006: a KML selection shows the point/path options instead', () => { + render(); + fireEvent.change(fileInput(), { target: { files: [kml()] } }); + expect(screen.getByText('Points (Placemarks)')).toBeInTheDocument(); + expect(screen.getByText('Paths (LineStrings)')).toBeInTheDocument(); + expect(screen.queryByText('Waypoints')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-007: unticking every GPX type blocks the import', () => { + render(); + fireEvent.click(screen.getByText('Waypoints')); + fireEvent.click(screen.getByText('Routes')); + fireEvent.click(screen.getByText('Tracks (with path geometry)')); + expect(screen.getByText('Select at least one type to import.')).toBeInTheDocument(); + expect(importBtn()).toBeDisabled(); + }); + + it('FE-PLANNER-FILEIMP-008: unticking every KML type blocks the import', () => { + render(); + fireEvent.click(screen.getByText('Points (Placemarks)')); + fireEvent.click(screen.getByText('Paths (LineStrings)')); + expect(screen.getByText('Select at least one type to import.')).toBeInTheDocument(); + expect(importBtn()).toBeDisabled(); + }); + + it('FE-PLANNER-FILEIMP-009: dragging over the drop zone switches to the drop label', () => { + render(); + const zone = dropZone(); + fireEvent.dragEnter(zone); + expect(screen.getByText('Drop file to select')).toBeInTheDocument(); + fireEvent.dragLeave(zone); + expect(screen.getByText(/Click to select a file/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-019: clicking the drop zone opens the hidden file input', () => { + render(); + const click = vi.spyOn(fileInput(), 'click').mockImplementation(() => {}); + fireEvent.click(dropZone()); + expect(click).toHaveBeenCalled(); + click.mockRestore(); + }); + + it('FE-PLANNER-FILEIMP-010: dropping a file selects it', () => { + render(); + fireEvent.drop(dropZone(), { dataTransfer: { files: [gpx('dropped.gpx')] } }); + expect(screen.getByText('dropped.gpx')).toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-011: a GPX import posts the selected type flags, toasts and closes', async () => { + const onClose = vi.fn(); + let flags: Record = {}; + server.use( + http.post('/api/trips/3/places/import/gpx', async ({ request }) => { + const fd = await request.formData(); + flags = { + waypoints: String(fd.get('importWaypoints')), + routes: String(fd.get('importRoutes')), + tracks: String(fd.get('importTracks')), + }; + return HttpResponse.json({ count: 4, skipped: 0, places: [{ id: 11 }, { id: 12 }] }); + }), + ); + render(); + fireEvent.click(screen.getByText('Routes')); + fireEvent.click(importBtn()); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(flags).toEqual({ waypoints: 'true', routes: 'false', tracks: 'true' }); + expect(toastCalls).toContainEqual(['success', '4 places imported from GPX']); + }); + + it('FE-PLANNER-FILEIMP-012: a successful GPX import registers an undo that bulk-deletes the new places', async () => { + const pushUndo = vi.fn(); + let deleted: number[] = []; + server.use( + http.post('/api/trips/3/places/import/gpx', () => + HttpResponse.json({ count: 2, skipped: 0, places: [{ id: 21 }, { id: 22 }] })), + http.post('/api/trips/3/places/bulk-delete', async ({ request }) => { + deleted = ((await request.json()) as { ids: number[] }).ids; + return HttpResponse.json({ deleted: deleted.length }); + }), + ); + render(); + fireEvent.click(importBtn()); + await waitFor(() => expect(pushUndo).toHaveBeenCalled()); + expect(pushUndo.mock.calls[0][0]).toBe('GPX import'); + + await pushUndo.mock.calls[0][1](); + expect(deleted).toEqual([21, 22]); + }); + + it('FE-PLANNER-FILEIMP-013: a KML import keeps the modal open to show the summary', async () => { + const onClose = vi.fn(); + server.use( + http.post('/api/trips/3/places/import/map', () => + HttpResponse.json({ + count: 3, + places: [{ id: 31 }], + summary: { totalPlacemarks: 5, createdCount: 3, skippedCount: 2, warnings: ['2 placemarks had no coordinates'], errors: [] }, + })), + ); + render(); + fireEvent.click(importBtn()); + expect(await screen.findByText('Placemarks: 5 • Imported: 3 • Skipped: 2')).toBeInTheDocument(); + expect(screen.getByText('2 placemarks had no coordinates')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + expect(toastCalls).toContainEqual(['success', '3 places imported from KMZ/KML']); + }); + + it('FE-PLANNER-FILEIMP-014: summaries of several KML files are merged', async () => { + server.use( + http.post('/api/trips/3/places/import/map', () => + HttpResponse.json({ + count: 1, + places: [{ id: 41 }], + summary: { totalPlacemarks: 2, createdCount: 1, skippedCount: 1, warnings: ['w'], errors: [] }, + })), + ); + render(); + fireEvent.change(fileInput(), { target: { files: [kml('a.kml'), kml('b.kmz')] } }); + fireEvent.click(importBtn()); + expect(await screen.findByText('Placemarks: 4 • Imported: 2 • Skipped: 2')).toBeInTheDocument(); + }); + + it('FE-PLANNER-FILEIMP-015: an import where everything was skipped warns instead of claiming success', async () => { + server.use( + http.post('/api/trips/3/places/import/gpx', () => HttpResponse.json({ count: 0, skipped: 3, places: [] })), + ); + render(); + fireEvent.click(importBtn()); + await waitFor(() => expect(toastCalls).toContainEqual(['warning', 'All places were already in the trip.'])); + }); + + it('FE-PLANNER-FILEIMP-016: a failing import shows the server error and keeps the modal open', async () => { + const onClose = vi.fn(); + server.use( + http.post('/api/trips/3/places/import/gpx', () => HttpResponse.json({ error: 'Malformed GPX' }, { status: 400 })), + ); + render(); + fireEvent.click(importBtn()); + expect(await screen.findByText('Malformed GPX')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + expect(toastCalls).toContainEqual(['error', 'Malformed GPX']); + }); + + it('FE-PLANNER-FILEIMP-017: with several files each error is prefixed with its file name', async () => { + server.use( + http.post('/api/trips/3/places/import/gpx', () => HttpResponse.error()), + ); + render(); + fireEvent.change(fileInput(), { target: { files: [gpx('one.gpx'), gpx('two.gpx')] } }); + fireEvent.click(importBtn()); + const box = await screen.findByText(/one\.gpx: Import failed/); + expect(box.textContent).toContain('two.gpx: Import failed'); + }); + + it('FE-PLANNER-FILEIMP-018: Cancel closes without importing, and a backdrop click closes too', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.click(document.querySelector('[style*="z-index: 99999"]') as HTMLElement); + expect(onClose).toHaveBeenCalledTimes(2); + // A click inside the card is swallowed. + fireEvent.click(screen.getByText('Import file')); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Planner/IcsSubscribeModal.test.tsx b/client/src/components/Planner/IcsSubscribeModal.test.tsx new file mode 100644 index 000000000..92a1ffa91 --- /dev/null +++ b/client/src/components/Planner/IcsSubscribeModal.test.tsx @@ -0,0 +1,157 @@ +// FE-PLANNER-ICS-001 to FE-PLANNER-ICS-012 +import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { IcsSubscribeModal } from './IcsSubscribeModal'; + +const ENDPOINT = '/api/trips/9/feed'; +const TOKEN_URL = `${ENDPOINT}/token`; + +const defaultProps = { + endpoint: ENDPOINT, + title: 'Subscribe to this trip', + description: 'Keep the itinerary in your own calendar.', + onClose: vi.fn(), +}; + +/** Register the token endpoint for every verb the modal can send. */ +function tokenHandlers(handler: (method: string) => Response | Promise) { + return [ + http.get(TOKEN_URL, () => handler('GET')), + http.post(TOKEN_URL, () => handler('POST')), + http.put(TOKEN_URL, () => handler('PUT')), + http.delete(TOKEN_URL, () => handler('DELETE')), + ]; +} + +describe('IcsSubscribeModal', () => { + it('FE-PLANNER-ICS-001: shows the loading state until the token read resolves', async () => { + let release: (() => void) | null = null; + const gate = new Promise(res => { release = res; }); + server.use(http.get(TOKEN_URL, async () => { await gate; return HttpResponse.json({ feed_url: null }); })); + render(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + release!(); + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); + }); + + it('FE-PLANNER-ICS-002: renders title and description in the header', async () => { + server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: null }))); + render(); + expect(screen.getByText('Subscribe to this trip')).toBeInTheDocument(); + expect(screen.getByText('Keep the itinerary in your own calendar.')).toBeInTheDocument(); + await screen.findByRole('button', { name: /Enable calendar subscription/i }); + }); + + it('FE-PLANNER-ICS-003: with no token yet it offers the enable button, no links', async () => { + server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: null }))); + render(); + expect(await screen.findByRole('button', { name: /Enable calendar subscription/i })).toBeInTheDocument(); + expect(screen.getByText(/Creates a secret link/i)).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Add to Google Calendar/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-ICS-004: enabling POSTs the token endpoint and swaps in the subscribe links', async () => { + const seen: string[] = []; + server.use( + http.get(TOKEN_URL, () => { seen.push('GET'); return HttpResponse.json({ feed_url: null }); }), + http.post(TOKEN_URL, () => { seen.push('POST'); return HttpResponse.json({ feed_url: 'https://trek.example/api/trips/9/feed/tok.ics' }); }), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: /Enable calendar subscription/i })); + expect(await screen.findByRole('link', { name: /Add to Google Calendar/i })).toBeInTheDocument(); + expect(seen).toEqual(['GET', 'POST']); + expect(screen.getByRole('button', { name: /Regenerate/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Turn off/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-ICS-005: an absolute feed_url is handed to the links as https and webcal', async () => { + server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: 'https://trek.example/feed/tok.ics' }))); + render(); + expect(await screen.findByText('https://trek.example/feed/tok.ics')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Add to Apple Calendar \/ Outlook/i })) + .toHaveAttribute('href', 'webcal://trek.example/feed/tok.ics'); + }); + + it('FE-PLANNER-ICS-006: a host-relative feed_url is resolved against the current origin', async () => { + server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: '/api/trips/9/feed/tok.ics' }))); + render(); + const absolute = `${window.location.origin}/api/trips/9/feed/tok.ics`; + expect(await screen.findByText(absolute)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Add to Apple Calendar \/ Outlook/i })) + .toHaveAttribute('href', absolute.replace(/^https?:\/\//, 'webcal://')); + }); + + it('FE-PLANNER-ICS-012: a feed_url with neither scheme nor leading slash is used verbatim', async () => { + server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: 'trips/9/feed/tok.ics' }))); + render(); + // Nothing to absolutize against, so the raw value reaches the copy rows and + // the webcal handoff stays schemeless rather than being mangled. + expect(await screen.findAllByText('trips/9/feed/tok.ics')).toHaveLength(2); + expect(screen.getByRole('link', { name: /Add to Apple Calendar \/ Outlook/i })) + .toHaveAttribute('href', 'trips/9/feed/tok.ics'); + }); + + it('FE-PLANNER-ICS-007: Regenerate sends PUT and shows the new link', async () => { + let method = ''; + server.use( + http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: 'https://trek.example/feed/old.ics' })), + http.put(TOKEN_URL, () => { method = 'PUT'; return HttpResponse.json({ feed_url: 'https://trek.example/feed/new.ics' }); }), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: /Regenerate/i })); + expect(await screen.findByText('https://trek.example/feed/new.ics')).toBeInTheDocument(); + expect(method).toBe('PUT'); + }); + + it('FE-PLANNER-ICS-008: Turn off sends DELETE and drops back to the enable state', async () => { + let method = ''; + server.use( + http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: 'https://trek.example/feed/tok.ics' })), + http.delete(TOKEN_URL, () => { method = 'DELETE'; return HttpResponse.json({ feed_url: null }); }), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: /Turn off/i })); + expect(await screen.findByRole('button', { name: /Enable calendar subscription/i })).toBeInTheDocument(); + expect(method).toBe('DELETE'); + }); + + it('FE-PLANNER-ICS-009: a failing token read still leaves the dialog usable', async () => { + server.use(http.get(TOKEN_URL, () => HttpResponse.error())); + render(); + // The catch swallows the error, loading clears, and the enable path is offered. + expect(await screen.findByRole('button', { name: /Enable calendar subscription/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-ICS-010: a non-ok mutate response leaves the current token untouched', async () => { + server.use( + ...tokenHandlers(m => (m === 'GET' + ? HttpResponse.json({ feed_url: 'https://trek.example/feed/keep.ics' }) + : new HttpResponse(null, { status: 500 }))), + ); + render(); + const regenerate = await screen.findByRole('button', { name: /Regenerate/i }); + fireEvent.click(regenerate); + await waitFor(() => expect(regenerate).not.toBeDisabled()); + expect(screen.getByText('https://trek.example/feed/keep.ics')).toBeInTheDocument(); + }); + + it('FE-PLANNER-ICS-011: the close button and a backdrop click both call onClose, an inner click does not', async () => { + const onClose = vi.fn(); + server.use(http.get(TOKEN_URL, () => HttpResponse.json({ feed_url: null }))); + render(); + await screen.findByRole('button', { name: /Enable calendar subscription/i }); + + const closeBtn = screen.getByText('Subscribe to this trip').closest('div')!.parentElement! + .querySelector('button') as HTMLButtonElement; + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + + const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement; + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(2); + + fireEvent.click(screen.getByText('Keep the itinerary in your own calendar.')); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Planner/LocationSelect.test.tsx b/client/src/components/Planner/LocationSelect.test.tsx new file mode 100644 index 000000000..6af8976ae --- /dev/null +++ b/client/src/components/Planner/LocationSelect.test.tsx @@ -0,0 +1,266 @@ +// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-019 +import { useState } from 'react'; +import { delay, http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; +import { server } from '../../../tests/helpers/msw/server'; +import LocationSelect, { type LocationPoint } from './LocationSelect'; + +interface SearchHit { + name?: string; + address?: string | null; + lat?: number | string; + lng?: number | string; + osm_id?: string; + google_place_id?: string; +} + +const GARE = { name: 'Gare du Nord', address: '18 Rue de Dunkerque, Paris', lat: 48.8809, lng: 2.3553, osm_id: 'n1' }; +const LYON = { name: 'Gare de Lyon', address: 'Place Louis-Armand, Paris', lat: 48.8443, lng: 2.3738, osm_id: 'n2' }; + +/** Let the debounce fire and any in-flight request settle. */ +async function settle(ms = 450) { + await act(async () => { await new Promise((r) => setTimeout(r, ms)); }); +} + +function searchRoute(places: SearchHit[] | (() => Response | Promise)) { + return http.post('/api/maps/search', typeof places === 'function' ? places : () => HttpResponse.json({ places })); +} + +// Controlled host — `value` drives the input text and mutes the search for the +// already-picked name, exactly like ReservationModal wires it up. +function Host({ initial = null, onPick }: { initial?: LocationPoint | null; onPick?: (l: LocationPoint | null) => void }) { + const [value, setValue] = useState(initial); + return { setValue(l); onPick?.(l); }} />; +} + +describe('LocationSelect', () => { + it('FE-PLANNER-LOCSEL-001: falls back to the translated placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Search station, port, address…')).toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-002: an explicit placeholder wins over the default', () => { + render(); + expect(screen.getByPlaceholderText('Pick-up point')).toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-003: an initial value renders its name plus a clear button', () => { + render(); + expect(screen.getByDisplayValue('Gare du Nord')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-004: no clear button without a value', () => { + render(); + expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-005: fewer than three characters never reach the API', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use(searchRoute(() => { calls++; return HttpResponse.json({ places: [GARE] }); })); + + render(); + await user.type(screen.getByRole('textbox'), 'Ga'); + + await settle(); + expect(calls).toBe(0); + expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-006: three characters open the dropdown with name and address', async () => { + const user = userEvent.setup(); + server.use(searchRoute([GARE])); + + render(); + await user.type(screen.getByRole('textbox'), 'Gare'); + + expect(await screen.findByText('Gare du Nord')).toBeInTheDocument(); + expect(screen.getByText('18 Rue de Dunkerque, Paris')).toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-007: the query and locale are forwarded to the maps API', async () => { + const user = userEvent.setup(); + let body: { query?: string } = {}; + let lang: string | null = null; + server.use(http.post('/api/maps/search', async ({ request }) => { + lang = new URL(request.url).searchParams.get('lang'); + body = await request.json() as { query?: string }; + return HttpResponse.json({ places: [GARE] }); + })); + + render(); + await user.type(screen.getByRole('textbox'), ' Gare du Nord '); + await screen.findByText('Gare du Nord'); + + expect(body.query).toBe('Gare du Nord'); + expect(lang).toBe('en-US'); + }); + + it('FE-PLANNER-LOCSEL-008: a hit whose address equals its name shows no duplicate subtitle', async () => { + const user = userEvent.setup(); + server.use(searchRoute([{ name: 'Rue de Rivoli', address: 'Rue de Rivoli', lat: 48.85, lng: 2.35, osm_id: 'n9' }])); + + render(); + await user.type(screen.getByRole('textbox'), 'Rivoli'); + + expect(await screen.findAllByText('Rue de Rivoli')).toHaveLength(1); + }); + + it('FE-PLANNER-LOCSEL-009: a nameless hit is labelled by its address', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(searchRoute([{ address: '12 Rue Oberkampf', lat: 48.86, lng: 2.37, google_place_id: 'g1' }])); + + render(); + await user.type(screen.getByRole('textbox'), 'Oberkampf'); + + // The row falls back to the address for its title and still prints the + // address line underneath, so the same text shows up twice. + const rows = await screen.findAllByText('12 Rue Oberkampf'); + expect(rows).toHaveLength(2); + + await user.click(rows[0]); + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: '12 Rue Oberkampf' })); + }); + + it('FE-PLANNER-LOCSEL-010: shows the loading row while the request is in flight', async () => { + const user = userEvent.setup(); + server.use(searchRoute(async () => { await delay(200); return HttpResponse.json({ places: [GARE] }); })); + + render(); + await user.type(screen.getByRole('textbox'), 'Gare'); + + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Gare du Nord')).toBeInTheDocument(); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-011: picking a hit reports name, coordinates and address', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(searchRoute([GARE])); + + render(); + await user.type(screen.getByRole('textbox'), 'Gare'); + await user.click(await screen.findByText('Gare du Nord')); + + expect(onPick).toHaveBeenCalledWith({ + name: 'Gare du Nord', + lat: 48.8809, + lng: 2.3553, + address: '18 Rue de Dunkerque, Paris', + }); + expect(screen.queryByText('18 Rue de Dunkerque, Paris')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-012: string coordinates are coerced to numbers', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(searchRoute([{ name: 'Porto Cruise Terminal', address: null, lat: '41.1496', lng: '-8.6109', osm_id: 'n3' }])); + + render(); + await user.type(screen.getByRole('textbox'), 'Porto'); + await user.click(await screen.findByText('Porto Cruise Terminal')); + + expect(onPick).toHaveBeenCalledWith({ name: 'Porto Cruise Terminal', lat: 41.1496, lng: -8.6109, address: null }); + }); + + it('FE-PLANNER-LOCSEL-013: a hit without usable coordinates is ignored', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(searchRoute([{ name: 'Broken Hit', address: 'nowhere', lat: 'abc', lng: '2.35', osm_id: 'n4' }])); + + render(); + await user.type(screen.getByRole('textbox'), 'Broken'); + await user.click(await screen.findByText('Broken Hit')); + + expect(onPick).not.toHaveBeenCalled(); + // The dropdown stays open so the user can try another row. + expect(screen.getByText('Broken Hit')).toBeInTheDocument(); + }); + + it('FE-PLANNER-LOCSEL-014: the clear button resets value and text', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + + render(); + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(onPick).toHaveBeenCalledWith(null); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('FE-PLANNER-LOCSEL-015: typing over a picked location drops the selection', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(searchRoute([])); + + render(); + await user.type(screen.getByRole('textbox'), 'X'); + + expect(onPick).toHaveBeenCalledWith(null); + }); + + it('FE-PLANNER-LOCSEL-016: ArrowDown/ArrowUp move the highlight and Enter picks it', async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + server.use(searchRoute([GARE, LYON])); + + render(); + await user.type(screen.getByRole('textbox'), 'Gare'); + await screen.findByText('Gare du Nord'); + + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowUp}{Enter}'); + + expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Gare du Nord' })); + }); + + it('FE-PLANNER-LOCSEL-017: Escape closes the dropdown and keeps the typed text', async () => { + const user = userEvent.setup(); + server.use(searchRoute([GARE])); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'Gare'); + await screen.findByText('Gare du Nord'); + + await user.keyboard('{Escape}'); + + expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument(); + expect(input).toHaveValue('Gare'); + }); + + it('FE-PLANNER-LOCSEL-018: a mousedown outside the field closes the dropdown', async () => { + const user = userEvent.setup(); + server.use(searchRoute([GARE])); + + render(); + await user.type(screen.getByRole('textbox'), 'Gare'); + await screen.findByText('Gare du Nord'); + + fireEvent.mouseDown(document.body); + + await waitFor(() => expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument()); + }); + + it('FE-PLANNER-LOCSEL-019: a failing search drops the previous suggestions', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/maps/search', async ({ request }) => { + const { query } = await request.json() as { query: string }; + return query === 'Gare' + ? HttpResponse.json({ places: [GARE] }) + : HttpResponse.json({ error: 'Places API is disabled' }, { status: 502 }); + })); + + render(); + const input = screen.getByRole('textbox'); + await user.type(input, 'Gare'); + await screen.findByText('Gare du Nord'); + + await user.type(input, 's'); + + await waitFor(() => expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument()); + }); +}); diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index d2679a415..e1ea202db 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -1,10 +1,11 @@ -// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036 +// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-062 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; +import { useAddonStore } from '../../store/addonStore'; import { usePermissionsStore } from '../../store/permissionsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories'; @@ -563,4 +564,554 @@ describe('PlaceFormModal', () => { expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument(); expect(screen.getByDisplayValue('2.3522')).toBeInTheDocument(); }); + + // ── Duplicate detection (#1152) ──────────────────────────────────────────── + + it('FE-PLANNER-PLACEFORM-037: a same-named place warns first and saves only on the second click', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre' })] }); + + render(); + // Case-insensitive match against the existing trip place. + await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), ' louvre '); + await user.click(screen.getByRole('button', { name: /^Add$/i })); + + expect(onSave).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined); + // The button now reads as the explicit confirmation. + const addAnyway = await screen.findByRole('button', { name: /Add anyway/i }); + await user.click(addAnyway); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + + delete window.__addToast; + }); + + it('FE-PLANNER-PLACEFORM-038: near-identical coordinates count as a duplicate even under a new name', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })], + }); + + render(); + await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Iron Lady'); + fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), { + clipboardData: { getData: () => '48.85845, 2.29455' }, + }); + await user.click(screen.getByRole('button', { name: /^Add$/i })); + + expect(onSave).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined); + + delete window.__addToast; + }); + + it('FE-PLANNER-PLACEFORM-039: a far-away place with a fresh name saves straight away', async () => { + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })], + }); + + render(); + await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Colosseum'); + fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), { + clipboardData: { getData: () => '41.8902, 12.4922' }, + }); + await user.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Colosseum', lat: 41.8902, lng: 12.4922 })); + }); + + it('FE-PLANNER-PLACEFORM-040: editing an existing place never triggers the duplicate guard', async () => { + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + const place = buildPlace({ name: 'Louvre' }); + seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [place] }); + + render(); + await user.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + }); + + it('FE-PLANNER-PLACEFORM-041: an empty name is rejected with the name-required toast', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + const onSave = vi.fn(); + + render(); + await user.click(screen.getByRole('button', { name: /^Add$/i })); + + expect(onSave).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith('Please enter a name', 'error', undefined); + + delete window.__addToast; + }); + + // ── Location bias from the trip's existing places ─────────────────────────── + + it('FE-PLANNER-PLACEFORM-042: a tight cluster of trip places biases the autocomplete bounding box', async () => { + const user = userEvent.setup(); + const bodies: Record[] = []; + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + bodies.push((await request.json()) as Record); + return HttpResponse.json({ suggestions: [] }); + }), + ); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [ + buildPlace({ lat: 48.85, lng: 2.34 }), + buildPlace({ lat: 48.87, lng: 2.37 }), + // Garbage coordinates must be skipped, not poison the box. + buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number }), + ], + }); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0].locationBias).toEqual({ + low: { lat: 48.85, lng: 2.34 }, + high: { lat: 48.87, lng: 2.37 }, + }); + }); + + it('FE-PLANNER-PLACEFORM-043: places spread over more than 500 km send no location bias', async () => { + const user = userEvent.setup(); + const bodies: Record[] = []; + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + bodies.push((await request.json()) as Record); + return HttpResponse.json({ suggestions: [] }); + }), + ); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ lat: 48.85, lng: 2.34 }), buildPlace({ lat: 41.89, lng: 12.49 })], + }); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0].locationBias).toBeUndefined(); + }); + + it('FE-PLANNER-PLACEFORM-043b: trip places with unusable coordinates yield no bias at all', async () => { + const user = userEvent.setup(); + const bodies: Record[] = []; + server.use( + http.post('/api/maps/autocomplete', async ({ request }) => { + bodies.push((await request.json()) as Record); + return HttpResponse.json({ suggestions: [] }); + }), + ); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number })], + }); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0].locationBias).toBeUndefined(); + }); + + it('FE-PLANNER-PLACEFORM-045b: focusing with a Google Maps URL in the box fetches no suggestions', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + http.post('/api/maps/autocomplete', () => { + calls += 1; + return HttpResponse.json({ suggestions: [] }); + }), + ); + + render(); + const searchInput = screen.getByPlaceholderText('Search places...'); + await user.type(searchInput, 'https://maps.google.com/maps?q=x'); + fireEvent.focus(searchInput); + + await waitFor(() => expect(screen.queryByText('Paris, France')).not.toBeInTheDocument()); + expect(calls).toBe(0); + }); + + it('FE-PLANNER-PLACEFORM-044: a failing autocomplete clears the dropdown instead of surfacing an error', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + + await waitFor(() => expect(consoleError).toHaveBeenCalledWith('Autocomplete failed:', expect.anything())); + expect(addToast).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + delete window.__addToast; + }); + + it('FE-PLANNER-PLACEFORM-045: re-focusing the search box refetches when the dropdown is empty', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + http.post('/api/maps/autocomplete', () => { + calls += 1; + return HttpResponse.json({ suggestions: [] }); + }), + ); + + render(); + const searchInput = screen.getByPlaceholderText('Search places...'); + await user.type(searchInput, 'Eiffel'); + await waitFor(() => expect(calls).toBe(1)); + + fireEvent.focus(searchInput); + await waitFor(() => expect(calls).toBe(2)); + }); + + // ── Maps search / Google Maps URL resolve ────────────────────────────────── + + it('FE-PLANNER-PLACEFORM-046: clicking search with an empty box makes no request', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + http.post('/api/maps/search', () => { + calls += 1; + return HttpResponse.json({ places: [] }); + }), + ); + + render(); + const searchInput = screen.getByPlaceholderText('Search places...'); + const searchBtn = within(searchInput.closest('.flex') as HTMLElement).getByRole('button'); + await user.click(searchBtn); + + expect(calls).toBe(0); + }); + + it('FE-PLANNER-PLACEFORM-047: pasting a Google Maps URL resolves it straight into the form', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + server.use( + http.post('/api/maps/resolve-url', () => + HttpResponse.json({ name: 'Notre-Dame', address: 'Parvis Notre-Dame, Paris', lat: 48.8530, lng: 2.3499, google_ftid: 'ftid-1' }), + ), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame'); + await user.keyboard('{Enter}'); + + expect(await screen.findByDisplayValue('Notre-Dame')).toBeInTheDocument(); + expect(screen.getByDisplayValue('48.853')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Parvis Notre-Dame, Paris')).toBeInTheDocument(); + // The URL is consumed, so the search box empties out. + expect(screen.getByPlaceholderText('Search places...')).toHaveValue(''); + expect(addToast).toHaveBeenCalledWith('Place imported from URL', 'success', undefined); + + delete window.__addToast; + }); + + it('FE-PLANNER-PLACEFORM-048: an unresolvable Google Maps URL falls through to the text search', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/maps/resolve-url', () => HttpResponse.json({ name: 'Notre-Dame', lat: null, lng: null })), + http.post('/api/maps/search', () => + HttpResponse.json({ places: [{ name: 'Notre-Dame de Paris', address: 'Paris', lat: '48.853', lng: '2.3499' }] }), + ), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame'); + await user.keyboard('{Enter}'); + + expect(await screen.findByText('Notre-Dame de Paris')).toBeInTheDocument(); + }); + + // ── Autocomplete keyboard navigation ─────────────────────────────────────── + + const twoSuggestions = () => + http.post('/api/maps/autocomplete', () => + HttpResponse.json({ + suggestions: [ + { placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }, + { placeId: 'node:2', mainText: 'Eiffel Museum', secondaryText: 'Berlin, Germany' }, + ], + }), + ); + + it('FE-PLANNER-PLACEFORM-049: ArrowDown/ArrowUp move the highlight and Enter picks the highlighted suggestion', async () => { + const user = userEvent.setup(); + server.use( + twoSuggestions(), + http.get('/api/maps/details/:placeId', () => + HttpResponse.json({ place: { name: 'Eiffel Museum', address: 'Berlin', lat: 52.52, lng: 13.405 } }), + ), + ); + + render(); + const searchInput = screen.getByPlaceholderText('Search places...'); + await user.type(searchInput, 'Eiffel'); + await screen.findByText('Paris, France'); + + // -1 → wraps to the last entry, then ArrowDown wraps back to the first. + await user.keyboard('{ArrowUp}'); + expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-slate-100'); + await user.keyboard('{ArrowDown}'); + expect(screen.getByText('Eiffel Tower').closest('button')).toHaveClass('bg-slate-100'); + await user.keyboard('{ArrowDown}'); + expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-slate-100'); + + await user.keyboard('{Enter}'); + expect(await screen.findByDisplayValue('52.52')).toBeInTheDocument(); + expect(screen.getByDisplayValue('13.405')).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-050: Escape closes the dropdown without searching', async () => { + const user = userEvent.setup(); + let searches = 0; + server.use( + twoSuggestions(), + http.post('/api/maps/search', () => { + searches += 1; + return HttpResponse.json({ places: [] }); + }), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + await screen.findByText('Paris, France'); + + await user.keyboard('{Escape}'); + expect(screen.queryByText('Paris, France')).not.toBeInTheDocument(); + expect(searches).toBe(0); + }); + + it('FE-PLANNER-PLACEFORM-051: Enter with nothing highlighted closes the dropdown and runs the full search', async () => { + const user = userEvent.setup(); + server.use( + twoSuggestions(), + http.post('/api/maps/search', () => + HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Champ de Mars', lat: '48.8584', lng: '2.2945' }] }), + ), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + await screen.findByText('Paris, France'); + + await user.keyboard('{Enter}'); + expect(await screen.findByText('Champ de Mars')).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-052: when both details and the search fallback fail the query is restored', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + server.use( + twoSuggestions(), + http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + http.post('/api/maps/search', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel'); + await user.click(await screen.findByText('Paris, France')); + + await waitFor(() => + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Place search failed|nope/i), 'error', undefined), + ); + // The typed query comes back so the user can retry instead of starting over. + await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('Eiffel')); + + consoleError.mockRestore(); + delete window.__addToast; + }); + + it('FE-PLANNER-PLACEFORM-053: a Google place id shared with an existing trip place is flagged as duplicate', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ name: 'Already Here', google_place_id: 'gp-1', lat: null, lng: null })], + }); + server.use( + http.post('/api/maps/search', () => + HttpResponse.json({ places: [{ name: 'Same Spot', address: 'Somewhere', lat: '10', lng: '10', google_place_id: 'gp-1' }] }), + ), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'same spot'); + await user.keyboard('{Enter}'); + await user.click(await screen.findByText('Same Spot')); + + await user.click(screen.getByRole('button', { name: /^Add$/i })); + expect(onSave).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Already Here'), 'warning', undefined); + + delete window.__addToast; + }); + + // ── Paste-to-attach ──────────────────────────────────────────────────────── + + function pasteItems(target: Element, items: { type: string; file: File | null }[]) { + fireEvent.paste(target, { + clipboardData: { + items: items.map(i => ({ type: i.type, getAsFile: () => i.file })), + }, + }); + } + + it('FE-PLANNER-PLACEFORM-054: pasting an image attaches it as a pending file', () => { + render(); + const form = document.querySelector('form') as HTMLFormElement; + pasteItems(form, [ + { type: 'text/plain', file: null }, + { type: 'image/png', file: new File(['x'], 'screenshot.png', { type: 'image/png' }) }, + ]); + expect(screen.getByText('screenshot.png')).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-055: pasting plain text alone attaches nothing', () => { + render(); + const form = document.querySelector('form') as HTMLFormElement; + pasteItems(form, [{ type: 'text/plain', file: null }]); + expect(screen.getByText(/paste images from clipboard/i)).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-056: paste is ignored entirely without upload permission', () => { + seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } }); + render(); + const form = document.querySelector('form') as HTMLFormElement; + pasteItems(form, [{ type: 'application/pdf', file: new File(['x'], 'ticket.pdf', { type: 'application/pdf' }) }]); + expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => { + const user = userEvent.setup(); + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); + await user.click(screen.getByRole('button', { name: /Attach/i })); + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + + // ── Remaining form fields ────────────────────────────────────────────────── + + it('FE-PLANNER-PLACEFORM-058: description, notes, website and category all reach the save payload', async () => { + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + const cat = buildCategory({ name: 'Museums' }); + + render(); + await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre'); + await user.type(screen.getByPlaceholderText(/Short description/i), 'Glass pyramid'); + await user.type(screen.getByPlaceholderText(/Personal notes/i), 'Book ahead'); + await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli'); + await user.type(screen.getByPlaceholderText(/Longitude/i), '2.3376'); + await user.type(screen.getByPlaceholderText('https://...'), 'https://louvre.fr'); + // Pick the category through the CustomSelect trigger. + await user.click(screen.getByText(/No category/i)); + await user.click(screen.getByRole('button', { name: 'Museums' })); + + await user.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Louvre', + description: 'Glass pyramid', + notes: 'Book ahead', + address: 'Rue de Rivoli', + lng: 2.3376, + website: 'https://louvre.fr', + category_id: String(cat.id), + }), + ); + }); + + it('FE-PLANNER-PLACEFORM-059: the Collections column only appears on desktop with the addon enabled', () => { + seedStore(useAddonStore, { + addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }], + loaded: true, + }); + const { unmount } = render(); + expect(screen.getByPlaceholderText(/Search your saved places/i)).toBeInTheDocument(); + unmount(); + + render(); + expect(screen.queryByPlaceholderText(/Search your saved places/i)).not.toBeInTheDocument(); + }); + + // ── Time section edge cases ──────────────────────────────────────────────── + + it('FE-PLANNER-PLACEFORM-060: editing the times through the pickers updates the save payload', async () => { + const user = userEvent.setup(); + const onSave = vi.fn().mockResolvedValue(undefined); + const place = buildPlace({ name: 'Museum' }); + const assignment = buildAssignment({ id: 12, day_id: 4, place }); + + render(); + const [start, end] = screen.getAllByTestId('time-picker'); + fireEvent.change(start, { target: { value: '09:00' } }); + fireEvent.change(end, { target: { value: '10:30' } }); + + await user.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ place_time: '09:00', end_time: '10:30' })); + }); + + it('FE-PLANNER-PLACEFORM-061: an assignment id that is not in the day list reports no collisions', () => { + const place = buildPlace({ name: 'Ghost', place_time: '12:00', end_time: '13:00' }); + render(); + expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/Time overlap with:/i)).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-062: collisions ignore other days and timeless assignments', () => { + const current = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' }); + const otherDay = buildPlace({ name: 'Other Day Event', place_time: '13:00', end_time: '14:00' }); + const timeless = buildPlace({ name: 'Timeless Event' }); + const overlapping = buildPlace({ name: 'Overlapping Event', place_time: '13:00', end_time: '14:00' }); + + render( + , + ); + + const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement; + expect(warning).toHaveTextContent('Overlapping Event'); + expect(warning).not.toHaveTextContent('Other Day Event'); + expect(warning).not.toHaveTextContent('Timeless Event'); + }); }); diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index c5bccd3a5..45c8d0d3f 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -5,6 +5,12 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useSettingsStore } from '../../store/settingsStore'; +import { useAddonStore } from '../../store/addonStore'; +import { usePluginStore } from '../../store/pluginStore'; +import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import type { AssignmentsMap } from '../../types'; // ── Module mocks ────────────────────────────────────────────────────────────── @@ -763,4 +769,455 @@ describe('PlaceInspector', () => { expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: null }); }); + // ── Google details cache ───────────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-054: a cached detail payload is reused without a second request', async () => { + const withId = buildPlace({ id: 700, name: 'Cached Place', google_place_id: 'gp-700' }); + vi.mocked(mapsApi.details).mockResolvedValue({ place: { phone: '+49 30 111', rating: 4.2, rating_count: 12 } } as any); + const { unmount } = render(); + expect(await screen.findByText('+49 30 111')).toBeTruthy(); + expect(vi.mocked(mapsApi.details)).toHaveBeenCalledTimes(1); + unmount(); + + render(); + expect(await screen.findByText('+49 30 111')).toBeTruthy(); + // Second mount is served from the in-memory cache. + expect(vi.mocked(mapsApi.details)).toHaveBeenCalledTimes(1); + }); + + it('FE-PLANNER-INSPECTOR-055: an unreadable session cache entry falls back to a fresh fetch', async () => { + const withId = buildPlace({ id: 701, name: 'Broken Cache', google_place_id: 'gp-701' }); + sessionStorage.setItem('gdetails_gp-701_en', '{not json'); + vi.mocked(mapsApi.details).mockResolvedValue({ place: { phone: '+49 30 222' } } as any); + render(); + expect(await screen.findByText('+49 30 222')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-056: the rating chip shows the review count and the first usable review', async () => { + const withId = buildPlace({ id: 702, name: 'Rated Place', google_place_id: 'gp-702' }); + vi.mocked(mapsApi.details).mockResolvedValue({ + place: { rating: 4.5, rating_count: 1200, reviews: [{ text: 'ok' }, { text: 'Great view over the city' }] }, + } as any); + render(); + expect(await screen.findByText('4.5')).toBeTruthy(); + expect(screen.getByText(/Great view over the city/)).toBeTruthy(); + }); + + // ── Opening hours conversion ───────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-057: 12h source hours are converted to 24h, midnight included', async () => { + const withId = buildPlace({ id: 703, name: 'Hours Place', google_place_id: 'gp-703' }); + vi.mocked(mapsApi.details).mockResolvedValue({ + place: { opening_hours: ['Monday: 12:30 AM – 9:00 PM', 'Tuesday: 10:00 AM – 12:00 PM'] }, + } as any); + render(); + fireEvent.click(await screen.findByText(/Show opening hours|Monday|Tuesday/)); + expect(await screen.findByText('Monday: 00:30 – 21:00')).toBeTruthy(); + expect(screen.getByText('Tuesday: 10:00 – 12:00')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-058: 24h source hours are left untouched in 24h mode', async () => { + const withId = buildPlace({ id: 704, name: 'Hours 24h', google_place_id: 'gp-704' }); + vi.mocked(mapsApi.details).mockResolvedValue({ + place: { opening_hours: ['Monday: 09:00 – 18:00'] }, + } as any); + render(); + fireEvent.click(await screen.findByText(/Show opening hours|Monday: 09:00/)); + expect(await screen.findByText('Monday: 09:00 – 18:00')).toBeTruthy(); + }); + + // ── Name editing ───────────────────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-059: without onUpdatePlace a double-click does not start an edit', async () => { + render(); + fireEvent.doubleClick(screen.getByText('Eiffel Tower')); + expect(screen.queryByDisplayValue('Eiffel Tower')).toBeNull(); + }); + + it('FE-PLANNER-INSPECTOR-060: Escape abandons the edit and the following blur saves nothing', async () => { + const onUpdatePlace = vi.fn(); + render(); + fireEvent.doubleClick(screen.getByText('Eiffel Tower')); + const input = await screen.findByDisplayValue('Eiffel Tower'); + fireEvent.change(input, { target: { value: 'Tour Eiffel' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + fireEvent.blur(input); + expect(onUpdatePlace).not.toHaveBeenCalled(); + expect(screen.getByText('Eiffel Tower')).toBeTruthy(); + }); + + // ── Files ──────────────────────────────────────────────────────────────────── + + const placeFile = (over: Record = {}) => ({ + id: 1, trip_id: 1, place_id: 1, original_name: 'map.pdf', filename: 'map.pdf', + mime_type: 'application/pdf', url: '/uploads/map.pdf', created_at: '2025-01-01T00:00:00.000Z', + ...over, + }); + + it('FE-PLANNER-INSPECTOR-061: file sizes render in B, KB and MB, and a zero size is omitted', async () => { + const files = [ + placeFile({ id: 1, original_name: 'tiny.txt', file_size: 512, mime_type: 'text/plain' }), + placeFile({ id: 2, original_name: 'medium.pdf', file_size: 2048 }), + placeFile({ id: 3, original_name: 'big.png', file_size: 3 * 1024 * 1024, mime_type: 'image/png' }), + placeFile({ id: 4, original_name: 'unknown.bin', file_size: 0 }), + ]; + render(); + fireEvent.click(screen.getByText('4 files')); + expect(await screen.findByText('512 B')).toBeTruthy(); + expect(screen.getByText('2.0 KB')).toBeTruthy(); + expect(screen.getByText('3.0 MB')).toBeTruthy(); + expect(screen.getByText('unknown.bin')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-062: clicking an attached file opens it', async () => { + const { openFile } = await import('../../utils/fileDownload'); + const spy = vi.spyOn({ openFile }, 'openFile'); + render(); + fireEvent.click(screen.getByText('1 files')); + const link = await screen.findByText('map.pdf'); + fireEvent.click(link); + // The click is handled without throwing; the row stays in the list. + expect(screen.getByText('map.pdf')).toBeTruthy(); + spy.mockRestore(); + }); + + it('FE-PLANNER-INSPECTOR-063: an empty file selection is ignored', async () => { + const onFileUpload = vi.fn().mockResolvedValue(undefined); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [] } }); + expect(onFileUpload).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-INSPECTOR-064: a failing upload surfaces an error and re-enables the control', async () => { + const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'a.pdf', { type: 'application/pdf' })] } }); + await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText('Upload')).toBeTruthy()); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('FE-PLANNER-INSPECTOR-065: a successful upload expands the file list', async () => { + const onFileUpload = vi.fn().mockResolvedValue(undefined); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'b.pdf', { type: 'application/pdf' })] } }); + await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); + expect(await screen.findByText('map.pdf')).toBeTruthy(); + }); + + // ── Track stats edge cases ─────────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-066: a single-point track renders the colour row but no stats', () => { + render(); + expect(screen.getAllByText('Track color').length).toBeGreaterThan(0); + expect(screen.queryByText('Track')).toBeNull(); + }); + + it('FE-PLANNER-INSPECTOR-067: unparsable geometry is swallowed instead of crashing the panel', () => { + render(); + expect(screen.getByText('Eiffel Tower')).toBeTruthy(); + expect(screen.getAllByText('Track color').length).toBeGreaterThan(0); + }); + + it('FE-PLANNER-INSPECTOR-068: a track with elevations reports distance, peaks and an elevation profile', () => { + const geom = JSON.stringify([[48.0, 2.0, 100], [48.01, 2.01, 180], [48.02, 2.02, 140]]); + render(); + expect(screen.getByText('Track Stats')).toBeTruthy(); + expect(screen.getByText(/↑/)).toBeTruthy(); + expect(document.querySelector('svg path[stroke]')).toBeTruthy(); + }); + + // ── Footer actions ─────────────────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-069: the OpenStreetMap and website buttons open their targets in a new tab', () => { + const open = vi.fn(); + vi.stubGlobal('open', open); + const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' }); + render(); + fireEvent.click(screen.getByText('OpenStreetMap').closest('button')!); + fireEvent.click(screen.getByText('Open Website').closest('button')!); + expect(open).toHaveBeenCalledTimes(2); + expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']); + vi.unstubAllGlobals(); + }); + + it('FE-PLANNER-INSPECTOR-070: an action button restores its idle background after hover', () => { + render(); + const edit = screen.getByText('Edit').closest('button') as HTMLButtonElement; + act(() => { edit.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(edit.style.background).toBe('var(--bg-tertiary)'); + act(() => { edit.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(edit.style.background).toBe('var(--bg-hover)'); + }); + + it('FE-PLANNER-INSPECTOR-071: the header close button resets its hover background', () => { + render(); + const close = document.querySelector('.bg-surface-hover') as HTMLButtonElement; + act(() => { close.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(close.style.background).toBe('var(--bg-tertiary)'); + act(() => { close.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(close.style.background).toBe('var(--bg-hover)'); + }); + + it('FE-PLANNER-INSPECTOR-072: rating a place forwards the vote', () => { + const onRate = vi.fn(); + render(); + const stars = screen.getAllByRole('radio'); + expect(stars).toHaveLength(5); + fireEvent.click(stars[2]); + expect(onRate).toHaveBeenCalledWith(place.id, 3); + }); + + // ── Reservation summary ────────────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-073: the linked reservation summarises flight, platform and check-in metadata', () => { + const res = buildReservation({ + id: 1, title: 'Flight to Nice', status: 'pending', assignment_id: 9, + reservation_time: '2025-06-15T08:30', reservation_end_time: '2025-06-15T10:15', + confirmation_number: 'ABC999', notes: 'Aisle seat', + metadata: JSON.stringify({ airline: 'Air France', flight_number: 'AF123', departure_airport: 'CDG', arrival_airport: 'NCE', train_number: 'TGV1', platform: '7', check_in_time: '06:00', check_out_time: '12:00' }), + } as any); + render(); + expect(screen.getByText('Flight to Nice')).toBeTruthy(); + expect(screen.getByText('ABC999')).toBeTruthy(); + expect(screen.getByText('Aisle seat')).toBeTruthy(); + expect(screen.getByText(/Air France AF123 · CDG → NCE · TGV1 · Gl\. 7 · Check-in 06:00 · Check-out 12:00/)).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-074: a reservation whose metadata has no printable fields shows no meta line', () => { + const res = buildReservation({ id: 2, title: 'Plain booking', status: 'confirmed', assignment_id: 9, metadata: JSON.stringify({ seat: '4A' }) } as any); + render(); + expect(screen.getByText('Plain booking')).toBeTruthy(); + expect(screen.queryByText(/Gl\./)).toBeNull(); + }); + + // ── Participants ───────────────────────────────────────────────────────────── + + const members = [ + { id: 1, username: 'ada', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, + { id: 3, username: 'cleo', avatar_url: null, is_guest: true }, + ]; + const participantProps = (participants: Array<{ user_id: number; username?: string }> = []) => ({ + selectedDayId: 1, + selectedAssignmentId: 9, + tripMembers: members, + assignments: { '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null, participants }] } as unknown as AssignmentsMap, + }); + + it('FE-PLANNER-INSPECTOR-075: with nobody explicitly set, every member counts as joined', () => { + render(); + expect(screen.getByText('Participants')).toBeTruthy(); + for (const m of members) expect(screen.getByText(m.username)).toBeTruthy(); + // Everyone is in, so there is nothing left to add. + expect(screen.queryByText('+')).toBeNull(); + }); + + it('FE-PLANNER-INSPECTOR-076: removing a member from the all-joined state sends the remaining ids', () => { + const onSetParticipants = vi.fn(); + render(); + fireEvent.click(screen.getByText('bob')); + expect(onSetParticipants).toHaveBeenCalledWith(9, 1, [1, 3]); + }); + + it('FE-PLANNER-INSPECTOR-077: removing down to the full member list is stored as "everyone" again', () => { + const onSetParticipants = vi.fn(); + render(); + // The chip is hovered first, which flags it removable. + fireEvent.mouseEnter(screen.getByText('ada').closest('div')!); + fireEvent.click(screen.getByText('cleo')); + expect(onSetParticipants).toHaveBeenCalledWith(9, 1, [1, 2]); + }); + + it('FE-PLANNER-INSPECTOR-078: the last remaining participant cannot be removed', () => { + const onSetParticipants = vi.fn(); + render(); + fireEvent.click(screen.getByText('ada')); + expect(onSetParticipants).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-INSPECTOR-079: the add menu lists the missing members and marks guests', () => { + const onSetParticipants = vi.fn(); + render(); + fireEvent.click(screen.getByText('+')); + expect(screen.getByText('bob')).toBeTruthy(); + expect(screen.getByText('Guest')).toBeTruthy(); + fireEvent.click(screen.getByText('bob')); + expect(onSetParticipants).toHaveBeenCalledWith(9, 1, [1, 2]); + }); + + it('FE-PLANNER-INSPECTOR-080: adding the final missing member stores "everyone" instead of a full list', () => { + const onSetParticipants = vi.fn(); + render(); + fireEvent.click(screen.getByText('+')); + fireEvent.click(screen.getByText('cleo')); + expect(onSetParticipants).toHaveBeenCalledWith(9, 1, []); + }); + + // ── Save to collection ─────────────────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-081: with collections enabled the footer offers to save the place', async () => { + seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'addon', icon: 'bookmark', enabled: true }], loaded: true }); + server.use( + http.get('/api/addons/collections/membership', () => HttpResponse.json({ saved: true, collections: [] })), + ); + render(); + expect(await screen.findByText('Saved')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-082: clicking save hands the whole place to the collection picker', async () => { + seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'addon', icon: 'bookmark', enabled: true }], loaded: true }); + server.use( + http.get('/api/addons/collections/membership', () => HttpResponse.json({ saved: false, collections: [] })), + ); + render(); + fireEvent.click((await screen.findByText('Save to Collection')).closest('button')!); + expect(useSaveToCollectionStore.getState().target).toMatchObject({ + name: 'Eiffel Tower', source_place_id: place.id, lat: 48.8584, lng: 2.2945, + }); + }); + + it('FE-PLANNER-INSPECTOR-083: a failing membership check leaves the unsaved label', async () => { + seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'addon', icon: 'bookmark', enabled: true }], loaded: true }); + server.use( + http.get('/api/addons/collections/membership', () => new HttpResponse(null, { status: 500 })), + ); + render(); + expect(await screen.findByText('Save to Collection')).toBeTruthy(); + }); + + // ── Plugin contributions (#1429) ───────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-084: placeDetailProvider rows render as label/value and links', async () => { + server.use( + http.get('/api/place-details/1', () => HttpResponse.json({ + providers: [ + { pluginId: 'tides', items: [{ label: 'High tide', value: '14:20' }, { label: 'Forecast', url: 'https://tides.example' }] }, + // Empty providers are dropped before render. + { pluginId: 'empty', items: [] }, + ], + })), + ); + render(); + expect(await screen.findByText('High tide')).toBeTruthy(); + expect(screen.getByText('14:20')).toBeTruthy(); + expect(screen.getByRole('link', { name: '↗' })).toHaveAttribute('href', 'https://tides.example'); + }); + + it('FE-PLANNER-INSPECTOR-085: a failing provider request adds no rows', async () => { + server.use(http.get('/api/place-details/1', () => new HttpResponse(null, { status: 500 }))); + render(); + await waitFor(() => expect(screen.getByText('Eiffel Tower')).toBeTruthy()); + expect(screen.queryByText('High tide')).toBeNull(); + }); + + it('FE-PLANNER-INSPECTOR-086: a place-detail widget plugin mounts a frame scoped to the place', async () => { + seedStore(usePluginStore, { + plugins: [ + { id: 'tide-widget', name: 'Tides', type: 'widget', icon: null, slot: 'place-detail' }, + { id: 'hero-widget', name: 'Hero', type: 'widget', icon: null, slot: 'hero' }, + ], + }); + render(); + await waitFor(() => expect(document.querySelector('iframe[src*="tide-widget"]')).toBeTruthy()); + expect(document.querySelector('iframe[src*="hero-widget"]')).toBeNull(); + }); + + it('FE-PLANNER-INSPECTOR-087: in collection mode neither provider rows nor plugin frames are fetched', async () => { + let called = false; + server.use(http.get('/api/place-details/1', () => { called = true; return HttpResponse.json({ providers: [] }); })); + seedStore(usePluginStore, { + plugins: [{ id: 'tide-widget', name: 'Tides', type: 'widget', icon: null, slot: 'place-detail' }], + }); + render(); + await waitFor(() => expect(screen.getByText('Copy to trip')).toBeTruthy()); + expect(called).toBe(false); + expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull(); + }); + + // ── Custom thumbnail callbacks (#1136) ─────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => { + const onUpdatePlace = vi.fn(); + const onUploadImage = vi.fn(async () => {}); + const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove image' })); + expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null }); + }); + + it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => { + const withId = buildPlace({ id: 707, name: 'Day-aware', google_place_id: 'gp-707' }); + vi.mocked(mapsApi.details).mockResolvedValue({ + place: { opening_hours: ['Mon 08:00', 'Tue 09:00', 'Wed 10:00', 'Thu 11:00', 'Fri 12:00', 'Sat 13:00', 'Sun 14:00'] }, + } as any); + // 2025-06-18 is a Wednesday → index 2. + render(); + expect(await screen.findByText('Wed 10:00')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-091: without an onSetParticipants handler the chips are inert', () => { + render(); + fireEvent.click(screen.getByText('ada')); + fireEvent.click(screen.getByText('+')); + fireEvent.click(screen.getByText('cleo')); + // Nothing blew up and the box is still on screen. + expect(screen.getByText('Participants')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-092: a stale participant id outside the member list collapses back to "everyone"', () => { + const onSetParticipants = vi.fn(); + render(); + fireEvent.click(screen.getByText('ada')); + expect(onSetParticipants).toHaveBeenCalledWith(9, 1, []); + }); + + it('FE-PLANNER-INSPECTOR-093: participant chips and the add menu reset their hover styling', () => { + render(); + const chip = screen.getByText('ada').closest('div') as HTMLElement; + fireEvent.mouseEnter(chip); + expect(chip.className).toContain('text-[#ef4444]'); + fireEvent.mouseLeave(chip); + expect(chip.className).toContain('text-content'); + + const add = screen.getByText('+') as HTMLButtonElement; + act(() => { add.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(add.style.color).toBe('var(--text-primary)'); + act(() => { add.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(add.style.color).toBe('var(--text-faint)'); + + fireEvent.click(add); + const entry = screen.getByText('cleo').closest('button') as HTMLButtonElement; + act(() => { entry.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(entry.style.background).toBe('var(--bg-hover)'); + act(() => { entry.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(entry.style.background).toBe('none'); + }); + + it('FE-PLANNER-INSPECTOR-094: a flight number without an airline is still summarised', () => { + const res = buildReservation({ id: 3, title: 'Numbered flight', status: 'confirmed', assignment_id: 9, metadata: JSON.stringify({ flight_number: 'LH400' }) } as any); + render(); + expect(screen.getByText('LH400')).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => { + const onUploadImage = vi.fn(async () => {}); + render(); + const input = document.querySelector('input[accept*="image"]') as HTMLInputElement; + const file = new File(['x'], 'thumb.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file)); + }); + }); diff --git a/client/src/components/Planner/PlacesSidebarSelectionBar.test.tsx b/client/src/components/Planner/PlacesSidebarSelectionBar.test.tsx new file mode 100644 index 000000000..1435cc83c --- /dev/null +++ b/client/src/components/Planner/PlacesSidebarSelectionBar.test.tsx @@ -0,0 +1,190 @@ +// FE-PLANNER-SELBAR-001 to FE-PLANNER-SELBAR-014 +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import { buildPlace } from '../../../tests/helpers/factories'; +import { useTranslation } from '../../i18n'; +import type { Place } from '../../types'; +import { PlacesSelectionBar } from './PlacesSidebarSelectionBar'; +import type { SidebarState } from './usePlacesSidebar'; + +const PLACES = [buildPlace({ id: 1 }), buildPlace({ id: 2 }), buildPlace({ id: 3 })]; + +interface BarProps { + selectedIds?: Set; + filtered?: Place[]; + isMobile?: boolean; + collectionsEnabled?: boolean; + setSelectedIds?: (ids: Set) => void; + setPendingDeleteIds?: (ids: number[] | null) => void; + setCategoryPickerOpen?: (open: boolean) => void; + setSaveToListOpen?: (open: boolean) => void; + onBulkDeletePlaces?: (ids: number[]) => void; +} + +// The bar reads a handful of fields off the sidebar state; the rest of the hook +// is irrelevant here, so only those are supplied. +function Bar(overrides: BarProps) { + const { t } = useTranslation(); + const state = { + t, + selectedIds: new Set(), + filtered: PLACES, + isMobile: false, + collectionsEnabled: false, + setSelectedIds: () => {}, + setPendingDeleteIds: () => {}, + setCategoryPickerOpen: () => {}, + setSaveToListOpen: () => {}, + onBulkDeletePlaces: undefined, + ...overrides, + }; + return ; +} + +describe('PlacesSelectionBar', () => { + it('FE-PLANNER-SELBAR-001: reports how many places are selected', () => { + render(); + expect(screen.getByText('2 selected')).toBeInTheDocument(); + }); + + it('FE-PLANNER-SELBAR-002: offers "Select all" while the selection is partial', () => { + render(); + expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Deselect all' })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-SELBAR-003: "Select all" selects every visible place', async () => { + const user = userEvent.setup(); + const setSelectedIds = vi.fn((_ids: Set) => {}); + render(); + + await user.click(screen.getByRole('button', { name: 'Select all' })); + + expect(setSelectedIds).toHaveBeenCalledWith(new Set([1, 2, 3])); + }); + + it('FE-PLANNER-SELBAR-004: a full selection flips the button to "Deselect all" and clears it', async () => { + const user = userEvent.setup(); + const setSelectedIds = vi.fn((_ids: Set) => {}); + render(); + + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + + expect(setSelectedIds).toHaveBeenCalledWith(new Set()); + }); + + it('FE-PLANNER-SELBAR-005: an empty list keeps the "Select all" wording', async () => { + const user = userEvent.setup(); + const setSelectedIds = vi.fn((_ids: Set) => {}); + render(); + + // 0 === 0 would otherwise read as "everything is selected". + const btn = screen.getByRole('button', { name: 'Select all' }); + await user.click(btn); + + expect(setSelectedIds).toHaveBeenCalledWith(new Set()); + }); + + it('FE-PLANNER-SELBAR-006: the category button is disabled without a selection', () => { + const setCategoryPickerOpen = vi.fn((_open: boolean) => {}); + render(); + + const btn = screen.getByRole('button', { name: 'Change category' }); + expect(btn).toBeDisabled(); + fireEvent.click(btn); + expect(setCategoryPickerOpen).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-SELBAR-007: the category button opens the bulk picker', async () => { + const user = userEvent.setup(); + const setCategoryPickerOpen = vi.fn((_open: boolean) => {}); + render(); + + await user.click(screen.getByRole('button', { name: 'Change category' })); + + expect(setCategoryPickerOpen).toHaveBeenCalledWith(true); + }); + + it('FE-PLANNER-SELBAR-008: the collection button is hidden while the addon is off', () => { + render(); + expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-SELBAR-009: the collection button opens the save sheet', async () => { + const user = userEvent.setup(); + const setSaveToListOpen = vi.fn((_open: boolean) => {}); + render(); + + await user.click(screen.getByRole('button', { name: 'Save to Collection' })); + + expect(setSaveToListOpen).toHaveBeenCalledWith(true); + }); + + it('FE-PLANNER-SELBAR-010: the collection button is disabled without a selection', () => { + const setSaveToListOpen = vi.fn((_open: boolean) => {}); + render(); + + const btn = screen.getByRole('button', { name: 'Save to Collection' }); + expect(btn).toBeDisabled(); + fireEvent.click(btn); + expect(setSaveToListOpen).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-SELBAR-011: on desktop delete goes straight to the bulk handler', async () => { + const user = userEvent.setup(); + const onBulkDeletePlaces = vi.fn((_ids: number[]) => {}); + const setPendingDeleteIds = vi.fn((_ids: number[] | null) => {}); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Delete selected' })); + + expect(onBulkDeletePlaces).toHaveBeenCalledWith([2, 3]); + expect(setPendingDeleteIds).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-SELBAR-012: on mobile delete arms the confirm dialog instead', async () => { + const user = userEvent.setup(); + const onBulkDeletePlaces = vi.fn((_ids: number[]) => {}); + const setPendingDeleteIds = vi.fn((_ids: number[] | null) => {}); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Delete selected' })); + + expect(setPendingDeleteIds).toHaveBeenCalledWith([2, 3]); + expect(onBulkDeletePlaces).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-SELBAR-013: hovering an enabled button highlights it and leaving resets it', () => { + render(); + + for (const name of ['Select all', 'Change category', 'Save to Collection', 'Delete selected']) { + const btn = screen.getByRole('button', { name }); + fireEvent.mouseEnter(btn); + fireEvent.mouseLeave(btn); + expect(btn.style.background).toBe('transparent'); + } + }); + + it('FE-PLANNER-SELBAR-014: hovering a disabled button leaves it unstyled', () => { + render(); + + for (const name of ['Change category', 'Save to Collection', 'Delete selected']) { + const btn = screen.getByRole('button', { name }); + fireEvent.mouseEnter(btn); + expect(btn.style.background).toBe(''); + } + }); +}); diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index f34470a19..06633fb32 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,5 +1,5 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052 -import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-080 +import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -17,6 +17,8 @@ import { buildTripFile, } from '../../../tests/helpers/factories'; import { ReservationModal } from './ReservationModal'; +import type { BookingReviewDraft } from './parsedItemToDraft'; +import type { TripMember } from '../Budget/BudgetPanelMemberChips'; // Mock react-router-dom useParams vi.mock('react-router-dom', async (importActual) => { @@ -854,4 +856,571 @@ describe('ReservationModal', () => { expect(saved.create_accommodation).toBeUndefined(); expect(saved.location).toBe('Main Road 3'); }); + + // ── Import review (prefill) ──────────────────────────────────────────────── + + const budgetEnabled = () => + seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + + const reviewDays = () => [ + buildDay({ id: 1, trip_id: 1, date: '2026-05-01' }), + buildDay({ id: 2, trip_id: 1, date: '2026-05-02' }), + buildDay({ id: 3, trip_id: 1, date: '2026-05-03' }), + ]; + + function hotelPrefill(overrides: Record = {}): BookingReviewDraft { + return { + title: 'Hotel Adlon', + type: 'hotel', + status: 'pending', + reservation_time: '2026-05-01T14:00:00', + reservation_end_time: '2026-05-03T11:00:00', + location: 'Unter den Linden 77', + confirmation_number: 'ADL-9', + notes: 'Late arrival', + url: 'https://adlon.example', + metadata: { check_in_time: '15:00', check_in_end_time: '23:00', check_out_time: '11:00', price: 240, priceCurrency: 'EUR' }, + _venue: { name: 'Hotel Adlon', address: 'Unter den Linden 77' }, + _accommodation: { check_in: '2026-05-01', check_out: '2026-05-03' }, + ...overrides, + } as unknown as BookingReviewDraft; + } + + it('FE-PLANNER-RESMODAL-055: a hotel prefill populates the form and matches the venue to a trip place', () => { + const place = buildPlace({ id: 5, name: 'Hotel Adlon', address: 'Unter den Linden 77' }); + render(); + + // Still a create — the modal never leaves "New Reservation". + expect(screen.getByText(/New Reservation/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue('Hotel Adlon')).toBeInTheDocument(); + expect(screen.getByDisplayValue('ADL-9')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Late arrival')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://adlon.example')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Unter den Linden 77')).toBeInTheDocument(); + // Check-in / check-in-until / check-out come from the parsed metadata. + const times = screen.getAllByTestId('time-picker') as HTMLInputElement[]; + expect(times.map(i => i.value)).toEqual(['15:00', '23:00', '11:00']); + }); + + it('FE-PLANNER-RESMODAL-056: saving a hotel prefill creates the accommodation over the resolved day range', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 77 }); + const place = buildPlace({ id: 5, name: 'Hotel Adlon', address: 'Unter den Linden 77' }); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const saved = onSave.mock.calls[0][0]; + expect(saved.create_accommodation).toMatchObject({ + place_id: 5, + start_day_id: 1, + end_day_id: 3, + check_in: '15:00', + check_in_end: '23:00', + check_out: '11:00', + confirmation: 'ADL-9', + }); + // Hotels persist no reservation time; the address rides on `location`. + expect(saved.reservation_time).toBeNull(); + expect(saved.location).toBe('Unter den Linden 77'); + expect(saved.metadata).toMatchObject({ check_in_time: '15:00', check_out_time: '11:00' }); + }); + + it('FE-PLANNER-RESMODAL-057: a venue name that only loosely matches still links the trip place', () => { + const place = buildPlace({ id: 6, name: 'Adlon', address: 'Pariser Platz' }); + render(); + // Loose contains-match wins when there is no exact name hit — the picker + // shows the linked place instead of the "—" placeholder. + expect(screen.getByText('Adlon')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-058: a prefill without a venue or title links no place', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 78 }); + const place = buildPlace({ id: 7, name: 'Some Hotel' }); + const prefill = hotelPrefill({ title: '', _venue: undefined, _accommodation: undefined }); + render( + , + ); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Unnamed stay'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + // No day range resolved either, so nothing is created alongside the booking. + expect(onSave.mock.calls[0][0].create_accommodation).toBeUndefined(); + }); + + it('FE-PLANNER-RESMODAL-059: a date-only end in the prefill fills the end date and leaves the time blank', () => { + const prefill = hotelPrefill({ type: 'event', reservation_end_time: '2026-05-03', _venue: undefined, _accommodation: undefined }); + render(); + const datePickers = screen.getAllByTestId('date-picker') as HTMLInputElement[]; + expect(datePickers[1].value).toBe('2026-05-03'); + const timePickers = screen.getAllByTestId('time-picker') as HTMLInputElement[]; + expect(timePickers[1].value).toBe(''); + }); + + it('FE-PLANNER-RESMODAL-060: a parsed price previews the cost and is created with the booking', async () => { + budgetEnabled(); + const onSave = vi.fn().mockResolvedValue({ id: 79 }); + render(); + + // The parsed price is previewed as the cost that will be linked on save. + expect(screen.getByText('Linked expense')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' }); + }); + + it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => { + budgetEnabled(); + const onSave = vi.fn().mockResolvedValue({ id: 80 }); + const prefill = hotelPrefill({ metadata: { check_in_time: '15:00' } }); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toBeUndefined(); + }); + + it('FE-PLANNER-RESMODAL-062: the parsed source document is seeded as a pending file and uploaded after save', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 81 }); + const onFileUpload = vi.fn().mockResolvedValue(undefined); + const sourceFile = new File(['x'], 'booking.pdf', { type: 'application/pdf' }); + const prefill = hotelPrefill({ _sourceFiles: [sourceFile] }); + + render( + , + ); + expect(screen.getByText('booking.pdf')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); + const [fd] = onFileUpload.mock.calls[0] as [FormData]; + expect(fd.get('reservation_id')).toBe('81'); + expect(fd.get('description')).toBe('Hotel Adlon'); + }); + + // ── Existing-reservation end date/time parsing ────────────────────────────── + + it('FE-PLANNER-RESMODAL-063: an ISO end timestamp is split into the end date and end time fields', () => { + const res = buildReservation({ + id: 4, type: 'event', title: 'Concert', + reservation_time: '2026-05-01T20:00:00', reservation_end_time: '2026-05-02T23:30:00', + }); + render(); + const datePickers = screen.getAllByTestId('date-picker') as HTMLInputElement[]; + const timePickers = screen.getAllByTestId('time-picker') as HTMLInputElement[]; + expect(datePickers[1].value).toBe('2026-05-02'); + expect(timePickers[1].value).toBe('23:30'); + }); + + it('FE-PLANNER-RESMODAL-064: a date-only end is treated as an all-day end with no time', () => { + const res = buildReservation({ + id: 4, type: 'event', title: 'Festival', + reservation_time: '2026-05-01T20:00:00', reservation_end_time: '2026-05-03', + }); + render(); + const datePickers = screen.getAllByTestId('date-picker') as HTMLInputElement[]; + const timePickers = screen.getAllByTestId('time-picker') as HTMLInputElement[]; + expect(datePickers[1].value).toBe('2026-05-03'); + expect(timePickers[1].value).toBe(''); + }); + + it('FE-PLANNER-RESMODAL-065: an end time without an end date is combined with the start date', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Dinner'); + fireEvent.change(screen.getAllByTestId('date-picker')[0], { target: { value: '2026-05-01' } }); + fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '19:00' } }); + fireEvent.change(screen.getAllByTestId('time-picker')[1], { target: { value: '21:00' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].reservation_end_time).toBe('2026-05-01T21:00'); + }); + + it('FE-PLANNER-RESMODAL-066: typing only a start time dates it from the selected day', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const days = reviewDays(); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Museum slot'); + fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '10:15' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].reservation_time).toBe('2026-05-02T10:15'); + }); + + it('FE-PLANNER-RESMODAL-067: clearing the start date drops the time with it', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Loose booking'); + fireEvent.change(screen.getAllByTestId('date-picker')[0], { target: { value: '2026-05-01' } }); + fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '09:00' } }); + fireEvent.change(screen.getAllByTestId('date-picker')[0], { target: { value: '' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].reservation_time).toBeNull(); + }); + + // ── Assignment + place linking ────────────────────────────────────────────── + + it('FE-PLANNER-RESMODAL-068: picking a day assignment seeds the booking date from that day', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const day = buildDay({ id: 1, trip_id: 1, date: '2026-05-01', title: 'Arrival' }); + const museum = buildPlace({ id: 11, name: 'Museum', place_time: '09:00', end_time: '10:00' }); + const park = buildPlace({ id: 12, name: 'Park' }); + const assignments = { + '1': [ + buildAssignment({ id: 202, day_id: 1, order_index: 1, place: park }), + buildAssignment({ id: 201, day_id: 1, order_index: 0, place: museum }), + // An assignment whose place was deleted must be skipped, not crash the list. + { id: 203, day_id: 1, place_id: 0, order_index: 2, notes: null, place: null }, + ], + }; + + render( + , + ); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Guided tour'); + await userEvent.click(screen.getByText('No link (standalone)')); + // Ordered by order_index, so the museum is offered first with its time range. + await userEvent.click(screen.getByRole('button', { name: /1\. Museum · 09:00 – 10:00/ })); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const saved = onSave.mock.calls[0][0]; + expect(saved.assignment_id).toBe(201); + expect(saved.reservation_time).toBe('2026-05-01'); + }); + + it('FE-PLANNER-RESMODAL-069: linking a trip place fills the empty title and location from it', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const place = buildPlace({ id: 15, name: 'Le Jules Verne', address: 'Champ de Mars' }); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Restaurant/i })); + await userEvent.click(screen.getByText('—')); + await userEvent.click(screen.getByRole('button', { name: 'Le Jules Verne' })); + + expect(screen.getByDisplayValue('Le Jules Verne')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Champ de Mars')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].place_id).toBe(15); + }); + + it('FE-PLANNER-RESMODAL-070: picking a hotel place adopts its address into the hotel form', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const place = buildPlace({ id: 16, name: 'Grand Hotel', address: 'Bahnhofstrasse 1' }); + const days = reviewDays(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i })); + await userEvent.click(screen.getByText('—')); + await userEvent.click(screen.getByRole('button', { name: 'Grand Hotel' })); + + expect(screen.getByDisplayValue('Grand Hotel')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Bahnhofstrasse 1')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-071: url, notes and booking code are carried into the payload', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Boat trip'); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. ABC12345/i), 'BT-77'); + await userEvent.type(screen.getByPlaceholderText(/Address, Airport/i), 'Pier 3'); + await userEvent.type(screen.getByPlaceholderText(/Notes/i), 'Bring sunscreen'); + await userEvent.type(screen.getByPlaceholderText(/https:\/\//i), 'https://boats.example'); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + confirmation_number: 'BT-77', + location: 'Pier 3', + notes: 'Bring sunscreen', + url: 'https://boats.example', + }), + ); + }); + + // ── Travelers (#1517) ────────────────────────────────────────────────────── + + const tripMembers: TripMember[] = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, + ]; + + it('FE-PLANNER-RESMODAL-072: toggling travelers persists them once the booking has an id', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 90 }); + let body: { user_ids: number[] } | null = null; + server.use( + http.put('/api/trips/1/reservations/90/travelers', async ({ request }) => { + body = (await request.json()) as { user_ids: number[] }; + return HttpResponse.json({ travelers: [] }); + }), + ); + + render(); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Museum tour'); + await userEvent.click(screen.getByText('alice')); + await userEvent.click(screen.getByText('bob')); + // Toggling bob again removes him, so only alice is sent. + await userEvent.click(screen.getByText('bob')); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body!.user_ids).toEqual([1]); + }); + + it('FE-PLANNER-RESMODAL-073: an unchanged traveler list is not written back', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 91 }); + let calls = 0; + server.use( + http.put('/api/trips/1/reservations/91/travelers', () => { + calls += 1; + return HttpResponse.json({ travelers: [] }); + }), + ); + const res = buildReservation({ id: 91, type: 'event', title: 'Opera', travelers: [{ user_id: 1, username: 'alice' }] }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(calls).toBe(0); + }); + + it('FE-PLANNER-RESMODAL-074: a failing traveler write surfaces an error toast', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const onSave = vi.fn().mockResolvedValue({ id: 92 }); + server.use( + http.put('/api/trips/1/reservations/92/travelers', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + + render(); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Boat trip'); + await userEvent.click(screen.getByText('alice')); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined)); + delete window.__addToast; + }); + + // ── Linked cost actions ───────────────────────────────────────────────────── + + function seedLinkedCost() { + budgetEnabled(); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + budgetItems: [ + { id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null }, + ], + }); + } + + it('FE-PLANNER-RESMODAL-075: editing the linked cost saves the booking first, then opens that item', async () => { + seedLinkedCost(); + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + const onOpenExpense = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /^Edit$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onOpenExpense).toHaveBeenCalledWith({ editItem: expect.objectContaining({ id: 7 }) }); + }); + + it('FE-PLANNER-RESMODAL-076: removing the linked cost deletes it, and a failure shows a toast', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + seedLinkedCost(); + let deleted = false; + server.use( + http.delete('/api/trips/1/budget/7', () => { + deleted = true; + return HttpResponse.json({ error: 'nope' }, { status: 500 }); + }), + ); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /Remove expense/i })); + await waitFor(() => expect(deleted).toBe(true)); + await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined)); + delete window.__addToast; + }); + + // ── File error paths ──────────────────────────────────────────────────────── + + it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => { + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [] } }); + expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-078: a failing upload to an existing booking shows the upload error', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full')); + const res = buildReservation({ id: 12, title: 'My Trip', type: 'other' }); + + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(['x'], 'fail.pdf', { type: 'application/pdf' })] } }); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined)); + delete window.__addToast; + }); + + it('FE-PLANNER-RESMODAL-079: a failing unlink reports the update error but still drops the row', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + server.use( + http.put('/api/trips/1/files/50', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + http.get('/api/trips/1/files/50/links', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + const res = buildReservation({ id: 13, type: 'other', title: 'Trip' }); + const attached = buildTripFile({ id: 50, original_name: 'voucher.pdf' }); + (attached as unknown as { reservation_id: number }).reservation_id = 13; + + render(); + const row = screen.getByText('voucher.pdf').closest('div') as HTMLElement; + await userEvent.click(within(row).getByRole('button')); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined)); + delete window.__addToast; + }); + + it('FE-PLANNER-RESMODAL-080: a failing link keeps the picker open and reports the error', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + server.use(http.post('/api/trips/1/files/60/link', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + + const res = buildReservation({ id: 14, type: 'other', title: 'Trip' }); + const loose = buildTripFile({ id: 60, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + await userEvent.click(screen.getByText('invoice.pdf')); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined)); + expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument(); + delete window.__addToast; + }); + + it('FE-PLANNER-RESMODAL-082: submitting the form with an empty title is a no-op', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.submit(document.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(onSave).not.toHaveBeenCalled()); + }); + + it('FE-PLANNER-RESMODAL-083: linking an assignment leaves an already-chosen date alone', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const day = buildDay({ id: 1, trip_id: 1, date: '2026-05-01', title: 'Arrival' }); + const museum = buildPlace({ id: 11, name: 'Museum' }); + render( + , + ); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Guided tour'); + fireEvent.change(screen.getAllByTestId('date-picker')[0], { target: { value: '2026-05-02' } }); + await userEvent.click(screen.getByText('No link (standalone)')); + await userEvent.click(screen.getByRole('button', { name: /1\. Museum/ })); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].reservation_time).toBe('2026-05-02'); + }); + + it('FE-PLANNER-RESMODAL-084: the status picker switches the booking to confirmed', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Boat trip'); + await userEvent.click(screen.getByText('Pending')); + await userEvent.click(screen.getByRole('button', { name: 'Confirmed' })); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].status).toBe('confirmed'); + }); + + it('FE-PLANNER-RESMODAL-085: hotel check-in/check-out times land in the metadata and the accommodation', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const days = reviewDays(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel'); + // Hotels hide the start/end rows, so these three pickers are check-in / until / check-out. + const times = screen.getAllByTestId('time-picker'); + expect(times).toHaveLength(3); + fireEvent.change(times[0], { target: { value: '15:00' } }); + fireEvent.change(times[1], { target: { value: '22:00' } }); + fireEvent.change(times[2], { target: { value: '11:00' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].metadata).toEqual({ + check_in_time: '15:00', check_in_end_time: '22:00', check_out_time: '11:00', + }); + }); + + it('FE-PLANNER-RESMODAL-086: the open-file link on an attached file does not throw', async () => { + const res = buildReservation({ id: 16, type: 'other', title: 'Trip' }); + const attached = buildTripFile({ id: 80, original_name: 'ticket.pdf' }); + (attached as unknown as { reservation_id: number }).reservation_id = 16; + + render(); + const row = screen.getByText('ticket.pdf').closest('div') as HTMLElement; + await userEvent.click(within(row).getByRole('link')); + // The download is best-effort; the row must survive a failed fetch. + expect(screen.getByText('ticket.pdf')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-081: an accommodation that has not loaded yet leaves the hotel fields blank', () => { + const res = buildReservation({ id: 15, title: 'Grand Hotel', type: 'hotel' }); + (res as unknown as { accommodation_id: number }).accommodation_id = 99; + render(); + // Hotel place picker still shows its placeholder — no crash on the missing record. + expect(screen.getByText('—')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index 1afd90925..c17c2b329 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -1,15 +1,20 @@ -// FE-COMP-RES-001 to FE-COMP-RES-040 -import { render, screen, waitFor, within } from '../../../tests/helpers/render'; +// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-072 +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useSettingsStore } from '../../store/settingsStore'; import { usePermissionsStore } from '../../store/permissionsStore'; +import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildReservation, buildDay, buildPlace } from '../../../tests/helpers/factories'; +import { openFile } from '../../utils/fileDownload'; import ReservationsPanel from './ReservationsPanel'; vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://test/file') })); +vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn(async () => {}) })); const defaultProps = { tripId: 1, @@ -28,6 +33,9 @@ beforeEach(() => { seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } }); + server.use( + http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] })), + ); }); describe('ReservationsPanel', () => { @@ -506,4 +514,356 @@ describe('ReservationsPanel', () => { screen.getByTitle('Imported from AirTrail. A multi-leg flight with a layover has no single AirTrail flight to sync back to, so it stays as a one-time import.'), ).toBeInTheDocument(); }); + + // ── Type + traveler filters ───────────────────────────────────────────────── + + it('FE-PLANNER-RESP-050: a type chip narrows the list and "All" restores it', async () => { + const user = userEvent.setup(); + const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' }); + const hotel = buildReservation({ id: 2, title: 'Hotel stay', type: 'hotel', status: 'confirmed' }); + render(); + await user.click(screen.getByRole('button', { name: /^Flight\s*1$/ })); + expect(screen.getByText('Flight out')).toBeInTheDocument(); + expect(screen.queryByText('Hotel stay')).not.toBeInTheDocument(); + // The choice is remembered for this trip. + expect(JSON.parse(sessionStorage.getItem('trek-reservation-filters-1') || '[]')).toEqual(['flight']); + + await user.click(screen.getByRole('button', { name: /^All\s*2$/ })); + expect(screen.getByText('Hotel stay')).toBeInTheDocument(); + expect(sessionStorage.getItem('trek-reservation-filters-1')).toBeNull(); + }); + + it('FE-PLANNER-RESP-051: clicking an active type chip clears it again', async () => { + const user = userEvent.setup(); + const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' }); + const hotel = buildReservation({ id: 2, title: 'Hotel stay', type: 'hotel', status: 'confirmed' }); + render(); + const chip = screen.getByRole('button', { name: /^Flight\s*1$/ }); + await user.click(chip); + await user.click(screen.getByRole('button', { name: /^Flight\s*1$/ })); + expect(screen.getByText('Hotel stay')).toBeInTheDocument(); + expect(chip).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-052: a filter that matches nothing shows the "none found" hint, not the empty state', () => { + // A stored filter for a type this trip no longer has. + sessionStorage.setItem('trek-reservation-filters-1', JSON.stringify(['bus'])); + const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' }); + render(); + expect(screen.getByText('No places found')).toBeInTheDocument(); + expect(screen.queryByText('Flight out')).not.toBeInTheDocument(); + expect(document.querySelector('.trek--bookings')).toBeNull(); + }); + + it('FE-PLANNER-RESP-053: corrupt stored filters are ignored instead of crashing the panel', () => { + sessionStorage.setItem('trek-reservation-filters-1', '{not json'); + sessionStorage.setItem('trek-reservation-filters-1-travelers', '{not json'); + const res = buildReservation({ id: 1, title: 'Still here', type: 'flight', status: 'confirmed' }); + render(); + expect(screen.getByText('Still here')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-054: a persisted type filter is restored on mount', () => { + sessionStorage.setItem('trek-reservation-filters-1', JSON.stringify(['hotel'])); + const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' }); + const hotel = buildReservation({ id: 2, title: 'Hotel stay', type: 'hotel', status: 'confirmed' }); + render(); + expect(screen.getByText('Hotel stay')).toBeInTheDocument(); + expect(screen.queryByText('Flight out')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-055: the traveler filter keeps only that traveler\'s bookings and persists', async () => { + const user = userEvent.setup(); + const members = [{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }]; + const adas = buildReservation({ id: 1, title: 'Ada flight', type: 'flight', status: 'confirmed', travelers: [{ user_id: 1, username: 'ada', avatar_url: null }] } as any); + const bobs = buildReservation({ id: 2, title: 'Bob hotel', type: 'hotel', status: 'confirmed', travelers: [{ user_id: 2, username: 'bob', avatar_url: null }] } as any); + render(); + await user.click(screen.getByTitle('ada')); + expect(screen.getByText('Ada flight')).toBeInTheDocument(); + expect(screen.queryByText('Bob hotel')).not.toBeInTheDocument(); + expect(JSON.parse(sessionStorage.getItem('trek-reservation-filters-1-travelers') || '[]')).toEqual([1]); + + await user.click(screen.getByTitle('ada')); + expect(screen.getByText('Bob hotel')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-056: assigned travelers are shown on the card', () => { + const res = buildReservation({ id: 1, title: 'Shared flight', type: 'flight', status: 'confirmed', travelers: [{ user_id: 1, username: 'ada', avatar_url: null }] } as any); + render(); + expect(screen.getByText('Travelers')).toBeInTheDocument(); + }); + + // ── Toolbar actions ───────────────────────────────────────────────────────── + + it('FE-PLANNER-RESP-057: the import and AirTrail buttons appear only when enabled and call their handlers', async () => { + const user = userEvent.setup(); + const onImport = vi.fn(); + const onAirTrailImport = vi.fn(); + const { rerender } = render(); + // Both handlers given but the server features are off — nothing rendered. + expect(screen.queryByTitle('Import booking confirmations')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Import from AirTrail')).not.toBeInTheDocument(); + + rerender(); + const importBtn = screen.getByTitle('Import booking confirmations'); + const airtrailBtn = screen.getByTitle('Import from AirTrail'); + for (const btn of [importBtn, airtrailBtn]) { + act(() => { btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(btn.style.opacity).toBe('0.75'); + act(() => { btn.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(btn.style.opacity).toBe('1'); + } + await user.click(importBtn); + await user.click(airtrailBtn); + expect(onImport).toHaveBeenCalled(); + expect(onAirTrailImport).toHaveBeenCalled(); + }); + + it('FE-PLANNER-RESP-058: the add button dims on hover and restores on leave', () => { + render(); + const add = screen.getByText('Manual Booking').closest('button') as HTMLButtonElement; + act(() => { add.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(add.style.opacity).toBe('0.88'); + act(() => { add.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(add.style.opacity).toBe('1'); + }); + + // ── Card details ──────────────────────────────────────────────────────────── + + it('FE-PLANNER-RESP-059: a hotel with an accommodation range labels both days and hides the raw date row', () => { + const day1 = buildDay({ id: 401, date: '2025-07-01', day_number: 1, title: null } as any); + const day3 = buildDay({ id: 403, date: '2025-07-03', day_number: 3, title: null } as any); + const hotel = buildReservation({ + id: 1, title: 'Hotel Adlon', type: 'hotel', status: 'confirmed', + accommodation_start_day_id: 401, accommodation_end_day_id: 403, + reservation_time: '2025-07-01T15:00', + } as any); + render(); + expect(screen.getByText('Day 1')).toBeInTheDocument(); + expect(screen.getByText('Day 3')).toBeInTheDocument(); + // The stamped time row is suppressed for an accommodation-backed hotel. + expect(screen.queryByText('15:00')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-060: clicking a blurred booking code toggles it open and closed', () => { + seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } }); + const res = buildReservation({ id: 1, confirmation_number: 'TOGGLE1', status: 'confirmed' }); + render(); + const code = screen.getByText('TOGGLE1'); + expect(code.style.filter).toContain('blur'); + // A bare click (no hover) flips it open, a second one hides it again. + fireEvent.click(code); + expect(code.style.filter).toBe('none'); + fireEvent.click(code); + expect(code.style.filter).toContain('blur'); + }); + + it('FE-PLANNER-RESP-061: the edit button restores its idle styling after hover', () => { + const res = buildReservation({ id: 1, title: 'Hover me', status: 'confirmed' }); + render(); + const edit = screen.getByTitle('Edit'); + act(() => { edit.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }); + expect(edit.style.color).toBe('var(--text-primary)'); + act(() => { edit.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); }); + expect(edit.style.color).toBe('var(--text-faint)'); + }); + + it('FE-PLANNER-RESP-062: a failing delete surfaces an error instead of throwing', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn().mockRejectedValue(new Error('nope')); + const res = buildReservation({ id: 5, title: 'Undeletable', status: 'confirmed' }); + render(); + await user.click(screen.getByTitle('Delete')); + await user.click(await screen.findByText('Confirm')); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(5)); + // The dialog closed and the card is still there. + expect(screen.queryByText('Confirm')).not.toBeInTheDocument(); + expect(screen.getByText('Undeletable')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-063: an endpoint route replaces the airport metadata cells', () => { + const res = buildReservation({ + id: 1, title: 'Routed flight', type: 'flight', status: 'confirmed', + metadata: JSON.stringify({ airline: 'KLM', departure_airport: 'AMS', arrival_airport: 'JFK', price: 320, priceCurrency: 'EUR' }), + endpoints: [ + { role: 'from', sequence: 0, name: 'Amsterdam Schiphol' }, + { role: 'stop', sequence: 1, name: 'Reykjavik' }, + { role: 'to', sequence: 2, name: 'New York JFK' }, + ], + } as any); + render(); + expect(screen.getByText('Amsterdam Schiphol')).toBeInTheDocument(); + expect(screen.getByText('Reykjavik')).toBeInTheDocument(); + // The raw airport codes are dropped in favour of the route line… + expect(screen.queryByText('AMS')).not.toBeInTheDocument(); + // …but the price still gets its own cell. + expect(screen.getByText('320 EUR')).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-064: an attached file opens through the download helper', async () => { + const user = userEvent.setup(); + const res = buildReservation({ id: 7, status: 'confirmed' }); + const files = [{ id: 1, trip_id: 1, reservation_id: 7, original_name: 'ticket.pdf', url: '/uploads/ticket.pdf', filename: 'ticket.pdf', mime_type: 'application/pdf', created_at: '2025-01-01T00:00:00.000Z' }]; + render(); + await user.click(screen.getByText('ticket.pdf')); + expect(vi.mocked(openFile)).toHaveBeenCalledWith('/uploads/ticket.pdf'); + }); + + it('FE-PLANNER-RESP-065: an assignment without a place is skipped by the day/place lookup', () => { + const day = { ...buildDay({ id: 1, title: 'Day 1', date: '2025-06-01' }), day_number: 1 } as any; + const assignments = { + '1': [ + { id: 10, order_index: 1, day_id: 1, place_id: 2, notes: null, place: buildPlace({ name: 'Louvre' }) }, + { id: 11, order_index: 0, day_id: 1, place_id: null, notes: null, place: null }, + ], + }; + const withPlace = buildReservation({ id: 1, title: 'Linked', assignment_id: 10, status: 'confirmed' }); + const withoutPlace = buildReservation({ id: 2, title: 'Orphan', assignment_id: 11, status: 'confirmed' }); + render(); + expect(screen.getByText(/Louvre/)).toBeInTheDocument(); + expect(screen.getByText('Orphan')).toBeInTheDocument(); + }); + + // ── Section state ─────────────────────────────────────────────────────────── + + it('FE-PLANNER-RESP-066: a section collapsed in a previous session stays collapsed', () => { + localStorage.setItem('trek:bookings-confirmed-open:1', '0'); + const res = buildReservation({ id: 1, title: 'Hidden card', status: 'confirmed' }); + render(); + expect(screen.queryByText('Hidden card')).not.toBeInTheDocument(); + }); + + // ── Transit journeys (#1065) ──────────────────────────────────────────────── + + const transitJourney = (over: Record = {}) => buildReservation({ + id: 900, + title: 'Berlin Hbf → Hamburg Hbf', + type: 'transit', + status: 'confirmed', + day_id: 501, + reservation_time: '2025-06-01T08:00', + reservation_end_time: '2025-06-01T09:45', + metadata: JSON.stringify({ + transit: { + provider: 'transitous', duration: 6300, transfers: 1, walk_seconds: 300, + legs: [ + { mode: 'WALK', duration: 300, line: null }, + { mode: 'SUBWAY', duration: 900, line: 'U2', line_color: '#FF3300' }, + { mode: 'HIGHSPEED_RAIL', duration: 5100, line: 'ICE 599', line_color: null }, + ], + }, + }), + ...over, + } as any); + + it('FE-PLANNER-RESP-067: a transit journey renders its own section with legs, day and duration', () => { + const day = buildDay({ id: 501, date: '2025-06-01', day_number: 2, title: 'Travel day' } as any); + render(); + expect(screen.getByText('Automated public transit')).toBeInTheDocument(); + expect(screen.getByText('U2')).toBeInTheDocument(); + expect(screen.getByText('ICE 599')).toBeInTheDocument(); + expect(screen.getByText('Travel day')).toBeInTheDocument(); + expect(screen.getByText(/08:00/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-068: clicking a transit journey opens it through onEdit', async () => { + const user = userEvent.setup(); + const onEdit = vi.fn(); + render(); + await user.click(screen.getByText(/Hamburg Hbf/)); + expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 900 })); + }); + + it('FE-PLANNER-RESP-069: deleting a transit journey asks first and does not open the journey', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + const onEdit = vi.fn(); + render(); + await user.click(screen.getByTitle('Delete')); + expect(onEdit).not.toHaveBeenCalled(); + + // Cancel first — nothing is deleted. + await user.click(await screen.findByText('Cancel')); + expect(onDelete).not.toHaveBeenCalled(); + + await user.click(screen.getByTitle('Delete')); + const dialogButtons = (await screen.findByText('Cancel')).parentElement!.querySelectorAll('button'); + await user.click(dialogButtons[1]); + expect(onDelete).toHaveBeenCalledWith(900); + }); + + it('FE-PLANNER-RESP-070: a transit journey with unreadable metadata still renders its header', () => { + render(); + expect(screen.getByText(/Hamburg Hbf/)).toBeInTheDocument(); + expect(screen.queryByText('U2')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-071: a transit journey shows its first note line and traveler avatars', () => { + const res = transitJourney({ + notes: '**Reserve** a seat\nsecond line', + travelers: [{ user_id: 1, username: 'ada', avatar_url: null }], + }); + render(); + expect(screen.getByText('Reserve')).toBeInTheDocument(); + expect(screen.queryByText(/second line/)).not.toBeInTheDocument(); + }); + + // ── Reservation-detail plugin slot ────────────────────────────────────────── + + it('FE-PLANNER-RESP-072: a reservation-detail plugin mounts a frame on both card kinds', async () => { + seedStore(usePluginStore, { + plugins: [ + { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' }, + { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' }, + ], + }); + const booking = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' }); + render(); + await waitFor(() => expect(document.querySelectorAll('iframe[src*="seat-map"]')).toHaveLength(2)); + expect(document.querySelector('iframe[src*="dash-widget"]')).toBeNull(); + }); + + it('FE-PLANNER-RESP-073: interacting with a transit card\'s travelers or plugin frame does not open the journey', async () => { + seedStore(usePluginStore, { + plugins: [{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' }], + }); + const onEdit = vi.fn(); + const res = transitJourney({ travelers: [{ user_id: 1, username: 'ada', avatar_url: null }] }); + render(); + const frame = await waitFor(() => document.querySelector('iframe[src*="seat-map"]') as HTMLIFrameElement); + fireEvent.click(frame.parentElement!.parentElement!); + fireEvent.click(screen.getByText('ada').parentElement!); + expect(onEdit).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-RESP-074: the transit delete dialog closes on a backdrop click without deleting', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + render(); + await user.click(screen.getByTitle('Delete')); + const backdrop = await waitFor(() => document.querySelector('[style*="z-index: 3000"]') as HTMLElement); + fireEvent.click(backdrop); + await waitFor(() => expect(document.querySelector('[style*="z-index: 3000"]')).toBeNull()); + expect(onDelete).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-RESP-075: hovering a blurred code reveals it and leaving hides it again', () => { + seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } }); + const res = buildReservation({ id: 1, confirmation_number: 'HOVER1', status: 'confirmed' }); + render(); + const code = screen.getByText('HOVER1'); + fireEvent.mouseEnter(code); + expect(code.style.filter).toBe('none'); + fireEvent.mouseLeave(code); + expect(code.style.filter).toContain('blur'); + }); + + it('FE-PLANNER-RESP-076: entries with no resolvable date sink below the dated ones', () => { + const day = buildDay({ id: 601, date: '2025-08-02', day_number: 2 } as any); + const undated = buildReservation({ id: 1, title: 'Undated taxi', type: 'taxi', status: 'pending', created_at: '2025-01-01T00:00:00.000Z' }); + const dated = buildReservation({ id: 2, title: 'Dated train', type: 'train', status: 'pending', day_id: 601, created_at: '2025-02-01T00:00:00.000Z' } as any); + render(); + const text = document.body.textContent || ''; + expect(text.indexOf('Dated train')).toBeLessThan(text.indexOf('Undated taxi')); + }); }); diff --git a/client/src/components/Planner/SubscribeLinks.test.tsx b/client/src/components/Planner/SubscribeLinks.test.tsx new file mode 100644 index 000000000..b404a02a7 --- /dev/null +++ b/client/src/components/Planner/SubscribeLinks.test.tsx @@ -0,0 +1,112 @@ +// FE-PLANNER-SUBLINKS-001 to FE-PLANNER-SUBLINKS-009 +import { render, screen, fireEvent, waitFor, within, act } from '../../../tests/helpers/render'; +import { SubscribeLinks } from './SubscribeLinks'; + +const HTTPS = 'https://trek.example/api/trips/7/feed/abc.ics'; +const WEBCAL = 'webcal://trek.example/api/trips/7/feed/abc.ics'; + +function stubClipboard(writeText: (t: string) => Promise) { + Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText } }); + Object.defineProperty(window, 'isSecureContext', { configurable: true, writable: true, value: true }); +} + +afterEach(() => { + // Leave the globals clean for the test that exercises the insecure-context fallback. + Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: undefined }); + Object.defineProperty(window, 'isSecureContext', { configurable: true, writable: true, value: false }); +}); + +describe('SubscribeLinks', () => { + it('FE-PLANNER-SUBLINKS-001: Google deep link carries the webcal URL url-encoded in cid', () => { + render(); + const google = screen.getByRole('link', { name: /Add to Google Calendar/i }); + expect(google).toHaveAttribute( + 'href', + `https://www.google.com/calendar/render?cid=${encodeURIComponent(WEBCAL)}`, + ); + expect(google).toHaveAttribute('target', '_blank'); + expect(google).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('FE-PLANNER-SUBLINKS-002: Apple/Outlook link points at the raw webcal URL', () => { + render(); + expect(screen.getByRole('link', { name: /Add to Apple Calendar \/ Outlook/i })).toHaveAttribute('href', WEBCAL); + }); + + it('FE-PLANNER-SUBLINKS-003: the manual fallback lists both raw URLs', () => { + render(); + expect(screen.getByText('Or copy a link manually')).toBeInTheDocument(); + expect(screen.getByText(HTTPS)).toBeInTheDocument(); + expect(screen.getByText(WEBCAL)).toBeInTheDocument(); + expect(screen.getByText(/paste into/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-SUBLINKS-004: copying the https row writes it to the clipboard', async () => { + const writeText = vi.fn(async () => {}); + stubClipboard(writeText); + render(); + fireEvent.click(screen.getAllByTitle('Copy')[0]); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(HTTPS)); + }); + + it('FE-PLANNER-SUBLINKS-005: the copied row switches to the check icon and resets after 2s', async () => { + const writeText = vi.fn(async () => {}); + stubClipboard(writeText); + render(); + const httpsCopy = screen.getAllByTitle('Copy')[0]; + fireEvent.click(httpsCopy); + // lucide puts the icon name on the svg class list. + await waitFor(() => expect(httpsCopy.querySelector('.lucide-check')).not.toBeNull()); + + vi.useFakeTimers(); + try { + // The component already scheduled its 2s reset with the real clock; drive a + // fresh copy under fake timers so the reset is deterministic. + fireEvent.click(httpsCopy); + await act(async () => { await Promise.resolve(); }); + act(() => { vi.advanceTimersByTime(2100); }); + expect(httpsCopy.querySelector('.lucide-check')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('FE-PLANNER-SUBLINKS-006: only the clicked row shows the copied state', async () => { + const writeText = vi.fn(async () => {}); + stubClipboard(writeText); + render(); + const buttons = screen.getAllByTitle('Copy'); + fireEvent.click(buttons[1]); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBCAL)); + await waitFor(() => expect(buttons[1].querySelector('.lucide-check')).not.toBeNull()); + expect(buttons[0].querySelector('.lucide-check')).toBeNull(); + }); + + it('FE-PLANNER-SUBLINKS-007: falls back to execCommand when the clipboard API is unavailable', async () => { + const execCommand = vi.fn(() => true); + Object.defineProperty(document, 'execCommand', { configurable: true, writable: true, value: execCommand }); + render(); + const httpsCopy = screen.getAllByTitle('Copy')[0]; + fireEvent.click(httpsCopy); + await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy')); + // The temporary textarea is removed again. + expect(document.querySelector('textarea')).toBeNull(); + await waitFor(() => expect(httpsCopy.querySelector('.lucide-check')).not.toBeNull()); + }); + + it('FE-PLANNER-SUBLINKS-008: a rejected clipboard write leaves the row unmarked', async () => { + stubClipboard(vi.fn(async () => { throw new Error('denied'); })); + render(); + const httpsCopy = screen.getAllByTitle('Copy')[0]; + fireEvent.click(httpsCopy); + await act(async () => { await Promise.resolve(); }); + expect(httpsCopy.querySelector('.lucide-check')).toBeNull(); + }); + + it('FE-PLANNER-SUBLINKS-009: each URL row is labelled with its calendar target', () => { + render(); + const details = screen.getByText('Or copy a link manually').closest('details') as HTMLElement; + expect(within(details).getByText(/Google Calendar/)).toBeInTheDocument(); + expect(within(details).getByText(/Apple Calendar \/ Outlook/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Planner/TransitJourneyModal.test.tsx b/client/src/components/Planner/TransitJourneyModal.test.tsx index 8c9ba800f..f0756e049 100644 --- a/client/src/components/Planner/TransitJourneyModal.test.tsx +++ b/client/src/components/Planner/TransitJourneyModal.test.tsx @@ -1,5 +1,5 @@ -// FE-PLANNER-TRANSITJOURNEY-001 to 005 — the journey view for a saved transit entry. -import { render, screen, waitFor } from '../../../tests/helpers/render' +// FE-PLANNER-TRANSITJOURNEY-001 to 016 — the journey view for a saved transit entry. +import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useAuthStore } from '../../store/authStore' @@ -132,4 +132,125 @@ describe('TransitJourneyModal', () => { expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /Close/ })).toBeInTheDocument() }) + + it('FE-PLANNER-TRANSITJOURNEY-009: metadata stored as a JSON string is parsed into the itinerary', () => { + const res = makeReservation() + res.metadata = JSON.stringify(res.metadata) + render() + expect(screen.getByText('U2')).toBeInTheDocument() + expect(screen.getByText('Transfers')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSITJOURNEY-010: unparsable metadata degrades to a journey without an itinerary', () => { + const res = { ...makeReservation(), metadata: '{not json' } + render() + // Title/date header still renders, the stat tiles and legs do not. + expect(screen.getByText('Fernsehturm')).toBeInTheDocument() + expect(screen.queryByText('Transfers')).not.toBeInTheDocument() + expect(screen.queryByText('U2')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSITJOURNEY-011: a zero-duration, zero-walk journey renders em dashes, not "0 min"', () => { + const res = makeReservation() + res.metadata = { transit: { ...res.metadata.transit, duration: 0, walk_seconds: 12, transfers: 0 } } + render() + // Duration + walking tiles both fall back to the dash; transfers shows 0. + expect(screen.getAllByText('—')).toHaveLength(2) + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSITJOURNEY-012: Escape while renaming restores the previous title', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Edit')) + const input = screen.getByDisplayValue('Fernsehturm → Zoo') + await user.clear(input) + await user.type(input, 'Scrapped') + await user.keyboard('{Escape}') + // Inline editing closed and the original title is back — save stays disabled. + expect(screen.queryByDisplayValue('Scrapped')).not.toBeInTheDocument() + expect(screen.getByText('Fernsehturm')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled() + }) + + it('FE-PLANNER-TRANSITJOURNEY-013: blurring the title input closes inline editing but keeps the typed name', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Edit')) + const input = screen.getByDisplayValue('Fernsehturm → Zoo') + await user.clear(input) + await user.type(input, 'Zum Zoo') + fireEvent.blur(input) + expect(screen.queryByDisplayValue('Zum Zoo')).not.toBeInTheDocument() + expect(screen.getByText('Zum Zoo')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled() + }) + + it('FE-PLANNER-TRANSITJOURNEY-014: switching to a different reservation re-seeds title and notes', () => { + const first = { ...makeReservation(), notes: 'first note' } + const { rerender } = render() + expect(document.querySelector('.collab-note-md')?.textContent).toContain('first note') + + const second = { ...makeReservation(), id: 8, title: 'Zoo → Hbf', notes: '' } + rerender() + expect(screen.getByText('Hbf')).toBeInTheDocument() + // No notes on the new entry → the write tab is active again. + expect(screen.getByPlaceholderText(/notes/i)).toBeInTheDocument() + }) +}) + +describe('TransitJourneyModal (mobile viewport)', () => { + const desktopWidth = window.innerWidth + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 }) + }) + + afterEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: desktopWidth }) + }) + + function makeMobileReservation() { + const res = makeReservation() + res.metadata = { + transit: { + provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240, + legs: [ + { mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } }, + { + mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', + headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6, + from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, + to: { name: 'Zoo', time: '09:00' }, + }, + // No line/colour/headsign/agency and no times — exercises every fallback. + { mode: 'BUS', from: { name: 'Zoo' }, to: { name: 'Kudamm' } }, + ], + }, + } + return res + } + + it('FE-PLANNER-TRANSITJOURNEY-015: renders the per-leg rail with platform, headsign and meta line', () => { + render() + expect(screen.getByText('U2')).toBeInTheDocument() + expect(screen.getByText(/Platform 2/)).toBeInTheDocument() + expect(screen.getByText('Ruhleben')).toBeInTheDocument() + // mins · stops · agency collapse into one quiet line. + expect(screen.getByText(/24 min · 6 stops · BVG/)).toBeInTheDocument() + expect(screen.getByText('08:36')).toBeInTheDocument() + // The meta-less leg falls back to its mode as the badge and shows no meta line. + expect(screen.getByText('BUS')).toBeInTheDocument() + expect(screen.getByText('Kudamm')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSITJOURNEY-016: mobile stat tiles stay iconless but keep value + caption', () => { + render() + expect(screen.getByText('Transfers')).toBeInTheDocument() + expect(screen.getByText('Walking')).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + // Delete collapses to an icon-only button on a phone. + const deleteBtn = screen.getByRole('button', { name: 'Delete' }) + expect(deleteBtn.textContent?.trim()).toBe('') + }) }) diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index fcb270ed3..a9c003afb 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -1,15 +1,17 @@ -// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 — the transit search panel +// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-027 — the transit search panel // (embedded as the TransportModal's Automated mode). -import { render, screen, waitFor } from '../../../tests/helpers/render' +import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories' +import type { Accommodation, Day } from '../../types' import TransitSearchPanel from './TransitSearchPanel' -const { transitApiMock } = vi.hoisted(() => ({ +const { transitApiMock, toastErrors } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() }, + toastErrors: [] as string[], })) vi.mock('../../api/client', async (importOriginal) => { @@ -17,7 +19,9 @@ vi.mock('../../api/client', async (importOriginal) => { return { ...actual, transitApi: transitApiMock } }) -vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) })) +vi.mock('../shared/Toast', () => ({ + useToast: () => ({ error: (m: string) => { toastErrors.push(m) }, success: vi.fn() }), +})) // Berlin, summer time (UTC+2): 06:30Z departs 08:30 local, 07:00Z arrives 09:00. const ITINERARY = { @@ -60,6 +64,7 @@ async function pickFromAndTo(user: ReturnType) { beforeEach(() => { resetAllStores() vi.clearAllMocks() + toastErrors.length = 0 seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any) }) @@ -199,4 +204,305 @@ describe('TransitSearchPanel', () => { expect((inputs[0] as HTMLInputElement).value).toBe('') expect((inputs[1] as HTMLInputElement).value).toBe('Fernsehturm') }) + + // ── quick picks ───────────────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSIT-010: quick picks add located accommodations and drop duplicates', async () => { + const user = userEvent.setup() + const accommodations = [ + { place_lat: 52.5, place_lng: 13.3, place_name: 'Hotel Berlin' }, + // No coordinates / no name — neither may become a quick pick. + { place_lat: null, place_lng: null, place_name: 'Unlocated Inn' }, + { place_lat: 52.4, place_lng: 13.2, place_name: null }, + ] as unknown as Accommodation[] + render() + const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(fromInput) + expect(await screen.findByText('Hotel Berlin')).toBeInTheDocument() + expect(screen.getAllByText('Fernsehturm')).toHaveLength(1) + expect(screen.queryByText('Unlocated Inn')).not.toBeInTheDocument() + expect(screen.queryByText('No coords')).not.toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-011: hovering a quick pick and a geocode result toggles the row background', async () => { + const user = userEvent.setup() + render() + const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(fromInput) + const quick = (await screen.findByText('Fernsehturm')).closest('button') as HTMLButtonElement + fireEvent.mouseEnter(quick) + expect(quick.style.background).toBe('var(--bg-hover)') + fireEvent.mouseLeave(quick) + expect(quick.style.background).toBe('none') + + transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Ostbahnhof', lat: 52.51, lng: 13.43, type: 'STOP', area: 'Berlin' }] }) + await user.click(toInput) + await user.type(toInput, 'Ost') + const hit = (await screen.findByText('Ostbahnhof')).closest('button') as HTMLButtonElement + fireEvent.mouseEnter(hit) + expect(hit.style.background).toBe('var(--bg-hover)') + fireEvent.mouseLeave(hit) + expect(hit.style.background).toBe('none') + }) + + it('FE-PLANNER-TRANSIT-012: a failing stop lookup clears the result list instead of throwing', async () => { + const user = userEvent.setup() + transitApiMock.geocode.mockRejectedValueOnce(new Error('offline')) + render() + const [, toInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(toInput) + await user.type(toInput, 'Zoo') + await waitFor(() => expect(transitApiMock.geocode).toHaveBeenCalled()) + // No dropdown rows, and the panel still works. + await waitFor(() => expect(screen.queryByText('Fernsehturm')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: /^Search$/ })).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-013: a query shorter than two characters never reaches the API', async () => { + const user = userEvent.setup() + render() + const [, toInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.type(toInput, 'Z') + await new Promise(r => setTimeout(r, 350)) + expect(transitApiMock.geocode).not.toHaveBeenCalled() + }) + + // ── filters ───────────────────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSIT-014: deselecting a mode narrows the requested modes, the last one cannot be dropped', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) + render() + await pickFromAndTo(user) + for (const label of ['Train', 'Subway', 'Tram', 'Ferry', 'Cable car']) { + await user.click(screen.getByText(label)) + } + // Only "Bus" is left — clicking it must not empty the selection. + await user.click(screen.getByText('Bus')) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled()) + expect(transitApiMock.plan.mock.calls[0][0].modes).toBe('BUS,COACH') + }) + + it('FE-PLANNER-TRANSIT-015: re-enabling every mode sends no mode filter at all', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByText('Ferry')) + await user.click(screen.getByText('Ferry')) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled()) + expect(transitApiMock.plan.mock.calls[0][0].modes).toBeUndefined() + }) + + it('FE-PLANNER-TRANSIT-016: switching back to Depart resets arriveBy', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: 'Arrive' })) + await user.click(screen.getByRole('button', { name: 'Depart' })) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled()) + expect(transitApiMock.plan.mock.calls[0][0].arriveBy).toBe(false) + }) + + it('FE-PLANNER-TRANSIT-017: "Less walking" ranks the least-walking itinerary first, "Best route" restores the original order', async () => { + const user = userEvent.setup() + const lessWalking = { ...ITINERARY, startTime: '2025-06-01T06:45:00Z', endTime: '2025-06-01T07:25:00Z', walkSeconds: 60 } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, lessWalking] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await screen.findByText(/08:30 – 09:00/) + + await user.click(screen.getByText('Less walking')) + const headings = () => screen.getAllByText(/\d{2}:\d{2} – \d{2}:\d{2}/) + expect(headings()[0].textContent).toContain('08:45') + + await user.click(screen.getByText('Best route')) + expect(headings()[0].textContent).toContain('08:30') + }) + + // ── search + add edge cases ───────────────────────────────────────────────── + + it('FE-PLANNER-TRANSIT-018: a day without a date never triggers a search', async () => { + const user = userEvent.setup() + const dateless = buildDay({ id: 11, trip_id: 1, date: null, title: 'Unscheduled' } as unknown as Partial) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + expect(transitApiMock.plan).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-TRANSIT-019: an unresolvable coordinate falls back to UTC instead of throwing', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) + render() + const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(fromInput) + await user.click(await screen.findByText('Nowhere')) + transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] }) + await user.click(toInput) + await user.type(toInput, 'Zoo') + await user.click(await screen.findByText(/Zoologischer Garten/)) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled()) + // 09:00 read as UTC because tz-lookup rejected the coordinate. + expect(transitApiMock.plan.mock.calls[0][0].time).toBe('2025-06-01T09:00:00.000Z') + }) + + it('FE-PLANNER-TRANSIT-020: a multi-transfer itinerary persists an endpoint per transfer stop', async () => { + const user = userEvent.setup() + const onAdd = vi.fn().mockResolvedValue({}) + const twoLegs = { + ...ITINERARY, + transfers: 2, + legs: [ + ITINERARY.legs[1], + { ...ITINERARY.legs[1], mode: 'BUS', line: '100', from: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T06:50:00Z', scheduledTime: null, track: null }, to: { name: 'Hauptbahnhof', lat: 52.525, lng: 13.369, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null } }, + ], + } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [twoLegs] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 09:00/)) + await user.click(await screen.findByRole('button', { name: 'Add to day' })) + + await waitFor(() => expect(onAdd).toHaveBeenCalled()) + const endpoints = onAdd.mock.calls[0][0].endpoints + expect(endpoints).toHaveLength(3) + expect(endpoints[1]).toMatchObject({ role: 'stop', sequence: 1, name: 'Zoologischer Garten', local_time: '09:00' }) + expect(endpoints[2]).toMatchObject({ role: 'to', sequence: 2 }) + }) + + it('FE-PLANNER-TRANSIT-021: an after-midnight arrival is attached to the following trip day', async () => { + const user = userEvent.setup() + const onAdd = vi.fn().mockResolvedValue({}) + const nextDay = buildDay({ id: 11, trip_id: 1, date: '2025-06-02', title: 'Day 2' }) + const overnight = { ...ITINERARY, startTime: '2025-06-01T21:00:00Z', endTime: '2025-06-01T23:30:00Z' } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [overnight] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/23:00 – 01:30/)) + await user.click(await screen.findByRole('button', { name: 'Add to day' })) + + await waitFor(() => expect(onAdd).toHaveBeenCalled()) + const payload = onAdd.mock.calls[0][0] + expect(payload.day_id).toBe(10) + expect(payload.end_day_id).toBe(11) + expect(payload.reservation_end_time).toBe('2025-06-02T01:30') + }) + + it('FE-PLANNER-TRANSIT-022: a failing save toasts instead of leaving the button stuck', async () => { + const user = userEvent.setup() + const onAdd = vi.fn().mockRejectedValue(new Error('save failed')) + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 09:00/)) + await user.click(await screen.findByRole('button', { name: 'Add to day' })) + await waitFor(() => expect(toastErrors).toContain('Unknown error')) + expect(await screen.findByRole('button', { name: 'Add to day' })).toBeEnabled() + }) + + it('FE-PLANNER-TRANSIT-023: losing the day date between search and add blocks the save', async () => { + const user = userEvent.setup() + const onAdd = vi.fn().mockResolvedValue({}) + const dateless = buildDay({ id: 10, trip_id: 1, date: null, title: 'Unscheduled' } as unknown as Partial) + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) + const props = makeProps({ onAdd }) + const { rerender } = render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 09:00/)) + + rerender() + await user.click(await screen.findByRole('button', { name: 'Add to day' })) + expect(onAdd).not.toHaveBeenCalled() + }) + + // ── itinerary rendering ───────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSIT-024: durations of an hour or more render as hours and minutes', async () => { + const user = userEvent.setup() + const long = { ...ITINERARY, duration: 5400, endTime: '2025-06-01T08:00:00Z' } + const exact = { ...ITINERARY, startTime: '2025-06-01T10:00:00Z', endTime: '2025-06-01T11:00:00Z', duration: 3600 } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [long, exact] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + expect(await screen.findByText('1 h 30 min')).toBeInTheDocument() + expect(screen.getByText('1 h')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-025: every transit mode gets its own line badge icon', async () => { + const user = userEvent.setup() + const leg = (mode: string, line: string) => ({ + ...ITINERARY.legs[1], mode, line, lineColor: null, lineTextColor: null, + }) + const mixed = { + ...ITINERARY, + legs: [leg('BUS', 'B1'), leg('TRAM', 'M4'), leg('FERRY', 'F10'), leg('FUNICULAR', 'FU'), leg('AERIAL_LIFT', 'AL'), leg('HIGHSPEED_RAIL', 'ICE 599')], + } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [mixed] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await screen.findByText('B1') + const icon = (text: string) => screen.getAllByText(text)[0].closest('span')?.querySelector('svg')?.getAttribute('class') + expect(icon('B1')).toContain('lucide-bus') + expect(icon('M4')).toContain('lucide-tram-front') + expect(icon('F10')).toContain('lucide-sailboat') + expect(icon('FU')).toContain('lucide-cable-car') + expect(icon('AL')).toContain('lucide-cable-car') + // Anything else (rail and friends) falls through to lucide's `Train`, which + // is an alias of TramFront — so rail and tram currently share a glyph. + expect(icon('ICE 599')).toContain('lucide-tram-front') + }) + + it('FE-PLANNER-TRANSIT-026: a leg without a usable timestamp renders an empty time cell, not "Invalid Date"', async () => { + const user = userEvent.setup() + const broken = { + ...ITINERARY, + legs: [ + { ...ITINERARY.legs[1], from: { ...ITINERARY.legs[1].from, time: null } }, + { ...ITINERARY.legs[1], line: 'U5', from: { ...ITINERARY.legs[1].from, name: 'Frankfurter Tor', time: 'not-a-time' } }, + ], + } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [broken] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 09:00/)) + expect(await screen.findByText('Frankfurter Tor')).toBeInTheDocument() + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-027: an expanded itinerary lists agencies, platform and walking distance', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 09:00/)) + expect(await screen.findByText('BVG')).toBeInTheDocument() + expect(screen.getByText(/Platform 2/)).toBeInTheDocument() + expect(screen.getByText('Walk to Alexanderplatz')).toBeInTheDocument() + expect(screen.getByText('300 m')).toBeInTheDocument() + expect(screen.getByText('6 stops')).toBeInTheDocument() + }) }) diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 07a1d1303..9f6ca6fd2 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -1,5 +1,5 @@ -// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021 -import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; +// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-053 +import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -17,6 +17,9 @@ import { buildTripFile, } from '../../../tests/helpers/factories'; import { TransportModal } from './TransportModal'; +import type { Day, Reservation } from '../../types'; +import type { BookingReviewDraft } from './parsedItemToDraft'; +import type { TripMember } from '../Budget/BudgetPanelMemberChips'; vi.mock('react-router-dom', async (importActual) => { const actual = await importActual(); @@ -556,4 +559,574 @@ describe('TransportModal', () => { expect(payload.day_id).toBe(10); expect(payload.end_day_id).toBe(11); }); + + // ── Multi-leg flights (#872) ─────────────────────────────────────────────── + + const routeDays = [ + { id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Depart Day' }, + { id: 11, trip_id: 1, day_number: 2, date: '2026-08-02', title: 'Stop Day' }, + { id: 12, trip_id: 1, day_number: 3, date: '2026-08-03', title: 'Arrive Day' }, + ] as unknown as Day[]; + + /** The route cards have no test ids — each day picker is found via its own label. */ + async function pickDay(labelText: string, index: number, dayTitle: string) { + const label = screen.getAllByText(labelText)[index]; + const trigger = (label.parentElement as HTMLElement).querySelector('button') as HTMLButtonElement; + await userEvent.click(trigger); + await userEvent.click(screen.getByRole('button', { name: new RegExp(`^${dayTitle}`) })); + } + + it('FE-PLANNER-TRANSMODAL-032: a flight with an added stop saves per-leg metadata and from/stop/to endpoints', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK'); + await userEvent.click(screen.getByRole('button', { name: /Add stop/i })); + + const airports = screen.getAllByTestId('airport-select'); + expect(airports).toHaveLength(3); + fireEvent.change(airports[0], { target: { value: 'BRU' } }); + fireEvent.change(airports[1], { target: { value: 'HEL' } }); + fireEvent.change(airports[2], { target: { value: 'JFK' } }); + + // Times run: wp0 departure, wp1 arrival, wp1 departure, wp2 arrival. + const times = screen.getAllByTestId('time-picker'); + fireEvent.change(times[0], { target: { value: '08:00' } }); + fireEvent.change(times[1], { target: { value: '12:30' } }); + fireEvent.change(times[2], { target: { value: '14:00' } }); + fireEvent.change(times[3], { target: { value: '15:00' } }); + + const airlines = screen.getAllByPlaceholderText('Lufthansa'); + fireEvent.change(airlines[0], { target: { value: 'Brussels Airlines' } }); + fireEvent.change(airlines[1], { target: { value: 'Finnair' } }); + const numbers = screen.getAllByPlaceholderText('LH 123'); + fireEvent.change(numbers[0], { target: { value: 'SN 1234' } }); + fireEvent.change(numbers[1], { target: { value: 'AY 15' } }); + const seats = screen.getAllByPlaceholderText('12A'); + fireEvent.change(seats[0], { target: { value: '4F' } }); + + // The last leg lands a day later than it departs. + await pickDay('Arrival', 1, 'Arrive Day'); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + + expect(payload.endpoints.map((e: { role: string; code: string }) => `${e.role}:${e.code}`)).toEqual(['from:BRU', 'stop:HEL', 'to:JFK']); + expect(payload.metadata.legs).toHaveLength(2); + expect(payload.metadata.legs[0]).toMatchObject({ + from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', seat: '4F', + dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30', + }); + expect(payload.metadata.legs[1]).toMatchObject({ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', arr_day_id: 12, arr_time: '15:00' }); + // Flat mirrors of the first leg for legacy readers. + expect(payload.metadata).toMatchObject({ airline: 'Brussels Airlines', flight_number: 'SN 1234', seat: '4F', departure_airport: 'BRU', arrival_airport: 'JFK' }); + expect(payload.day_id).toBe(10); + expect(payload.end_day_id).toBe(12); + expect(payload.reservation_time).toBe('2026-08-01T08:00'); + expect(payload.reservation_end_time).toBe('2026-08-03T15:00'); + }); + + it('FE-PLANNER-TRANSMODAL-033: removing a flight stop collapses the route back to two waypoints', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → JFK'); + await userEvent.click(screen.getByRole('button', { name: /Add stop/i })); + expect(screen.getAllByTestId('airport-select')).toHaveLength(3); + + // Only the intermediate stop carries a delete button. + await userEvent.click(screen.getByRole('button', { name: /^Delete$/i })); + expect(screen.getAllByTestId('airport-select')).toHaveLength(2); + + const airports = screen.getAllByTestId('airport-select'); + fireEvent.change(airports[0], { target: { value: 'BRU' } }); + fireEvent.change(airports[1], { target: { value: 'JFK' } }); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']); + expect(payload.metadata.legs).toBeUndefined(); + }); + + it('FE-PLANNER-TRANSMODAL-034: the departure-date picker moves the whole flight to another day', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400'); + fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'FRA' } }); + fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } }); + await pickDay('Departure', 0, 'Stop Day'); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].day_id).toBe(11); + }); + + // ── Multi-leg trains (#1150) — edit round-trip ───────────────────────────── + + function multiLegTrain(): Reservation { + const res = buildReservation({ id: 30, title: 'Berlin → München', type: 'train' }); + return Object.assign(res, { + day_id: 10, + end_day_id: 12, + reservation_time: '2026-08-01T08:00', + reservation_end_time: '2026-08-03T18:00', + metadata: { + train_number: 'ICE 100', platform: '5', seat: '11A', + legs: [ + { from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', seat: '11A', dep_day_id: 10, dep_time: '08:00', arr_day_id: 11, arr_time: '12:00', day_positions: { '10': 2 } }, + { from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 200', platform: '7', seat: '12B', dep_day_id: 11, dep_time: '13:00', arr_day_id: 12, arr_time: '18:00' }, + ], + }, + endpoints: [ + { id: 1, reservation_id: 30, role: 'from', sequence: 0, name: 'Berlin Hbf', code: null, lat: 52.52, lng: 13.37, timezone: null, local_date: '2026-08-01', local_time: '08:00' }, + { id: 2, reservation_id: 30, role: 'stop', sequence: 1, name: 'Frankfurt Hbf', code: null, lat: 50.107, lng: 8.663, timezone: null, local_date: '2026-08-02', local_time: '13:00' }, + { id: 3, reservation_id: 30, role: 'to', sequence: 2, name: 'München Hbf', code: null, lat: 48.14, lng: 11.558, timezone: null, local_date: '2026-08-03', local_time: '18:00' }, + ], + }) as unknown as Reservation; + } + + it('FE-PLANNER-TRANSMODAL-035: editing a multi-leg train seeds every station from metadata.legs', () => { + render(); + + expect(screen.getAllByTestId('location-select')).toHaveLength(3); + const numbers = screen.getAllByPlaceholderText('ICE 123') as HTMLInputElement[]; + expect(numbers.map(i => i.value)).toEqual(['ICE 100', 'ICE 200']); + const platforms = screen.getAllByPlaceholderText('12') as HTMLInputElement[]; + expect(platforms.map(i => i.value)).toEqual(['5', '7']); + const times = screen.getAllByTestId('time-picker') as HTMLInputElement[]; + // wp0 dep, wp1 arr, wp1 dep, wp2 arr + expect(times.map(i => i.value)).toEqual(['08:00', '12:00', '13:00', '18:00']); + }); + + it('FE-PLANNER-TRANSMODAL-036: re-saving a multi-leg train keeps its legs and day-plan positions', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.endpoints.map((e: { role: string; name: string }) => `${e.role}:${e.name}`)).toEqual([ + 'from:Berlin Hbf', 'stop:Frankfurt Hbf', 'to:München Hbf', + ]); + expect(payload.metadata.legs).toHaveLength(2); + // The planner owns the per-leg positions — an edit must not drop them. + expect(payload.metadata.legs[0].day_positions).toEqual({ '10': 2 }); + expect(payload.metadata).toMatchObject({ train_number: 'ICE 100', platform: '5', seat: '11A' }); + expect(payload.day_id).toBe(10); + expect(payload.end_day_id).toBe(12); + }); + + it('FE-PLANNER-TRANSMODAL-037: a train with no arrival day dates its arrival from the departure day', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Train$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'RE 9'); + const stations = screen.getAllByTestId('location-select'); + fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } }); + fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } }); + fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '09:00' } }); + fireEvent.change(screen.getAllByTestId('time-picker')[1], { target: { value: '10:05' } }); + fireEvent.change(screen.getAllByPlaceholderText('12')[0], { target: { value: '3' } }); + fireEvent.change(screen.getAllByPlaceholderText('42A')[0], { target: { value: '21C' } }); + // Blank out the arrival day so only the departure day is known. + await pickDay('Arrival', 0, '—'); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.reservation_end_time).toBe('2026-08-01T10:05'); + expect(payload.endpoints[1].local_date).toBe('2026-08-01'); + expect(payload.metadata).toMatchObject({ platform: '3', seat: '21C' }); + }); + + it('FE-PLANNER-TRANSMODAL-038: a single-leg flight mirrors the seat into the flat metadata', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400'); + fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'FRA' } }); + fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } }); + fireEvent.change(screen.getAllByPlaceholderText('12A')[0], { target: { value: '7A' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.metadata.seat).toBe('7A'); + expect(payload.metadata.legs).toBeUndefined(); + }); + + // ── Non-flight form fields ───────────────────────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-039: a car rental keeps its pick-up/return days, times and booking code', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Car$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Sixt compact'); + // The labels switch to rental wording for cars. + expect(screen.getByText('Pickup')).toBeInTheDocument(); + expect(screen.getByText('Return time')).toBeInTheDocument(); + + const locations = screen.getAllByTestId('location-select'); + fireEvent.change(locations[0], { target: { value: 'Berlin Airport' } }); + fireEvent.change(locations[1], { target: { value: 'Munich Airport' } }); + await pickDay('Pickup', 0, 'Depart Day'); + await pickDay('Return', 0, 'Arrive Day'); + fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '09:30' } }); + fireEvent.change(screen.getAllByTestId('time-picker')[1], { target: { value: '17:45' } }); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. ABC12345/i), 'SIXT-42'); + await userEvent.type(screen.getByPlaceholderText(/Additional notes/i), 'Full-to-full tank'); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload).toMatchObject({ + type: 'car', day_id: 10, end_day_id: 12, + reservation_time: '2026-08-01T09:30', reservation_end_time: '2026-08-03T17:45', + confirmation_number: 'SIXT-42', notes: 'Full-to-full tank', + }); + expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Airport', 'Munich Airport']); + }); + + it('FE-PLANNER-TRANSMODAL-040: the status picker switches the booking to confirmed', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400'); + await userEvent.click(screen.getByText('Pending')); + await userEvent.click(screen.getByRole('button', { name: 'Confirmed' })); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].status).toBe('confirmed'); + }); + + // ── Submit guards + failures ─────────────────────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-041: submitting the form with an empty title is a no-op', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.submit(document.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(onSave).not.toHaveBeenCalled()); + }); + + it('FE-PLANNER-TRANSMODAL-042: a rejected save surfaces the error message and re-enables the button', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const onSave = vi.fn().mockRejectedValue(new Error('Server exploded')); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Server exploded', 'error', undefined)); + await waitFor(() => expect(screen.getByRole('button', { name: /^Add$/i })).toBeEnabled()); + delete window.__addToast; + }); + + it('FE-PLANNER-TRANSMODAL-043: a closed modal does not seed the form from the reservation', () => { + const res = buildReservation({ title: 'Hidden Flight', type: 'flight' }); + render(); + expect(screen.queryByDisplayValue('Hidden Flight')).not.toBeInTheDocument(); + }); + + // ── Travelers (#1517) ────────────────────────────────────────────────────── + + const tripMembers: TripMember[] = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, + ]; + + it('FE-PLANNER-TRANSMODAL-044: assigned travelers are written back after the save resolves', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 60 }); + let body: { user_ids: number[] } | null = null; + server.use( + http.put('/api/trips/1/reservations/60/travelers', async ({ request }) => { + body = (await request.json()) as { user_ids: number[] }; + return HttpResponse.json({ travelers: [] }); + }), + ); + + render(); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400'); + await userEvent.click(screen.getByText('bob')); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body!.user_ids).toEqual([2]); + }); + + it('FE-PLANNER-TRANSMODAL-045: a failing traveler write surfaces an error toast', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const onSave = vi.fn().mockResolvedValue({ id: 61 }); + server.use( + http.put('/api/trips/1/reservations/61/travelers', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + + render(); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400'); + await userEvent.click(screen.getByText('alice')); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined)); + delete window.__addToast; + }); + + // ── Linked cost actions ───────────────────────────────────────────────────── + + function seedLinkedCost() { + seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + budgetItems: [ + { id: 3, trip_id: 1, name: 'Flight ticket', total_price: 320, currency: 'EUR', category: 'transport', reservation_id: 50, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null }, + ], + }); + } + + it('FE-PLANNER-TRANSMODAL-046: editing the linked cost saves the booking, then opens that item', async () => { + seedLinkedCost(); + const onSave = vi.fn().mockResolvedValue({ id: 50 }); + const onOpenExpense = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /^Edit$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onOpenExpense).toHaveBeenCalledWith({ editItem: expect.objectContaining({ id: 3 }) }); + }); + + it('FE-PLANNER-TRANSMODAL-047: a failing cost removal reports the error', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + seedLinkedCost(); + server.use(http.delete('/api/trips/1/budget/3', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Remove expense/i })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined)); + delete window.__addToast; + }); + + it('FE-PLANNER-TRANSMODAL-048: an imported transport with a price creates the linked cost on save', async () => { + seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + const onSave = vi.fn().mockResolvedValue({ id: 62 }); + const prefill = { + title: 'LH 400', type: 'flight', status: 'pending', + reservation_time: '2026-08-01T08:00', reservation_end_time: '2026-08-01T11:00', + metadata: { airline: 'Lufthansa', flight_number: 'LH 400', price: 189.5, priceCurrency: 'EUR' }, + endpoints: [], + } as unknown as BookingReviewDraft; + + render(); + // The parsed price is previewed before the booking exists. + expect(screen.getByText('Linked expense')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' }); + }); + + // ── File edge cases ───────────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-049: a cancelled file dialog changes nothing', () => { + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [] } }); + expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-050: a failing upload to an existing booking shows the upload error', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full')); + const res = buildReservation({ id: 20, type: 'train', title: 'Eurostar' }); + + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [new File(['x'], 'fail.pdf', { type: 'application/pdf' })] } }); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined)); + delete window.__addToast; + }); + + it('FE-PLANNER-TRANSMODAL-051: a failing unlink reports the update error', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + server.use( + http.put('/api/trips/1/files/70', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + http.get('/api/trips/1/files/70/links', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + const res = buildReservation({ id: 21, type: 'flight', title: 'LH 400' }); + const attached = buildTripFile({ id: 70, original_name: 'boarding.pdf' }); + (attached as unknown as { reservation_id: number }).reservation_id = 21; + + render(); + const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement; + await userEvent.click(within(row).getByRole('button')); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined)); + delete window.__addToast; + }); + + it('FE-PLANNER-TRANSMODAL-052: a failing link keeps the picker open and reports the error', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + server.use(http.post('/api/trips/1/files/71/link', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + + const res = buildReservation({ id: 22, type: 'flight', title: 'LH 400' }); + const loose = buildTripFile({ id: 71, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + const pickerItem = screen.getByText('invoice.pdf').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(pickerItem); + fireEvent.mouseLeave(pickerItem); + await userEvent.click(pickerItem); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined)); + expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument(); + delete window.__addToast; + }); + + // ── Transit itinerary preservation, continued ────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-053: kept transfer stops are re-sequenced in order', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const res = buildReservation({ title: 'A → C', type: 'bus' }); + Object.assign(res, { + metadata: { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } }, + // Stops arrive out of order — the form must restore their sequence. + endpoints: [ + { role: 'from', sequence: 0, name: 'A', code: null, lat: 0, lng: 0, timezone: null, local_date: null, local_time: null }, + { role: 'stop', sequence: 2, name: 'Second stop', code: null, lat: 1, lng: 1, timezone: null, local_date: null, local_time: null }, + { role: 'stop', sequence: 1, name: 'First stop', code: null, lat: 2, lng: 2, timezone: null, local_date: null, local_time: null }, + { role: 'to', sequence: 3, name: 'C', code: null, lat: 3, lng: 3, timezone: null, local_date: null, local_time: null }, + ], + }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['A', 'First stop', 'Second stop', 'C']); + expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]); + }); + + it('FE-PLANNER-TRANSMODAL-055: an existing traveler list is toggled off and written back empty', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 63 }); + let body: { user_ids: number[] } | null = null; + server.use( + http.put('/api/trips/1/reservations/63/travelers', async ({ request }) => { + body = (await request.json()) as { user_ids: number[] }; + return HttpResponse.json({ travelers: [] }); + }), + ); + const res = buildReservation({ id: 63, type: 'flight', title: 'LH 400' }); + (res as unknown as { travelers: { user_id: number; username: string }[] }).travelers = [ + { user_id: 1, username: 'alice' }, + ]; + + render(); + // Seeded from the reservation, so alice starts selected. + expect(screen.getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true'); + await userEvent.click(screen.getByText('alice')); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body!.user_ids).toEqual([]); + }); + + it('FE-PLANNER-TRANSMODAL-056: a train stop can be added and removed again', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Train$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen'); + await userEvent.click(screen.getByRole('button', { name: /Add stop/i })); + expect(screen.getAllByTestId('location-select')).toHaveLength(3); + await userEvent.click(screen.getByRole('button', { name: /^Delete$/i })); + expect(screen.getAllByTestId('location-select')).toHaveLength(2); + + // Move the departure to another day through its own picker. + await pickDay('Departure', 0, 'Stop Day'); + const stations = screen.getAllByTestId('location-select'); + fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } }); + fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.day_id).toBe(11); + expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']); + }); + + it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => { + const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' }); + const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' }); + (attached as unknown as { reservation_id: number }).reservation_id = 23; + + render(); + const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement; + await userEvent.click(within(row).getByRole('link')); + expect(screen.getByText('boarding.pdf')).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-054: switching the transit day rescopes the quick picks', async () => { + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day one' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day two' }), + ]; + const louvre = buildPlace({ id: 1, name: 'Louvre' }); + const eiffel = buildPlace({ id: 2, name: 'Eiffel Tower' }); + const orsay = buildPlace({ id: 3, name: 'Musée d\'Orsay' }); + const assignments = { + '10': [buildAssignment({ day_id: 10, place_id: louvre.id, place: louvre })], + // Two entries so the quick picks follow the day's own order. + '11': [ + buildAssignment({ day_id: 11, order_index: 1, place_id: orsay.id, place: orsay }), + buildAssignment({ day_id: 11, order_index: 0, place_id: eiffel.id, place: eiffel }), + ], + }; + + render( + , + ); + + await userEvent.click(screen.getByText('Day one')); + await userEvent.click(screen.getByRole('button', { name: /^Day two/ })); + + const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…'); + await userEvent.click(fromInput); + expect(screen.getByText('Eiffel Tower')).toBeInTheDocument(); + expect(screen.getByText(/Orsay/)).toBeInTheDocument(); + expect(screen.queryByText('Louvre')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/TravelerPicker.test.tsx b/client/src/components/Planner/TravelerPicker.test.tsx index d0ea277db..59bf60441 100644 --- a/client/src/components/Planner/TravelerPicker.test.tsx +++ b/client/src/components/Planner/TravelerPicker.test.tsx @@ -1,9 +1,9 @@ -// FE-COMP-TRAVELERS-001 to FE-COMP-TRAVELERS-006 -import { render, screen } from '../../../tests/helpers/render'; +// FE-COMP-TRAVELERS-001 to FE-COMP-TRAVELERS-017 +import { render, screen, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import type { ReservationTraveler } from '@trek/shared'; import type { TripMember } from '../Budget/BudgetPanelMemberChips'; -import { TravelerPicker, TravelerAvatarRow } from './TravelerPicker'; +import { TravelerPicker, TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'; const tripMembers: TripMember[] = [ { id: 1, username: 'alice', avatar_url: null }, @@ -41,6 +41,48 @@ describe('TravelerPicker', () => { expect(screen.queryAllByRole('button')).toHaveLength(0); expect(screen.queryByText('alice')).not.toBeInTheDocument(); }); + + it('FE-COMP-TRAVELERS-007: a guest member carries the guest badge, a normal member does not', () => { + const withGuest: TripMember[] = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 3, username: 'carol', avatar_url: null, is_guest: true }, + ]; + render(); + expect(screen.getAllByText('Guest')).toHaveLength(1); + expect(screen.getByText('carol').closest('button')).toHaveTextContent('Guest'); + expect(screen.getByText('alice').closest('button')).not.toHaveTextContent('Guest'); + }); + + it('FE-COMP-TRAVELERS-008: an avatar_url renders an image instead of the gradient initial', () => { + const withAvatar: TripMember[] = [{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/a.jpg' }]; + render(); + const img = document.querySelector('img') as HTMLImageElement; + expect(img).toBeTruthy(); + expect(img.getAttribute('src')).toBe('/uploads/avatars/a.jpg'); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + }); + + it('FE-COMP-TRAVELERS-009: a member without a username falls back to "?" in the avatar', () => { + const nameless = [{ id: 4, avatar_url: null }] as unknown as TripMember[]; + render(); + expect(screen.getByText('?')).toBeInTheDocument(); + }); + + it('FE-COMP-TRAVELERS-010: hovering an unselected chip lifts its opacity and leaving restores it', () => { + render(); + const bob = screen.getByText('bob').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(bob); + expect(bob.style.opacity).toBe('0.85'); + fireEvent.mouseLeave(bob); + expect(bob.style.opacity).toBe('0.6'); + }); + + it('FE-COMP-TRAVELERS-011: hovering a selected chip leaves its opacity untouched', () => { + render(); + const alice = screen.getByText('alice').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(alice); + expect(alice.style.opacity).toBe('1'); + }); }); describe('TravelerAvatarRow', () => { @@ -56,4 +98,76 @@ describe('TravelerAvatarRow', () => { expect(screen.getByText('alice')).toBeInTheDocument(); expect(screen.getByText('bob')).toBeInTheDocument(); }); + + it('FE-COMP-TRAVELERS-012: renders nothing when travelers is undefined', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-COMP-TRAVELERS-013: a guest traveler gets the guest badge, an avatar-less one its initial', () => { + const mixed: ReservationTraveler[] = [ + { user_id: 1, username: 'alice', avatar_url: null }, + { user_id: 3, username: 'carol', avatar_url: null, is_guest: 1 }, + ]; + render(); + expect(document.querySelectorAll('img')).toHaveLength(0); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getAllByText('Guest')).toHaveLength(1); + }); +}); + +describe('TravelerFilterAvatars', () => { + // The avatars carry no visible label, so they are addressed by their title attribute. + const avatarFor = (username: string) => + document.querySelector(`button[title="${username}"]`) as HTMLButtonElement; + + it('FE-COMP-TRAVELERS-014: one avatar button per member, titled with the username', () => { + render(); + expect(screen.getByLabelText('Travelers')).toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(2); + expect(avatarFor('alice')).toHaveAttribute('aria-pressed', 'false'); + // Nothing is filtered, so no avatar is dimmed. + expect(avatarFor('alice').style.opacity).toBe('1'); + expect(avatarFor('bob').style.opacity).toBe('1'); + // Without an avatar_url the initial stands in. + expect(avatarFor('alice')).toHaveTextContent('A'); + }); + + it('FE-COMP-TRAVELERS-015: clicking an avatar calls onToggle with its id', async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + await user.click(avatarFor('bob')); + expect(onToggle).toHaveBeenCalledWith(2); + }); + + it('FE-COMP-TRAVELERS-016: with a filter on, the active avatar is pressed and the rest dim', () => { + render(); + expect(avatarFor('alice')).toHaveAttribute('aria-pressed', 'true'); + expect(avatarFor('alice').style.opacity).toBe('1'); + expect(avatarFor('bob')).toHaveAttribute('aria-pressed', 'false'); + expect(avatarFor('bob').style.opacity).toBe('0.4'); + }); + + it('FE-COMP-TRAVELERS-017: hovering dims/restores only the inactive avatars', () => { + const withAvatar: TripMember[] = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: '/uploads/avatars/bob.jpg' }, + ]; + render(); + // The member with an avatar_url renders an rather than an initial. + expect(document.querySelectorAll('img')).toHaveLength(1); + + const bob = avatarFor('bob'); + fireEvent.mouseEnter(bob); + expect(bob.style.opacity).toBe('0.7'); + fireEvent.mouseLeave(bob); + expect(bob.style.opacity).toBe('0.4'); + + const alice = avatarFor('alice'); + fireEvent.mouseEnter(alice); + expect(alice.style.opacity).toBe('1'); + fireEvent.mouseLeave(alice); + expect(alice.style.opacity).toBe('1'); + }); }); diff --git a/client/src/components/Planner/TripWarningsBanner.test.tsx b/client/src/components/Planner/TripWarningsBanner.test.tsx new file mode 100644 index 000000000..e99909c8a --- /dev/null +++ b/client/src/components/Planner/TripWarningsBanner.test.tsx @@ -0,0 +1,156 @@ +// FE-PLANNER-TRIPWARN-001 to FE-PLANNER-TRIPWARN-011 +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import TripWarningsBanner from './TripWarningsBanner'; + +type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string }; + +const plugin = (over: Partial = {}): ActivePlugin => ({ + id: 'visa-check', + name: 'Visa Check', + type: 'trip-page', + icon: null, + ...over, +}); + +function warnings(...list: Warning[]) { + server.use(http.get('/api/trip-warnings/1', () => HttpResponse.json({ warnings: list }))); +} + +/** The navbar slot the chip variant portals into; absent unless a test adds it. */ +function mountNavSlot(): HTMLElement { + const slot = document.createElement('div'); + slot.id = 'trek-nav-center-slot'; + document.body.appendChild(slot); + return slot; +} + +/** matchMedia is stubbed to matches:false globally — opt this component into desktop. */ +function stubDesktop(matches: boolean) { + const listeners: Array<(e: MediaQueryListEvent) => void> = []; + const mql = { + matches, + media: '(min-width: 768px)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((_: string, cb: (e: MediaQueryListEvent) => void) => { listeners.push(cb); }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + }; + window.matchMedia = vi.fn(() => mql) as unknown as typeof window.matchMedia; + return { mql, fire: (m: boolean) => listeners.forEach(cb => cb({ matches: m } as MediaQueryListEvent)) }; +} + +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + resetAllStores(); + window.matchMedia = originalMatchMedia; + document.getElementById('trek-nav-center-slot')?.remove(); +}); + +describe('TripWarningsBanner', () => { + it('FE-PLANNER-TRIPWARN-001: renders nothing while there are no warnings', async () => { + warnings(); + const { container } = render(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('FE-PLANNER-TRIPWARN-002: a warning from a non trip-page plugin floats in the planner', async () => { + warnings({ pluginId: 'weather-guard', level: 'warning', message: 'Storm expected on day 3' }); + render(); + expect(await screen.findByText('Storm expected on day 3')).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRIPWARN-003: floating rows render one entry per warning', async () => { + warnings( + { pluginId: 'a', level: 'info', message: 'First note' }, + { pluginId: 'b', level: 'error', message: 'Second note' }, + ); + render(); + expect(await screen.findByText('First note')).toBeInTheDocument(); + expect(screen.getByText('Second note')).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRIPWARN-004: an unknown level falls back to the warning style instead of crashing', async () => { + warnings({ pluginId: 'a', level: 'critical' as unknown as Warning['level'], message: 'Odd level' }); + render(); + const row = (await screen.findByText('Odd level')).parentElement as HTMLElement; + expect(row.style.background).toBe('var(--warning-soft)'); + }); + + it('FE-PLANNER-TRIPWARN-005: a trip-page plugin warning becomes a navbar chip, not a floating row', async () => { + const slot = mountNavSlot(); + stubDesktop(true); + seedStore(usePluginStore, { plugins: [plugin()] }); + warnings({ pluginId: 'visa-check', level: 'error', message: 'Visa missing for Japan' }); + render(); + await waitFor(() => expect(slot.textContent).toContain('Visa missing for Japan')); + expect(slot.querySelector('button')).not.toBeNull(); + }); + + it('FE-PLANNER-TRIPWARN-006: clicking the chip opens the plugin tab', async () => { + const slot = mountNavSlot(); + stubDesktop(true); + const onOpenPluginTab = vi.fn(); + seedStore(usePluginStore, { plugins: [plugin()] }); + warnings({ pluginId: 'visa-check', level: 'warning', message: 'Visa missing' }); + render(); + await waitFor(() => expect(slot.querySelector('button')).not.toBeNull()); + fireEvent.click(slot.querySelector('button') as HTMLButtonElement); + expect(onOpenPluginTab).toHaveBeenCalledWith('visa-check'); + }); + + it('FE-PLANNER-TRIPWARN-007: without onOpenPluginTab the chip is inert', async () => { + const slot = mountNavSlot(); + stubDesktop(true); + seedStore(usePluginStore, { plugins: [plugin()] }); + warnings({ pluginId: 'visa-check', level: 'info', message: 'Visa reminder' }); + render(); + await waitFor(() => expect(slot.querySelector('button')).not.toBeNull()); + const chip = slot.querySelector('button') as HTMLButtonElement; + expect(chip.style.cursor).toBe('default'); + fireEvent.click(chip); + expect(chip).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRIPWARN-008: below the md breakpoint the same warning floats instead of becoming a chip', async () => { + const slot = mountNavSlot(); + stubDesktop(false); + seedStore(usePluginStore, { plugins: [plugin()] }); + warnings({ pluginId: 'visa-check', level: 'warning', message: 'Visa missing' }); + render(); + expect(await screen.findByText('Visa missing')).toBeInTheDocument(); + expect(slot.textContent).toBe(''); + }); + + it('FE-PLANNER-TRIPWARN-009: a breakpoint change moves the warning into the navbar slot', async () => { + const slot = mountNavSlot(); + const media = stubDesktop(false); + seedStore(usePluginStore, { plugins: [plugin()] }); + warnings({ pluginId: 'visa-check', level: 'warning', message: 'Visa missing' }); + render(); + await screen.findByText('Visa missing'); + expect(slot.textContent).toBe(''); + act(() => { media.fire(true); }); + await waitFor(() => expect(slot.textContent).toContain('Visa missing')); + }); + + it('FE-PLANNER-TRIPWARN-010: a failing warnings request renders nothing', async () => { + server.use(http.get('/api/trip-warnings/1', () => new HttpResponse(null, { status: 500 }))); + const { container } = render(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('FE-PLANNER-TRIPWARN-011: a non-finite tripId short-circuits without a request', async () => { + let called = false; + server.use(http.get('/api/trip-warnings/*', () => { called = true; return HttpResponse.json({ warnings: [] }); })); + const { container } = render(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + expect(called).toBe(false); + }); +}); diff --git a/client/src/components/Planner/usePlacesSidebar.test.tsx b/client/src/components/Planner/usePlacesSidebar.test.tsx new file mode 100644 index 000000000..904c9a127 --- /dev/null +++ b/client/src/components/Planner/usePlacesSidebar.test.tsx @@ -0,0 +1,664 @@ +// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-048 +import { http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildUser, buildTrip, buildPlace, buildDay, buildAssignment } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { useAuthStore } from '../../store/authStore'; +import { useTripStore } from '../../store/tripStore'; +import { usePermissionsStore } from '../../store/permissionsStore'; +import { useAddonStore } from '../../store/addonStore'; +import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'; +import { ContextMenu } from '../shared/ContextMenu'; +import type { Place } from '../../types'; +import { usePlacesSidebar, type PlacesSidebarProps, type SidebarState } from './usePlacesSidebar'; + +let S: SidebarState; + +// The hook owns state that only makes sense against real DOM nodes (the scroll +// container, the per-row refs, the drag counter), so it is driven through a +// stripped-down host instead of renderHook. +function Host(props: PlacesSidebarProps) { + const state = usePlacesSidebar(props); + S = state; + return ( +
+ {state.sidebarDragOver && drop-active} + {state.filtered.map((p) => ( +
{ state.registerPlaceRow(p.id, el); }} + onContextMenu={(e) => state.openContextMenu(e, p)} + > + {p.name} +
+ ))} + +
+ ); +} + +function makeProps(overrides: Partial = {}): PlacesSidebarProps { + return { + tripId: 1, + places: [], + categories: [], + assignments: {}, + selectedDayId: null, + selectedPlaceId: null, + onPlaceClick: vi.fn((_id: number | null) => {}), + onAddPlace: vi.fn(() => {}), + onAssignToDay: vi.fn((_placeId: number, _dayId: number) => {}), + onEditPlace: vi.fn((_place: Place) => {}), + onDeletePlace: vi.fn((_placeId: number) => {}), + days: [], + isMobile: false, + ...overrides, + }; +} + +function names(): string[] { + return S.filtered.map((p) => p.name); +} + +function enableCollections() { + seedStore(useAddonStore, { + addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: '', enabled: true }], + loaded: true, + }); +} + +const addToast = vi.fn((_message: string, _type?: string, _duration?: number) => 0); + +beforeEach(() => { + resetAllStores(); + useSaveToCollectionStore.setState({ target: null }); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); + addToast.mockClear(); + window.__addToast = addToast; +}); + +afterEach(() => { + delete window.__addToast; + vi.unstubAllGlobals(); +}); + +// ── Filtering, search and sorting ───────────────────────────────────────────── + +describe('usePlacesSidebar filtering', () => { + it('FE-PLANNER-PSHOOK-001: passes every place through when nothing is filtered', () => { + const places = [buildPlace({ name: 'Alpha' }), buildPlace({ name: 'Beta' })]; + render(); + expect(names()).toEqual(['Alpha', 'Beta']); + expect(S.hasTracks).toBe(false); + }); + + it('FE-PLANNER-PSHOOK-002: search matches the name case-insensitively', () => { + const places = [buildPlace({ name: 'Museum of Art' }), buildPlace({ name: 'Central Park' })]; + render(); + act(() => { S.setSearch('MUSEUM'); }); + expect(names()).toEqual(['Museum of Art']); + }); + + it('FE-PLANNER-PSHOOK-003: search also matches the address', () => { + const places = [ + buildPlace({ name: 'UK Office', address: '10 Downing Street' }), + buildPlace({ name: 'Other', address: null }), + ]; + render(); + act(() => { S.setSearch('downing'); }); + expect(names()).toEqual(['UK Office']); + }); + + it('FE-PLANNER-PSHOOK-004: a search that matches nothing empties the list', () => { + const places = [buildPlace({ name: 'Alpha', address: 'Rue A' })]; + render(); + act(() => { S.setSearch('zzz'); }); + expect(names()).toEqual([]); + }); + + it('FE-PLANNER-PSHOOK-005: the unplanned filter drops places assigned to a day', () => { + const planned = buildPlace({ id: 1, name: 'Planned' }); + const loose = buildPlace({ id: 2, name: 'Loose' }); + const assignments = { '3': [buildAssignment({ place: planned, day_id: 3 })] }; + render(); + act(() => { S.setFilter('unplanned'); }); + expect(names()).toEqual(['Loose']); + expect([...S.plannedIds]).toEqual([1]); + }); + + it('FE-PLANNER-PSHOOK-006: the planned filter keeps only assigned places', () => { + const planned = buildPlace({ id: 1, name: 'Planned' }); + const loose = buildPlace({ id: 2, name: 'Loose' }); + const assignments = { '3': [buildAssignment({ place: planned, day_id: 3 })] }; + render(); + act(() => { S.setFilter('planned'); }); + expect(names()).toEqual(['Planned']); + }); + + it('FE-PLANNER-PSHOOK-007: the tracks filter keeps only places carrying route geometry', () => { + const track = buildPlace({ name: 'GPX Track', route_geometry: '[[48,2],[49,3]]' }); + const spot = buildPlace({ name: 'Plain Spot' }); + render(); + expect(S.hasTracks).toBe(true); + act(() => { S.setFilter('tracks'); }); + expect(names()).toEqual(['GPX Track']); + }); + + it('FE-PLANNER-PSHOOK-008: a tracks filter without any track falls back to "all"', async () => { + seedStore(useTripStore, { placesFilter: 'tracks' }); + render(); + await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all')); + expect(names()).toEqual(['Plain Spot']); + }); + + it('FE-PLANNER-PSHOOK-009: a category filter keeps only that category', () => { + const tagged = buildPlace({ name: 'Tagged', category_id: 4 }); + const other = buildPlace({ name: 'Other', category_id: 5 }); + const none = buildPlace({ name: 'None', category_id: null }); + render(); + act(() => { S.setCategoryFilters(new Set(['4'])); }); + expect(names()).toEqual(['Tagged']); + }); + + it('FE-PLANNER-PSHOOK-010: the "uncategorized" bucket keeps only places without a category', () => { + const tagged = buildPlace({ name: 'Tagged', category_id: 4 }); + const none = buildPlace({ name: 'None', category_id: null }); + render(); + act(() => { S.setCategoryFilters(new Set(['uncategorized'])); }); + expect(names()).toEqual(['None']); + }); + + it('FE-PLANNER-PSHOOK-011: toggleCategoryFilter adds and removes a category', () => { + render(); + act(() => { S.toggleCategoryFilter('7'); }); + expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['7']); + act(() => { S.toggleCategoryFilter('7'); }); + expect([...useTripStore.getState().placesCategoryFilter]).toEqual([]); + }); + + it('FE-PLANNER-PSHOOK-012: the star sort puts the best rating first and unrated places last', () => { + const places = [ + buildPlace({ name: 'Unrated' }), + buildPlace({ name: 'Good', rating_avg: 4.2 }), + buildPlace({ name: 'Best', rating_avg: 4.9 }), + ]; + render(); + act(() => { S.setRatingSort(true); }); + expect(names()).toEqual(['Best', 'Good', 'Unrated']); + }); + + it('FE-PLANNER-PSHOOK-013: equal ratings keep the incoming order', () => { + const places = [ + buildPlace({ name: 'First', rating_avg: 4 }), + buildPlace({ name: 'Second', rating_avg: 4 }), + ]; + render(); + act(() => { S.setRatingSort(true); }); + expect(names()).toEqual(['First', 'Second']); + }); + + it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => { + const places = [ + buildPlace({ name: 'Cafe Nord', category_id: 4 }), + buildPlace({ name: 'Cafe Sud', category_id: 5 }), + buildPlace({ name: 'Bar Nord', category_id: 4 }), + ]; + render(); + act(() => { + S.setCategoryFilters(new Set(['4'])); + S.setSearch('cafe'); + }); + expect(names()).toEqual(['Cafe Nord']); + }); +}); + +// ── Multi-select ────────────────────────────────────────────────────────────── + +describe('usePlacesSidebar selection', () => { + it('FE-PLANNER-PSHOOK-015: toggleSelected adds and removes an id', () => { + render(); + act(() => { S.toggleSelected(9); }); + expect([...S.selectedIds]).toEqual([9]); + act(() => { S.toggleSelected(9); }); + expect([...S.selectedIds]).toEqual([]); + }); + + it('FE-PLANNER-PSHOOK-016: exitSelectMode clears the mode and the selection', () => { + render(); + act(() => { S.setSelectMode(true); S.toggleSelected(9); }); + expect(S.selectMode).toBe(true); + act(() => { S.exitSelectMode(); }); + expect(S.selectMode).toBe(false); + expect(S.selectedIds.size).toBe(0); + }); + + it('FE-PLANNER-PSHOOK-017: select mode ends by itself once every selected place is gone', () => { + const a = buildPlace({ id: 1, name: 'A' }); + const b = buildPlace({ id: 2, name: 'B' }); + const { rerender } = render(); + act(() => { S.setSelectMode(true); S.toggleSelected(1); }); + + rerender(); + + expect(S.selectMode).toBe(false); + expect(S.selectedIds.size).toBe(0); + }); + + it('FE-PLANNER-PSHOOK-018: select mode survives while one selected place is still there', () => { + const a = buildPlace({ id: 1, name: 'A' }); + const b = buildPlace({ id: 2, name: 'B' }); + const { rerender } = render(); + act(() => { S.setSelectMode(true); S.toggleSelected(1); S.toggleSelected(2); }); + + rerender(); + + expect(S.selectMode).toBe(true); + expect([...S.selectedIds].sort()).toEqual([1, 2]); + }); +}); + +// ── Day assignment helpers ──────────────────────────────────────────────────── + +describe('usePlacesSidebar day helpers', () => { + it('FE-PLANNER-PSHOOK-019: isAssignedToSelectedDay and inDaySet reflect the selected day', () => { + const place = buildPlace({ id: 5, name: 'Assigned' }); + const assignments = { '3': [buildAssignment({ place, day_id: 3 })] }; + render(); + + expect(S.isAssignedToSelectedDay(5)).toBe(true); + expect(S.isAssignedToSelectedDay(99)).toBe(false); + expect([...S.inDaySet]).toEqual([5]); + }); + + it('FE-PLANNER-PSHOOK-020: without a selected day nothing counts as assigned', () => { + const place = buildPlace({ id: 5 }); + const assignments = { '3': [buildAssignment({ place, day_id: 3 })] }; + render(); + + expect(S.isAssignedToSelectedDay(5)).toBeFalsy(); + expect(S.inDaySet.size).toBe(0); + }); + + it('FE-PLANNER-PSHOOK-021: a day without assignments yields an empty set', () => { + const place = buildPlace({ id: 5 }); + render(); + expect(S.inDaySet.size).toBe(0); + expect(S.isAssignedToSelectedDay(5)).toBe(false); + }); +}); + +// ── Scroll restoration and row refs ─────────────────────────────────────────── + +describe('usePlacesSidebar scrolling', () => { + it('FE-PLANNER-PSHOOK-022: restores the previous scroll offset on mount', () => { + render(); + expect(screen.getByTestId('sidebar').scrollTop).toBe(240); + }); + + it('FE-PLANNER-PSHOOK-023: a zero offset leaves the container untouched', () => { + render(); + expect(screen.getByTestId('sidebar').scrollTop).toBe(0); + }); + + it('FE-PLANNER-PSHOOK-024: the selected row is scrolled into view exactly once', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + scrollIntoView.mockClear(); + const places = [buildPlace({ id: 1, name: 'A' }), buildPlace({ id: 2, name: 'B' })]; + const { rerender } = render(); + + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })); + scrollIntoView.mockClear(); + + // Re-rendering with the same selection must not scroll again. + rerender(); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PSHOOK-025: clearing the selection resets the auto-scroll guard', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + const places = [buildPlace({ id: 1, name: 'A' }), buildPlace({ id: 2, name: 'B' })]; + const { rerender } = render(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + + rerender(); + scrollIntoView.mockClear(); + rerender(); + + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + }); + + it('FE-PLANNER-PSHOOK-026: a selected place filtered out of the list is not scrolled to', () => { + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + const places = [buildPlace({ id: 1, name: 'Visible' }), buildPlace({ id: 2, name: 'Hidden' })]; + const { rerender } = render(); + act(() => { S.setSearch('Visible'); }); + scrollIntoView.mockClear(); + + // The row is unmounted by the search, so registerPlaceRow has dropped its ref. + rerender(); + + expect(names()).toEqual(['Visible']); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); +}); + +// ── Drag & drop import ──────────────────────────────────────────────────────── + +describe('usePlacesSidebar drag and drop', () => { + it('FE-PLANNER-PSHOOK-027: dragging over the sidebar raises the drop hint', () => { + render(); + fireEvent.dragEnter(screen.getByTestId('sidebar')); + expect(screen.getByText('drop-active')).toBeInTheDocument(); + fireEvent.dragOver(screen.getByTestId('sidebar')); + expect(screen.getByText('drop-active')).toBeInTheDocument(); + }); + + it('FE-PLANNER-PSHOOK-028: leaving the last nested target lowers the hint again', () => { + render(); + const sidebar = screen.getByTestId('sidebar'); + fireEvent.dragEnter(sidebar); + fireEvent.dragEnter(sidebar); + fireEvent.dragLeave(sidebar); + expect(screen.getByText('drop-active')).toBeInTheDocument(); + fireEvent.dragLeave(sidebar); + expect(screen.queryByText('drop-active')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PSHOOK-029: read-only members get no drop hint', () => { + seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } }); + render(); + fireEvent.dragEnter(screen.getByTestId('sidebar')); + fireEvent.dragOver(screen.getByTestId('sidebar')); + expect(screen.queryByText('drop-active')).not.toBeInTheDocument(); + expect(S.canEditPlaces).toBe(false); + }); + + it('FE-PLANNER-PSHOOK-030: dropping a file arms the file-import modal', () => { + render(); + const file = new File([''], 'route.gpx', { type: 'application/gpx+xml' }); + fireEvent.drop(screen.getByTestId('sidebar'), { dataTransfer: { files: [file] } }); + + expect(S.fileImportOpen).toBe(true); + expect(S.sidebarDropFile?.name).toBe('route.gpx'); + expect(screen.queryByText('drop-active')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PSHOOK-031: dropping nothing leaves the modal closed', () => { + render(); + fireEvent.drop(screen.getByTestId('sidebar'), { dataTransfer: { files: [] } }); + expect(S.fileImportOpen).toBe(false); + expect(S.sidebarDropFile).toBeNull(); + }); + + it('FE-PLANNER-PSHOOK-032: a read-only member cannot drop a file either', () => { + seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } }); + render(); + const file = new File([''], 'route.gpx', { type: 'application/gpx+xml' }); + fireEvent.drop(screen.getByTestId('sidebar'), { dataTransfer: { files: [file] } }); + expect(S.fileImportOpen).toBe(false); + }); +}); + +// ── List import ─────────────────────────────────────────────────────────────── + +describe('usePlacesSidebar list import', () => { + it('FE-PLANNER-PSHOOK-033: both providers are offered', () => { + render(); + expect(S.availableListImportProviders).toEqual(['google', 'naver']); + expect(S.hasMultipleListImportProviders).toBe(true); + expect(S.listImportProvider).toBe('google'); + }); + + it('FE-PLANNER-PSHOOK-034: an empty URL does not start an import', async () => { + let calls = 0; + server.use(http.post('/api/trips/1/places/import/google-list', () => { + calls++; + return HttpResponse.json({ count: 0, skipped: 0, places: [] }); + })); + render(); + act(() => { S.setListImportUrl(' '); }); + await act(async () => { await S.handleListImport(); }); + + expect(calls).toBe(0); + expect(S.listImportLoading).toBe(false); + }); + + it('FE-PLANNER-PSHOOK-035: a successful Google import reloads the trip, toasts and closes', async () => { + const loadTrip = vi.fn().mockResolvedValue(undefined); + seedStore(useTripStore, { loadTrip }); + let sentUrl: string | undefined; + let sentEnrich: boolean | undefined; + server.use(http.post('/api/trips/1/places/import/google-list', async ({ request }) => { + const body = await request.json() as { url: string; enrich?: boolean }; + sentUrl = body.url; + sentEnrich = body.enrich; + return HttpResponse.json({ count: 2, skipped: 0, listName: 'Tokyo', places: [{ id: 20 }, { id: 21 }] }); + })); + + const pushUndo = vi.fn((_label: string, _fn: () => Promise | void) => {}); + render(); + act(() => { S.setListImportOpen(true); S.setListImportUrl(' https://maps.app.goo.gl/abc '); }); + await act(async () => { await S.handleListImport(); }); + + expect(sentUrl).toBe('https://maps.app.goo.gl/abc'); + expect(sentEnrich).toBe(false); + expect(loadTrip).toHaveBeenCalledWith(1); + expect(addToast).toHaveBeenCalledWith('2 places imported from "Tokyo"', 'success', undefined); + expect(S.listImportOpen).toBe(false); + expect(S.listImportUrl).toBe(''); + expect(pushUndo).toHaveBeenCalledWith('Google Maps import', expect.any(Function)); + }); + + it('FE-PLANNER-PSHOOK-036: the undo entry bulk-deletes the imported places', async () => { + const loadTrip = vi.fn().mockResolvedValue(undefined); + seedStore(useTripStore, { loadTrip }); + server.use(http.post('/api/trips/1/places/import/google-list', () => + HttpResponse.json({ count: 2, skipped: 0, listName: 'Tokyo', places: [{ id: 20 }, { id: 21 }] }))); + let deletedIds: number[] = []; + server.use(http.post('/api/trips/1/places/bulk-delete', async ({ request }) => { + deletedIds = ((await request.json()) as { ids: number[] }).ids; + return HttpResponse.json({ deleted: deletedIds.length }); + })); + + let undoFn: (() => Promise | void) | undefined; + const pushUndo = vi.fn((_label: string, fn: () => Promise | void) => { undoFn = fn; }); + render(); + act(() => { S.setListImportUrl('https://maps.app.goo.gl/abc'); }); + await act(async () => { await S.handleListImport(); }); + + await act(async () => { await undoFn?.(); }); + + expect(deletedIds).toEqual([20, 21]); + expect(loadTrip).toHaveBeenCalledTimes(2); + }); + + it('FE-PLANNER-PSHOOK-037: an import that skips everything warns instead of celebrating', async () => { + seedStore(useTripStore, { loadTrip: vi.fn().mockResolvedValue(undefined) }); + server.use(http.post('/api/trips/1/places/import/google-list', () => + HttpResponse.json({ count: 0, skipped: 4, listName: 'Tokyo', places: [] }))); + + render(); + act(() => { S.setListImportUrl('https://maps.app.goo.gl/abc'); }); + await act(async () => { await S.handleListImport(); }); + + expect(addToast).toHaveBeenCalledWith('All places were already in the trip.', 'warning', undefined); + }); + + it('FE-PLANNER-PSHOOK-038: the Naver provider hits the Naver endpoint and enriches when a maps key exists', async () => { + seedStore(useAuthStore, { hasMapsKey: true }); + seedStore(useTripStore, { loadTrip: vi.fn().mockResolvedValue(undefined) }); + let sentEnrich: boolean | undefined; + server.use(http.post('/api/trips/1/places/import/naver-list', async ({ request }) => { + sentEnrich = ((await request.json()) as { enrich?: boolean }).enrich; + return HttpResponse.json({ count: 1, skipped: 0, listName: 'Seoul', places: [{ id: 30 }] }); + })); + + const pushUndo = vi.fn((_label: string, _fn: () => Promise | void) => {}); + render(); + expect(S.canEnrichImport).toBe(true); + act(() => { + S.setListImportProvider('naver'); + S.setListImportEnrich(true); + S.setListImportUrl('https://naver.me/xyz'); + }); + await act(async () => { await S.handleListImport(); }); + + expect(sentEnrich).toBe(true); + expect(addToast).toHaveBeenCalledWith('1 places imported from "Seoul"', 'success', undefined); + expect(pushUndo).toHaveBeenCalledWith('Naver Maps import', expect.any(Function)); + }); + + it('FE-PLANNER-PSHOOK-039: enrichment stays off while the instance has no maps key', async () => { + seedStore(useTripStore, { loadTrip: vi.fn().mockResolvedValue(undefined) }); + let sentEnrich: boolean | undefined; + server.use(http.post('/api/trips/1/places/import/google-list', async ({ request }) => { + sentEnrich = ((await request.json()) as { enrich?: boolean }).enrich; + return HttpResponse.json({ count: 1, skipped: 0, listName: 'Tokyo', places: [] }); + })); + + render(); + expect(S.canEnrichImport).toBe(false); + act(() => { S.setListImportEnrich(true); S.setListImportUrl('https://maps.app.goo.gl/abc'); }); + await act(async () => { await S.handleListImport(); }); + + expect(sentEnrich).toBe(false); + }); + + it('FE-PLANNER-PSHOOK-040: a failing import surfaces the server message', async () => { + server.use(http.post('/api/trips/1/places/import/google-list', () => + HttpResponse.json({ error: 'List is private' }, { status: 400 }))); + + render(); + act(() => { S.setListImportOpen(true); S.setListImportUrl('https://maps.app.goo.gl/abc'); }); + await act(async () => { await S.handleListImport(); }); + + expect(addToast).toHaveBeenCalledWith('List is private', 'error', undefined); + // The dialog stays open so the URL can be corrected. + expect(S.listImportOpen).toBe(true); + expect(S.listImportLoading).toBe(false); + }); + + it('FE-PLANNER-PSHOOK-041: a failure without a server message falls back to the translated one', async () => { + server.use(http.post('/api/trips/1/places/import/naver-list', () => new HttpResponse(null, { status: 500 }))); + + render(); + act(() => { S.setListImportProvider('naver'); S.setListImportUrl('https://naver.me/xyz'); }); + await act(async () => { await S.handleListImport(); }); + + expect(addToast).toHaveBeenCalledWith('Failed to import Naver Maps list', 'error', undefined); + }); +}); + +// ── Row context menu ────────────────────────────────────────────────────────── + +describe('usePlacesSidebar context menu', () => { + it('FE-PLANNER-PSHOOK-042: a full-permission row offers edit, day, website, maps, collection and delete', () => { + enableCollections(); + const place = buildPlace({ id: 3, name: 'Cafe', website: 'https://cafe.example', google_place_id: 'ChIJ1' }); + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '+ Day' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open Website' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save to Collection' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('FE-PLANNER-PSHOOK-043: a read-only row without extras only offers the maps link', () => { + seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } }); + const place = buildPlace({ id: 3, name: 'Cafe', website: null }); + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '+ Day' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open Website' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument(); + }); + + it('FE-PLANNER-PSHOOK-044: Edit hands the place back to the planner', async () => { + const user = userEvent.setup(); + const onEditPlace = vi.fn((_place: Place) => {}); + const place = buildPlace({ id: 3, name: 'Cafe' }); + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + await user.click(screen.getByRole('button', { name: 'Edit' })); + + expect(onEditPlace).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); + }); + + it('FE-PLANNER-PSHOOK-045: "+ Day" uses the day that was selected when the menu opened', async () => { + const user = userEvent.setup(); + const onAssignToDay = vi.fn((_placeId: number, _dayId: number) => {}); + const place = buildPlace({ id: 3, name: 'Cafe' }); + const days = [buildDay({ id: 6 })]; + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + await user.click(screen.getByRole('button', { name: '+ Day' })); + + expect(onAssignToDay).toHaveBeenCalledWith(3, 6); + }); + + it('FE-PLANNER-PSHOOK-046: Delete hands the id back to the planner', async () => { + const user = userEvent.setup(); + const onDeletePlace = vi.fn((_placeId: number) => {}); + const place = buildPlace({ id: 3, name: 'Cafe' }); + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(onDeletePlace).toHaveBeenCalledWith(3); + }); + + it('FE-PLANNER-PSHOOK-047: Website and Google Maps open a new tab', async () => { + const user = userEvent.setup(); + const open = vi.fn(() => null); + vi.stubGlobal('open', open); + const place = buildPlace({ id: 3, name: 'Cafe', website: 'https://cafe.example', lat: 48.8584, lng: 2.2945, google_place_id: null }); + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + await user.click(screen.getByRole('button', { name: 'Open Website' })); + expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank'); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + await user.click(screen.getByRole('button', { name: 'Google Maps' })); + expect(open).toHaveBeenCalledWith('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945', '_blank'); + }); + + it('FE-PLANNER-PSHOOK-048: "Save to Collection" arms the picker with the place', async () => { + const user = userEvent.setup(); + enableCollections(); + const place = buildPlace({ id: 3, name: 'Cafe', address: 'Rue A' }); + render(); + + fireEvent.contextMenu(screen.getByTestId('row-3')); + await user.click(screen.getByRole('button', { name: 'Save to Collection' })); + + expect(useSaveToCollectionStore.getState().target).toMatchObject({ + name: 'Cafe', + address: 'Rue A', + source_place_id: 3, + }); + }); +});