mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(planner): cover the day plan sidebar and the planner dialogs
This commit is contained in:
@@ -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<Response>)) {
|
||||
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 (
|
||||
<AddressInput
|
||||
value={value}
|
||||
onChange={(v) => { 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(<AddressInput value="Hauptstrasse 1" onChange={vi.fn()} placeholder="Address" className="addr" />);
|
||||
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(<Host onText={onText} />);
|
||||
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(<Host initial="Philharmoniker Str. 4" />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host onText={onText} />);
|
||||
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(<Host onText={onText} />);
|
||||
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(<Host onText={onText} />);
|
||||
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(<Host onText={onText} />);
|
||||
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(<Host onText={onText} />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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(<AirTrailImportModal {...defaultProps} />);
|
||||
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(<AirTrailImportModal {...defaultProps} />);
|
||||
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(<AirTrailImportModal {...defaultProps} />);
|
||||
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(<AirTrailImportModal {...defaultProps} />);
|
||||
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(<AirTrailImportModal {...defaultProps} />);
|
||||
// Label falls back to the route, and the sub-line carries no ` · <date>` 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(<AirTrailImportModal {...defaultProps} pushUndo={pushUndo} />);
|
||||
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(<AirTrailImportModal {...defaultProps} />);
|
||||
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(<AirTrailImportModal {...defaultProps} onClose={onClose} />);
|
||||
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(<AirTrailImportModal {...defaultProps} onClose={onClose} />);
|
||||
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(<AirTrailImportModal {...defaultProps} />);
|
||||
await screen.findByText('Finnair AY1502');
|
||||
expect(screen.queryByText(/^Imported$/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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<Airport | null>(initial);
|
||||
return (
|
||||
<AirportSelect
|
||||
value={value}
|
||||
onChange={(a) => { 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<Response>) {
|
||||
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(<AirportSelect value={null} onChange={vi.fn()} />);
|
||||
expect(screen.getByPlaceholderText('Airport code or city (e.g. FRA)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-AIRPORTSEL-002: an explicit placeholder wins over the default', () => {
|
||||
render(<AirportSelect value={null} onChange={vi.fn()} placeholder="Departure airport" />);
|
||||
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(<AirportSelect value={buildAirport()} onChange={vi.fn()} />);
|
||||
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(<AirportSelect value={null} onChange={vi.fn()} />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host />);
|
||||
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(<Host initial={buildAirport()} onPick={onPick} />);
|
||||
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(<Host initial={buildAirport()} onPick={onPick} />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(<BookingImportModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Import booking confirmations')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKIMP-002: shows title, accepted formats and a disabled import button', () => {
|
||||
render(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
// 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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} onClose={onClose} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
// 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(<BookingImportModal {...defaultProps} onClose={onClose} />);
|
||||
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(<BookingImportModal {...defaultProps} />);
|
||||
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(<BookingImportModal {...defaultProps} onClose={onClose} />);
|
||||
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(<BookingImportModal {...defaultProps} isOpen={false} onClose={onClose} />);
|
||||
rerender(<BookingImportModal {...defaultProps} isOpen onClose={onClose} />);
|
||||
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(<BookingImportModal {...defaultProps} onClose={onClose} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(<DayDetailPanel {...defaultProps} />);
|
||||
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(<DayDetailPanel {...defaultProps} reservations={[hotel, dinner]} />);
|
||||
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(<DayDetailPanel {...defaultProps} />);
|
||||
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(<DayDetailPanel {...defaultProps} />);
|
||||
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(<DayDetailPanel {...defaultProps} />);
|
||||
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(<DayDetailPanel {...defaultProps} />);
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
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(<DayDetailPanel {...defaultProps} places={[place]} />);
|
||||
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(<DayDetailPanel {...defaultProps} places={places} categories={categories} />);
|
||||
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(<DayDetailPanel {...defaultProps} places={places} />);
|
||||
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(<DayDetailPanel {...defaultProps} places={[]} />);
|
||||
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(<DayDetailPanel {...defaultProps} />);
|
||||
const frame = await waitFor(() => document.querySelector('iframe[src*="day-notes"]') as HTMLIFrameElement);
|
||||
expect(frame).not.toBeNull();
|
||||
expect(document.querySelector('iframe[src*="hero-thing"]')).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<React.ComponentProps<typeof MobileAddPlaceButton>> = {}) {
|
||||
return {
|
||||
dayId: 5,
|
||||
places: [louvre, eiffel] as Place[],
|
||||
assignments: {} as AssignmentsMap,
|
||||
onAssign: vi.fn(),
|
||||
onAddNew: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPicker(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Add Place' }))
|
||||
}
|
||||
|
||||
describe('MobileAddPlaceButton', () => {
|
||||
it('FE-PLANNER-DPADDPLACE-001: renders only the collapsed trigger initially', () => {
|
||||
render(<MobileAddPlaceButton {...makeProps()} />)
|
||||
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(<MobileAddPlaceButton {...makeProps()} />)
|
||||
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(<MobileAddPlaceButton {...makeProps({ assignments })} />)
|
||||
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(<MobileAddPlaceButton {...makeProps()} />)
|
||||
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(<MobileAddPlaceButton {...makeProps()} />)
|
||||
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(<MobileAddPlaceButton {...makeProps({ assignments })} />)
|
||||
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(<MobileAddPlaceButton {...makeProps({ onAssign })} />)
|
||||
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(<MobileAddPlaceButton {...makeProps({ onAddNew })} />)
|
||||
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(<MobileAddPlaceButton {...makeProps({ onAddNew: undefined })} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
{title}
|
||||
<button onClick={onClose}>close-subscribe</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
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<React.ComponentProps<typeof DayPlanSidebarToolbar>> = {}) {
|
||||
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<number>(),
|
||||
setExpandedDays: vi.fn((_v: Set<number>) => {}),
|
||||
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<Reservation>)
|
||||
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ days, dayNotes })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ setPdfHover })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
|
||||
rerender(<DayPlanSidebarToolbar {...makeProps({ pdfHover: true })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
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<number>) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, setExpandedDays })} />)
|
||||
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<number>) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, expandedDays: new Set([10, 11]), setExpandedDays })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ days: [] })} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.expandAll' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Undo ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-014: no undo control without an onUndo handler', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ onUndo, canUndo: false })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ onUndo, canUndo: true, setUndoHover })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ onUndo: vi.fn(), canUndo: true, undoHover: true, lastActionLabel: 'Reorder' })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ onUndo: vi.fn(), canUndo: false, undoHover: true, lastActionLabel: 'Reorder' })} />)
|
||||
// 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(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.reorderDays' })).not.toBeInTheDocument()
|
||||
rerender(<DayPlanSidebarToolbar {...makeProps({ days: [], canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ onToggleAllConnections, reservations: [buildReservation({ id: 6 })] })} />)
|
||||
expect(screen.queryByRole('button', { name: 'map.showAllConnections' })).not.toBeInTheDocument()
|
||||
|
||||
rerender(<DayPlanSidebarToolbar {...makeProps({ onToggleAllConnections, reservations: [routableReservation()] })} />)
|
||||
const show = screen.getByRole('button', { name: 'map.showAllConnections' })
|
||||
expect(show).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.click(show)
|
||||
expect(onToggleAllConnections).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<DayPlanSidebarToolbar {...makeProps({ onToggleAllConnections, reservations: [routableReservation()], allConnectionsShown: true })} />)
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ days, onToggleAllConnections: vi.fn(), reservations: [routableReservation()] })} />)
|
||||
|
||||
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(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebarTransportDetailModal>> = {}) {
|
||||
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<Reservation>)
|
||||
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps()} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-002: shows the title, date and time range in the header', () => {
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
expect(screen.getByText('planner.resPending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-005: flight metadata renders airline, number, route and seat', () => {
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
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<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
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<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight() })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), setTransportDetail, onNavigateToFiles })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res, onEdit })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), onEdit: undefined, setTransportDetail })} />)
|
||||
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(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: flight(), setTransportDetail })} />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<React.ComponentProps<typeof DayReorderPopup>> = {}) {
|
||||
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<HTMLElement>('[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(<DayReorderPopup {...makeProps({ isOpen: false, days: threeDays() })} />)
|
||||
expect(screen.queryByText('Paris')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-002: renders one row per day with its position number', () => {
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays(), onReorder })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays(), onReorder })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays(), onReorder })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays(), onReorder })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays(), onReorder })} />)
|
||||
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(<DayReorderPopup {...makeProps({ days: threeDays(), onClose, onAddDay })} />)
|
||||
await user.click(screen.getByText('dayplan.addDay'))
|
||||
expect(onAddDay).toHaveBeenCalled()
|
||||
await user.click(screen.getByText('common.close'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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(['<gpx/>'], name, { type: 'application/gpx+xml' });
|
||||
Object.defineProperty(f, 'size', { value: size });
|
||||
return f;
|
||||
};
|
||||
const kml = (name = 'places.kml') => new File(['<kml/>'], 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(<FileImportModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Import file')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-FILEIMP-002: shows title, hint and a disabled import button', () => {
|
||||
render(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} initialFile={gpx()} />);
|
||||
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(<FileImportModal {...defaultProps} initialFile={new File(['x'], 'notes.txt')} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} initialFile={gpx()} />);
|
||||
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(<FileImportModal {...defaultProps} initialFile={kml()} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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<string, string> = {};
|
||||
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(<FileImportModal {...defaultProps} onClose={onClose} initialFile={gpx()} />);
|
||||
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(<FileImportModal {...defaultProps} pushUndo={pushUndo} initialFile={gpx()} />);
|
||||
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(<FileImportModal {...defaultProps} onClose={onClose} initialFile={kml()} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} initialFile={gpx()} />);
|
||||
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(<FileImportModal {...defaultProps} onClose={onClose} initialFile={gpx()} />);
|
||||
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(<FileImportModal {...defaultProps} />);
|
||||
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(<FileImportModal {...defaultProps} onClose={onClose} initialFile={gpx()} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Response>) {
|
||||
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<void>(res => { release = res; });
|
||||
server.use(http.get(TOKEN_URL, async () => { await gate; return HttpResponse.json({ feed_url: null }); }));
|
||||
render(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
// 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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} />);
|
||||
// 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(<IcsSubscribeModal {...defaultProps} />);
|
||||
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(<IcsSubscribeModal {...defaultProps} onClose={onClose} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Response>)) {
|
||||
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<LocationPoint | null>(initial);
|
||||
return <LocationSelect value={value} onChange={(l) => { setValue(l); onPick?.(l); }} />;
|
||||
}
|
||||
|
||||
describe('LocationSelect', () => {
|
||||
it('FE-PLANNER-LOCSEL-001: falls back to the translated placeholder', () => {
|
||||
render(<LocationSelect value={null} onChange={vi.fn()} />);
|
||||
expect(screen.getByPlaceholderText('Search station, port, address…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LOCSEL-002: an explicit placeholder wins over the default', () => {
|
||||
render(<LocationSelect value={null} onChange={vi.fn()} placeholder="Pick-up point" />);
|
||||
expect(screen.getByPlaceholderText('Pick-up point')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-LOCSEL-003: an initial value renders its name plus a clear button', () => {
|
||||
render(<LocationSelect value={{ name: 'Gare du Nord', lat: 48.88, lng: 2.35 }} onChange={vi.fn()} />);
|
||||
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(<LocationSelect value={null} onChange={vi.fn()} />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host initial={{ name: 'Gare du Nord', lat: 48.88, lng: 2.35, address: null }} onPick={onPick} />);
|
||||
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(<Host initial={{ name: 'Gare du Nord', lat: 48.88, lng: 2.35 }} onPick={onPick} />);
|
||||
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(<Host onPick={onPick} />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
// 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(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
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(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
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(<PlaceFormModal {...defaultProps} place={place} onSave={onSave} />);
|
||||
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(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
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<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} />);
|
||||
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(<PlaceFormModal {...defaultProps} onSave={onSave} categories={[cat]} />);
|
||||
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(<PlaceFormModal {...defaultProps} isMobile={false} />);
|
||||
expect(screen.getByPlaceholderText(/Search your saved places/i)).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} isMobile />);
|
||||
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(<PlaceFormModal {...defaultProps} place={place} assignmentId={12} dayAssignments={[assignment]} onSave={onSave} />);
|
||||
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(<PlaceFormModal {...defaultProps} place={place} assignmentId={999} dayAssignments={[]} />);
|
||||
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(
|
||||
<PlaceFormModal
|
||||
{...defaultProps}
|
||||
place={current}
|
||||
assignmentId={10}
|
||||
dayAssignments={[
|
||||
buildAssignment({ id: 10, day_id: 5, place: current }),
|
||||
buildAssignment({ id: 20, day_id: 6, place: otherDay }),
|
||||
buildAssignment({ id: 30, day_id: 5, place: timeless }),
|
||||
buildAssignment({ id: 40, day_id: 5, place: overlapping }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<PlaceInspector {...defaultProps} place={withId} />);
|
||||
expect(await screen.findByText('+49 30 111')).toBeTruthy();
|
||||
expect(vi.mocked(mapsApi.details)).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
|
||||
render(<PlaceInspector {...defaultProps} place={withId} />);
|
||||
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(<PlaceInspector {...defaultProps} place={withId} />);
|
||||
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(<PlaceInspector {...defaultProps} place={withId} />);
|
||||
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(<PlaceInspector {...defaultProps} place={withId} />);
|
||||
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(<PlaceInspector {...defaultProps} place={withId} />);
|
||||
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(<PlaceInspector {...defaultProps} onUpdatePlace={undefined} />);
|
||||
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(<PlaceInspector {...defaultProps} onUpdatePlace={onUpdatePlace} />);
|
||||
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<string, unknown> = {}) => ({
|
||||
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(<PlaceInspector {...defaultProps} files={files as any} />);
|
||||
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(<PlaceInspector {...defaultProps} files={[placeFile()] as any} />);
|
||||
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(<PlaceInspector {...defaultProps} onFileUpload={onFileUpload} />);
|
||||
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(<PlaceInspector {...defaultProps} onFileUpload={onFileUpload} />);
|
||||
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(<PlaceInspector {...defaultProps} files={[placeFile()] as any} onFileUpload={onFileUpload} />);
|
||||
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(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: '[[48.0,2.0]]' } as any} />);
|
||||
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(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: 'not-json' } as any} />);
|
||||
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(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: geom } as any} />);
|
||||
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(<PlaceInspector {...defaultProps} place={p} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} onRate={onRate} />);
|
||||
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(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||||
reservations={[res]} />);
|
||||
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(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||||
reservations={[res]} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps()} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps()} onSetParticipants={onSetParticipants} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }])} onSetParticipants={onSetParticipants} />);
|
||||
// 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(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }])} onSetParticipants={onSetParticipants} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }])} onSetParticipants={onSetParticipants} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} onSetParticipants={onSetParticipants} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} />);
|
||||
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(<PlaceInspector {...defaultProps} mode="collection" onCopyToTrip={vi.fn()} onRemoveFromList={vi.fn()} />);
|
||||
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(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
|
||||
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(<PlaceInspector {...defaultProps} place={withId} days={[{ id: 5, date: '2025-06-18' }] as any} selectedDayId={5} />);
|
||||
expect(await screen.findByText('Wed 10:00')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-091: without an onSetParticipants handler the chips are inert', () => {
|
||||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} onSetParticipants={undefined} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }, { user_id: 99 }])} onSetParticipants={onSetParticipants} />);
|
||||
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(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} />);
|
||||
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(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||||
reservations={[res]} />);
|
||||
expect(screen.getByText('LH400')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
|
||||
const onUploadImage = vi.fn(async () => {});
|
||||
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
|
||||
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));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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<number>;
|
||||
filtered?: Place[];
|
||||
isMobile?: boolean;
|
||||
collectionsEnabled?: boolean;
|
||||
setSelectedIds?: (ids: Set<number>) => 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<number>(),
|
||||
filtered: PLACES,
|
||||
isMobile: false,
|
||||
collectionsEnabled: false,
|
||||
setSelectedIds: () => {},
|
||||
setPendingDeleteIds: () => {},
|
||||
setCategoryPickerOpen: () => {},
|
||||
setSaveToListOpen: () => {},
|
||||
onBulkDeletePlaces: undefined,
|
||||
...overrides,
|
||||
};
|
||||
return <PlacesSelectionBar {...(state as unknown as SidebarState)} />;
|
||||
}
|
||||
|
||||
describe('PlacesSelectionBar', () => {
|
||||
it('FE-PLANNER-SELBAR-001: reports how many places are selected', () => {
|
||||
render(<Bar selectedIds={new Set([1, 2])} />);
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SELBAR-002: offers "Select all" while the selection is partial', () => {
|
||||
render(<Bar selectedIds={new Set([1])} />);
|
||||
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<number>) => {});
|
||||
render(<Bar selectedIds={new Set([1])} setSelectedIds={setSelectedIds} />);
|
||||
|
||||
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<number>) => {});
|
||||
render(<Bar selectedIds={new Set([1, 2, 3])} setSelectedIds={setSelectedIds} />);
|
||||
|
||||
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<number>) => {});
|
||||
render(<Bar filtered={[]} selectedIds={new Set()} setSelectedIds={setSelectedIds} />);
|
||||
|
||||
// 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(<Bar selectedIds={new Set()} setCategoryPickerOpen={setCategoryPickerOpen} />);
|
||||
|
||||
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(<Bar selectedIds={new Set([1])} setCategoryPickerOpen={setCategoryPickerOpen} />);
|
||||
|
||||
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(<Bar selectedIds={new Set([1])} collectionsEnabled={false} />);
|
||||
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(<Bar selectedIds={new Set([1])} collectionsEnabled setSaveToListOpen={setSaveToListOpen} />);
|
||||
|
||||
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(<Bar selectedIds={new Set()} collectionsEnabled setSaveToListOpen={setSaveToListOpen} />);
|
||||
|
||||
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(
|
||||
<Bar
|
||||
selectedIds={new Set([2, 3])}
|
||||
onBulkDeletePlaces={onBulkDeletePlaces}
|
||||
setPendingDeleteIds={setPendingDeleteIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Bar
|
||||
isMobile
|
||||
selectedIds={new Set([2, 3])}
|
||||
onBulkDeletePlaces={onBulkDeletePlaces}
|
||||
setPendingDeleteIds={setPendingDeleteIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<Bar selectedIds={new Set([1])} collectionsEnabled />);
|
||||
|
||||
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(<Bar selectedIds={new Set()} collectionsEnabled />);
|
||||
|
||||
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('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}): 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(<ReservationModal {...defaultProps} prefill={hotelPrefill()} days={reviewDays()} places={[place]} />);
|
||||
|
||||
// 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(
|
||||
<ReservationModal {...defaultProps} onSave={onSave} prefill={hotelPrefill()} days={reviewDays()} places={[place]} />,
|
||||
);
|
||||
|
||||
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(<ReservationModal {...defaultProps} prefill={hotelPrefill()} days={reviewDays()} places={[place]} />);
|
||||
// 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(
|
||||
<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} places={[place]} />,
|
||||
);
|
||||
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(<ReservationModal {...defaultProps} prefill={prefill} days={reviewDays()} />);
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} prefill={hotelPrefill()} days={reviewDays()} />);
|
||||
|
||||
// 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(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
|
||||
|
||||
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(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
onSave={onSave}
|
||||
onFileUpload={onFileUpload}
|
||||
prefill={prefill}
|
||||
days={reviewDays()}
|
||||
/>,
|
||||
);
|
||||
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(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
|
||||
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(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} days={reviewDays()} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} days={days} selectedDayId={2} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} days={reviewDays()} />);
|
||||
|
||||
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(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
onSave={onSave}
|
||||
days={[day]}
|
||||
assignments={assignments as unknown as typeof defaultProps.assignments}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} places={[place]} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} places={[place]} days={days} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} tripMembers={tripMembers} />);
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
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(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
onSave={onSave}
|
||||
onOpenExpense={onOpenExpense}
|
||||
reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />,
|
||||
);
|
||||
|
||||
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(<ReservationModal {...defaultProps} reservation={null} />);
|
||||
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(<ReservationModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||
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(<ReservationModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
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(<ReservationModal {...defaultProps} reservation={res} files={[loose]} />);
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
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(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
onSave={onSave}
|
||||
days={[day]}
|
||||
assignments={{ '1': [buildAssignment({ id: 201, day_id: 1, order_index: 0, place: museum })] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||
|
||||
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(<ReservationModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
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(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} accommodations={[]} />);
|
||||
// Hotel place picker still shows its placeholder — no crash on the missing record.
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<ReservationsPanel {...defaultProps} reservations={[flight, hotel]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[flight, hotel]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[flight]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[flight, hotel]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[adas, bobs]} tripMembers={members} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} onImport={onImport} onAirTrailImport={onAirTrailImport} />);
|
||||
// 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(<ReservationsPanel {...defaultProps} onImport={onImport} bookingImportAvailable onAirTrailImport={onAirTrailImport} airTrailAvailable />);
|
||||
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(<ReservationsPanel {...defaultProps} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[hotel]} days={[day1, day3]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} onDelete={onDelete} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} files={files} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[withPlace, withoutPlace]} days={[day]} assignments={assignments as any} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
expect(screen.queryByText('Hidden card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Transit journeys (#1065) ────────────────────────────────────────────────
|
||||
|
||||
const transitJourney = (over: Record<string, unknown> = {}) => 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(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} days={[day]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} onEdit={onEdit} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} onDelete={onDelete} onEdit={onEdit} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[transitJourney({ metadata: '{broken' })]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[booking, transitJourney()]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} onEdit={onEdit} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} onDelete={onDelete} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
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(<ReservationsPanel {...defaultProps} reservations={[undated, dated]} days={[day]} />);
|
||||
const text = document.body.textContent || '';
|
||||
expect(text.indexOf('Dated train')).toBeLessThan(text.indexOf('Undated taxi'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>) {
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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(<SubscribeLinks httpsUrl={HTTPS} webcalUrl={WEBCAL} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
||||
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(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
||||
// 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(<TransitJourneyModal {...makeProps({ reservation: res })} />)
|
||||
// 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(<TransitJourneyModal {...makeProps()} />)
|
||||
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(<TransitJourneyModal {...makeProps()} />)
|
||||
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(<TransitJourneyModal {...makeProps({ reservation: first })} />)
|
||||
expect(document.querySelector('.collab-note-md')?.textContent).toContain('first note')
|
||||
|
||||
const second = { ...makeReservation(), id: 8, title: 'Zoo → Hbf', notes: '' }
|
||||
rerender(<TransitJourneyModal {...makeProps({ reservation: second })} />)
|
||||
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(<TransitJourneyModal {...makeProps({ reservation: makeMobileReservation() })} />)
|
||||
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(<TransitJourneyModal {...makeProps({ reservation: makeMobileReservation() })} />)
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof userEvent.setup>) {
|
||||
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(<TransitSearchPanel {...makeProps({
|
||||
places: [
|
||||
buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 }),
|
||||
buildPlace({ id: 2, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 }),
|
||||
buildPlace({ id: 3, name: 'No coords', lat: null, lng: null }),
|
||||
],
|
||||
accommodations,
|
||||
})} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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<Day>)
|
||||
render(<TransitSearchPanel {...makeProps({ day: dateless, days: [dateless] })} />)
|
||||
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(<TransitSearchPanel {...makeProps({
|
||||
places: [buildPlace({ id: 9, name: 'Nowhere', lat: 999, lng: 999 })],
|
||||
})} />)
|
||||
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(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
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(<TransitSearchPanel {...makeProps({ onAdd, days: [day, nextDay] })} />)
|
||||
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(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
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<Day>)
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
|
||||
const props = makeProps({ onAdd })
|
||||
const { rerender } = render(<TransitSearchPanel {...props} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await user.click(await screen.findByText(/08:30 – 09:00/))
|
||||
|
||||
rerender(<TransitSearchPanel {...props} day={dateless} days={[dateless]} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof import('react-router-dom')>();
|
||||
@@ -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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} days={routeDays} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
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(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} isOpen={false} reservation={res} />);
|
||||
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(<TransportModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
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(<TransportModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
||||
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(
|
||||
<TransportModal
|
||||
{...defaultProps}
|
||||
onSave={onSave}
|
||||
onOpenExpense={onOpenExpense}
|
||||
reservation={buildReservation({ id: 50, type: 'flight', title: 'LH 400' })}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<TransportModal {...defaultProps} reservation={buildReservation({ id: 50, type: 'flight', title: 'LH 400' })} />);
|
||||
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(<TransportModal {...defaultProps} days={routeDays} prefill={prefill} onSave={onSave} />);
|
||||
// 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(<TransportModal {...defaultProps} reservation={null} />);
|
||||
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(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||
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(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
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(<TransportModal {...defaultProps} reservation={res} files={[loose]} />);
|
||||
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(<TransportModal {...defaultProps} reservation={res as unknown as Reservation} onSave={onSave} />);
|
||||
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(<TransportModal {...defaultProps} reservation={res} onSave={onSave} tripMembers={tripMembers} />);
|
||||
// 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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
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(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
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(
|
||||
<TransportModal
|
||||
{...defaultProps}
|
||||
days={days}
|
||||
selectedDayId={10}
|
||||
initialAutomated
|
||||
places={[louvre, eiffel, orsay]}
|
||||
assignments={assignments}
|
||||
accommodations={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<TravelerPicker tripMembers={withGuest} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
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(<TravelerPicker tripMembers={withAvatar} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
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(<TravelerPicker tripMembers={nameless} selectedIds={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.getByText('?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRAVELERS-010: hovering an unselected chip lifts its opacity and leaving restores it', () => {
|
||||
render(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set([1])} onToggle={vi.fn()} />);
|
||||
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(<TravelerPicker tripMembers={tripMembers} selectedIds={new Set([1])} onToggle={vi.fn()} />);
|
||||
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(<TravelerAvatarRow />);
|
||||
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(<TravelerAvatarRow travelers={mixed} />);
|
||||
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(<TravelerFilterAvatars members={tripMembers} active={new Set()} onToggle={vi.fn()} label="Travelers" />);
|
||||
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(<TravelerFilterAvatars members={tripMembers} active={new Set()} onToggle={onToggle} />);
|
||||
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(<TravelerFilterAvatars members={tripMembers} active={new Set([1])} onToggle={vi.fn()} />);
|
||||
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(<TravelerFilterAvatars members={withAvatar} active={new Set([1])} onToggle={vi.fn()} />);
|
||||
// The member with an avatar_url renders an <img> 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} onOpenPluginTab={onOpenPluginTab} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={1} />);
|
||||
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(<TripWarningsBanner tripId={Number.NaN} />);
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="sidebar"
|
||||
ref={state.scrollContainerRef}
|
||||
onDragEnter={state.handleSidebarDragEnter}
|
||||
onDragOver={state.handleSidebarDragOver}
|
||||
onDragLeave={state.handleSidebarDragLeave}
|
||||
onDrop={state.handleSidebarDrop}
|
||||
>
|
||||
{state.sidebarDragOver && <span>drop-active</span>}
|
||||
{state.filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
data-testid={`row-${p.id}`}
|
||||
ref={(el) => { state.registerPlaceRow(p.id, el); }}
|
||||
onContextMenu={(e) => state.openContextMenu(e, p)}
|
||||
>
|
||||
{p.name}
|
||||
</div>
|
||||
))}
|
||||
<ContextMenu menu={state.ctxMenu.menu} onClose={state.ctxMenu.close} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function makeProps(overrides: Partial<PlacesSidebarProps> = {}): 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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places: [planned, loose], assignments })} />);
|
||||
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(<Host {...makeProps({ places: [planned, loose], assignments })} />);
|
||||
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(<Host {...makeProps({ places: [track, spot] })} />);
|
||||
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(<Host {...makeProps({ places: [buildPlace({ name: 'Plain Spot' })] })} />);
|
||||
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(<Host {...makeProps({ places: [tagged, other, none] })} />);
|
||||
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(<Host {...makeProps({ places: [tagged, none] })} />);
|
||||
act(() => { S.setCategoryFilters(new Set(['uncategorized'])); });
|
||||
expect(names()).toEqual(['None']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-011: toggleCategoryFilter adds and removes a category', () => {
|
||||
render(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places })} />);
|
||||
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(<Host {...makeProps({ places: [buildPlace({ id: 9 })] })} />);
|
||||
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(<Host {...makeProps({ places: [buildPlace({ id: 9 })] })} />);
|
||||
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(<Host {...makeProps({ places: [a, b] })} />);
|
||||
act(() => { S.setSelectMode(true); S.toggleSelected(1); });
|
||||
|
||||
rerender(<Host {...makeProps({ places: [b] })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [a, b] })} />);
|
||||
act(() => { S.setSelectMode(true); S.toggleSelected(1); S.toggleSelected(2); });
|
||||
|
||||
rerender(<Host {...makeProps({ places: [a] })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], assignments, selectedDayId: 3 })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], assignments, selectedDayId: null })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], assignments: {}, selectedDayId: 8 })} />);
|
||||
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(<Host {...makeProps({ places: [buildPlace()], initialScrollTop: 240 })} />);
|
||||
expect(screen.getByTestId('sidebar').scrollTop).toBe(240);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-023: a zero offset leaves the container untouched', () => {
|
||||
render(<Host {...makeProps({ places: [buildPlace()], initialScrollTop: 0 })} />);
|
||||
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<typeof vi.fn>;
|
||||
scrollIntoView.mockClear();
|
||||
const places = [buildPlace({ id: 1, name: 'A' }), buildPlace({ id: 2, name: 'B' })];
|
||||
const { rerender } = render(<Host {...makeProps({ places, selectedPlaceId: 2 })} />);
|
||||
|
||||
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }));
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
// Re-rendering with the same selection must not scroll again.
|
||||
rerender(<Host {...makeProps({ places, selectedPlaceId: 2 })} />);
|
||||
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<typeof vi.fn>;
|
||||
const places = [buildPlace({ id: 1, name: 'A' }), buildPlace({ id: 2, name: 'B' })];
|
||||
const { rerender } = render(<Host {...makeProps({ places, selectedPlaceId: 2 })} />);
|
||||
await waitFor(() => expect(scrollIntoView).toHaveBeenCalled());
|
||||
|
||||
rerender(<Host {...makeProps({ places, selectedPlaceId: null })} />);
|
||||
scrollIntoView.mockClear();
|
||||
rerender(<Host {...makeProps({ places, selectedPlaceId: 2 })} />);
|
||||
|
||||
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<typeof vi.fn>;
|
||||
const places = [buildPlace({ id: 1, name: 'Visible' }), buildPlace({ id: 2, name: 'Hidden' })];
|
||||
const { rerender } = render(<Host {...makeProps({ places, selectedPlaceId: null })} />);
|
||||
act(() => { S.setSearch('Visible'); });
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
// The row is unmounted by the search, so registerPlaceRow has dropped its ref.
|
||||
rerender(<Host {...makeProps({ places, selectedPlaceId: 2 })} />);
|
||||
|
||||
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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
const file = new File(['<gpx/>'], '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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
const file = new File(['<gpx/>'], '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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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> | void) => {});
|
||||
render(<Host {...makeProps({ pushUndo })} />);
|
||||
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> | void) | undefined;
|
||||
const pushUndo = vi.fn((_label: string, fn: () => Promise<void> | void) => { undoFn = fn; });
|
||||
render(<Host {...makeProps({ pushUndo })} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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> | void) => {});
|
||||
render(<Host {...makeProps({ pushUndo })} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps()} />);
|
||||
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(<Host {...makeProps({ places: [place], selectedDayId: 6 })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], selectedDayId: null })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], onEditPlace })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], days, selectedDayId: 6, onAssignToDay })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place], onDeletePlace })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place] })} />);
|
||||
|
||||
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(<Host {...makeProps({ places: [place] })} />);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user