// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -072
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';
import PlaceFormModal from './PlaceFormModal';
// Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI
vi.mock('../shared/CustomTimePicker', () => ({
default: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
onChange(e.target.value)}
placeholder={placeholder ?? '00:00'}
/>
),
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn(),
place: null,
prefillCoords: null,
tripId: 1,
categories: [],
onCategoryCreated: vi.fn(),
assignmentId: null,
dayAssignments: [],
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('PlaceFormModal', () => {
it('FE-COMP-PLACEFORM-001: renders modal when isOpen is true', () => {
render();
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-002: shows Add Place title for new place', () => {
render();
// places.addPlace = "Add Place/Activity"
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
});
it('FE-COMP-PLACEFORM-003: shows Edit Place title when editing', () => {
const place = buildPlace({ name: 'Eiffel Tower' });
render();
expect(screen.getByText('Edit Place')).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-004: shows Name field with placeholder', () => {
render();
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-005: shows Description field', () => {
render();
expect(screen.getByPlaceholderText(/Short description/i)).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-006: shows Address field', () => {
render();
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-007: shows Add button for new place', () => {
render();
expect(screen.getByRole('button', { name: /^Add$/i })).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-008: shows Update button when editing', () => {
const place = buildPlace({ name: 'Test Place' });
render();
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-009: shows Cancel button', () => {
render();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-010: clicking Cancel calls onClose', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('FE-COMP-PLACEFORM-011: pre-fills name field when editing existing place', () => {
const place = buildPlace({ name: 'Notre Dame' });
render();
const nameInput = screen.getByDisplayValue('Notre Dame');
expect(nameInput).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-012: pre-fills address when editing existing place', () => {
const place = buildPlace({ name: 'Test', address: '123 Main St' });
render();
expect(screen.getByDisplayValue('123 Main St')).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-013: submitting empty form does not call onSave (name required)', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /^Add$/i }));
// Form validation prevents calling onSave without a name
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-PLACEFORM-014: typing in name field and submitting calls onSave', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Sacre Coeur');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sacre Coeur' }));
});
it('FE-COMP-PLACEFORM-015: categories appear in category selector', () => {
const cats = [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Park' })];
render();
// Category label is present
expect(screen.getByText('Category')).toBeInTheDocument();
});
// ── Form initialization ──────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-016: prefillCoords populates lat/lng/name', () => {
render(
,
);
expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument();
expect(screen.getByDisplayValue('Paris')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-017: form resets when isOpen changes from place to null', () => {
const place = buildPlace({ name: 'Old Place' });
const { rerender } = render();
expect(screen.getByDisplayValue('Old Place')).toBeInTheDocument();
rerender();
expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument();
});
// ── Maps search ──────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-018: maps search populates results via button click', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }],
}),
),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel Tower');
// The search button is the sibling button of the search input
const searchRow = searchInput.closest('.flex') as HTMLElement;
const searchBtn = within(searchRow).getByRole('button');
await user.click(searchBtn);
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }],
}),
),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel Tower');
await user.keyboard('{Enter}');
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-020: clicking a maps result fills the form', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }],
}),
),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel Tower');
await user.keyboard('{Enter}');
const resultBtn = await screen.findByText('Eiffel Tower');
await user.click(resultBtn);
expect(screen.getByDisplayValue('Eiffel Tower')).toBeInTheDocument();
expect(screen.getByDisplayValue('48.8584')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-021: maps search error surfaces the server-provided reason', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
// The backend forwards the real upstream error (e.g. a Google Places API message);
// the modal must show it instead of a generic "search failed" so the cause is visible.
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ error: 'Places API (New) has not been used in project 123 or it is disabled' }, { status: 403 })),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'someplace');
await user.keyboard('{Enter}');
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringMatching(/Places API \(New\) has not been used/i),
'error',
undefined,
);
});
delete window.__addToast;
});
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
// it yields no usable place the modal must fall back to the reliable text search
// instead of dead-ending on "Place search failed".
async function openSuggestion(user: ReturnType) {
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel');
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
return screen.findByText('Paris, France');
}
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
source: 'openstreetmap',
}),
),
);
render();
const suggestion = await openSuggestion(user);
await user.click(suggestion);
// Form is populated from the search fallback, and no error toast is shown.
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
source: 'openstreetmap',
}),
),
);
render();
const suggestion = await openSuggestion(user);
await user.click(suggestion);
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'nominatim',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
);
render();
const suggestion = await openSuggestion(user);
await user.click(suggestion);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
});
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
// It used to say "Using OpenStreetMap" above the box whenever no Google key
// was set, which was a sentence about configuration in the middle of a form
// about a place. Each result names its own source now, which is the question
// people actually had.
render();
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'trek-places',
}),
),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
expect(await screen.findByText('TREK')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
// The whole point of putting it on the row: with both indexes answering,
// a label above the list could only name one of them. The index marks its
// own rows, so the unmarked one is OpenStreetMap by elimination.
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
],
source: 'trek-places+openstreetmap',
}),
),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await user.keyboard('{Enter}');
expect(await screen.findByText('TREK')).toBeInTheDocument();
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// ── Category ─────────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-023: category selector renders options', () => {
// The component conditionally shows CustomSelect (showNewCategory=false) or text input
// Default state shows CustomSelect; no visible "+" trigger exists in current code
const cats = [buildCategory({ name: 'Beaches' }), buildCategory({ name: 'Museums' })];
render();
// The "No category" placeholder text from CustomSelect should be visible
expect(screen.getByText(/No category/i)).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
// Regression: form.category_id is a string but the option values were numbers,
// so CustomSelect's strict-equality match failed and the trigger fell back to
// "No category". With string option values the chosen category renders.
const cat = buildCategory({ name: 'Museums' });
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
render();
// Dropdown is closed, so the only place the category name can appear is the trigger.
expect(screen.getByText('Museums')).toBeInTheDocument();
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
const user = userEvent.setup();
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
render();
expect(onCategoryCreated).not.toHaveBeenCalled();
// The plus next to the category select swaps in the inline name field.
await user.click(screen.getByRole('button', { name: 'New category' }));
await user.type(screen.getByPlaceholderText('Category name'), 'Beaches');
await user.click(screen.getByRole('button', { name: 'OK' }));
expect(onCategoryCreated).toHaveBeenCalledWith({ name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
// The new category is selected and the inline field closes again.
await waitFor(() => expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument());
});
it('FE-PLANNER-PLACEFORM-024b: an empty category name does not call onCategoryCreated', async () => {
const user = userEvent.setup();
const onCategoryCreated = vi.fn();
render();
await user.click(screen.getByRole('button', { name: 'New category' }));
await user.click(screen.getByRole('button', { name: 'OK' }));
expect(onCategoryCreated).not.toHaveBeenCalled();
const inlineRow = screen.getByPlaceholderText('Category name').parentElement!;
await user.click(within(inlineRow).getByRole('button', { name: 'Cancel' }));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
});
// ── Time section (edit mode only) ────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-025: time section is NOT shown in create mode', () => {
render();
// English labels are 'Start' and 'End' (places.startTime / places.endTime)
expect(screen.queryByText(/^Start$/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^End$/i)).not.toBeInTheDocument();
// Also verify no time pickers rendered
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
// Times are per day-assignment; editing a pool place with no day in context
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
const place = buildPlace({ name: 'Test' });
render();
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
const assignment = buildAssignment({ id: 10, day_id: 5, place });
render();
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
// The pool Place carries no times — they live on the day-assignment. Opening the
// editor with an assignmentId must hydrate the fields from assignment.place, not
// the (timeless) pool place that the Places panel passes in.
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
render();
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
// Build an assignment whose place has end_time before place_time
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
const assignment = buildAssignment({ id: 11, day_id: 5, place });
render();
// hasTimeError = true → submit button disabled
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
expect(submitBtn).toBeDisabled();
});
it('FE-PLANNER-PLACEFORM-028: time collision warning appears when assignments overlap', () => {
// Create an assignment for the "current" place being edited
const currentPlace = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' });
const conflictingPlace = buildPlace({ name: 'Other Event', place_time: '13:00', end_time: '14:00' });
const currentAssignment = buildAssignment({ id: 10, day_id: 5, place: currentPlace });
const otherAssignment = buildAssignment({ id: 20, day_id: 5, place: conflictingPlace });
render(
,
);
// English translation: 'places.timeCollision' = 'Time overlap with:'
expect(screen.getByText(/Time overlap with:/i)).toBeInTheDocument();
});
// ── File attachments ──────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-029: file attachment section shown when canUploadFiles=true', () => {
// Default: permissions={} → not configured → allow → canUploadFiles=true
render();
expect(screen.getByText('Attach')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-030: file attachment section hidden when canUploadFiles=false', () => {
// Set file_upload to 'admin' level; non-admin user cannot upload
seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } });
render();
expect(screen.queryByText('Attach')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
render();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } });
await screen.findByText('photo.jpg');
});
it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
const user = userEvent.setup();
render();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } });
await screen.findByText('remove-me.jpg');
// The X button is inside the file item's container div
const fileItem = screen.getByText('remove-me.jpg').closest('div.flex') as HTMLElement;
const removeBtn = within(fileItem).getByRole('button');
await user.click(removeBtn);
expect(screen.queryByText('remove-me.jpg')).not.toBeInTheDocument();
});
// ── Submit ────────────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-033: onSave receives parsed lat/lng as numbers', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame');
const latInput = screen.getByPlaceholderText(/Latitude/i);
await user.clear(latInput);
await user.type(latInput, '48.853');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ lat: 48.853 }));
});
it('FE-PLANNER-PLACEFORM-034: onSave error shows toast', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockRejectedValue(new Error('Server error'));
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('Server error', 'error', undefined);
});
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-035: save button shows "Saving..." while saving', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument();
});
});
it('FE-PLANNER-PLACEFORM-036: lat/lng paste splits "48.8566, 2.3522" into lat and lng fields', () => {
render();
const latInput = screen.getByPlaceholderText(/Latitude/i);
fireEvent.paste(latInput, {
clipboardData: {
getData: () => '48.8566, 2.3522',
},
});
expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument();
expect(screen.getByDisplayValue('2.3522')).toBeInTheDocument();
});
// ── Duplicate detection (#1152) ────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-037: a same-named place warns first and saves only on the second click', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre' })] });
render();
// Case-insensitive match against the existing trip place.
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), ' louvre ');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined);
// The button now reads as the explicit confirmation.
const addAnyway = await screen.findByRole('button', { name: /Add anyway/i });
await user.click(addAnyway);
await waitFor(() => expect(onSave).toHaveBeenCalled());
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-038: near-identical coordinates count as a duplicate even under a new name', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })],
});
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Iron Lady');
fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), {
clipboardData: { getData: () => '48.85845, 2.29455' },
});
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined);
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-039: a far-away place with a fresh name saves straight away', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })],
});
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Colosseum');
fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), {
clipboardData: { getData: () => '41.8902, 12.4922' },
});
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Colosseum', lat: 41.8902, lng: 12.4922 }));
});
it('FE-PLANNER-PLACEFORM-040: editing an existing place never triggers the duplicate guard', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ name: 'Louvre' });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [place] });
render();
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
});
it('FE-PLANNER-PLACEFORM-041: an empty name is rejected with the name-required toast', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith('Please enter a name', 'error', undefined);
delete window.__addToast;
});
// ── Location bias from the trip's existing places ───────────────────────────
it('FE-PLANNER-PLACEFORM-042: a tight cluster of trip places biases the autocomplete bounding box', async () => {
const user = userEvent.setup();
const bodies: Record[] = [];
server.use(
http.post('/api/maps/autocomplete', async ({ request }) => {
bodies.push((await request.json()) as Record);
return HttpResponse.json({ suggestions: [] });
}),
);
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
places: [
buildPlace({ lat: 48.85, lng: 2.34 }),
buildPlace({ lat: 48.87, lng: 2.37 }),
// Garbage coordinates must be skipped, not poison the box.
buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number }),
],
});
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0].locationBias).toEqual({
low: { lat: 48.85, lng: 2.34 },
high: { lat: 48.87, lng: 2.37 },
});
});
it('FE-PLANNER-PLACEFORM-043: places spread over more than 500 km send no location bias', async () => {
const user = userEvent.setup();
const bodies: Record[] = [];
server.use(
http.post('/api/maps/autocomplete', async ({ request }) => {
bodies.push((await request.json()) as Record);
return HttpResponse.json({ suggestions: [] });
}),
);
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
places: [buildPlace({ lat: 48.85, lng: 2.34 }), buildPlace({ lat: 41.89, lng: 12.49 })],
});
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0].locationBias).toBeUndefined();
});
it('FE-PLANNER-PLACEFORM-043b: trip places with unusable coordinates yield no bias at all', async () => {
const user = userEvent.setup();
const bodies: Record[] = [];
server.use(
http.post('/api/maps/autocomplete', async ({ request }) => {
bodies.push((await request.json()) as Record);
return HttpResponse.json({ suggestions: [] });
}),
);
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
places: [buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number })],
});
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await waitFor(() => expect(bodies).toHaveLength(1));
expect(bodies[0].locationBias).toBeUndefined();
});
it('FE-PLANNER-PLACEFORM-045b: focusing with a Google Maps URL in the box fetches no suggestions', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
http.post('/api/maps/autocomplete', () => {
calls += 1;
return HttpResponse.json({ suggestions: [] });
}),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'https://maps.google.com/maps?q=x');
fireEvent.focus(searchInput);
await waitFor(() => expect(screen.queryByText('Paris, France')).not.toBeInTheDocument());
expect(calls).toBe(0);
});
it('FE-PLANNER-PLACEFORM-044: a failing autocomplete clears the dropdown instead of surfacing an error', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await waitFor(() => expect(consoleError).toHaveBeenCalledWith('Autocomplete failed:', expect.anything()));
expect(addToast).not.toHaveBeenCalled();
consoleError.mockRestore();
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-045: re-focusing the search box refetches when the dropdown is empty', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
http.post('/api/maps/autocomplete', () => {
calls += 1;
return HttpResponse.json({ suggestions: [] });
}),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel');
await waitFor(() => expect(calls).toBe(1));
fireEvent.focus(searchInput);
await waitFor(() => expect(calls).toBe(2));
});
// ── Maps search / Google Maps URL resolve ──────────────────────────────────
it('FE-PLANNER-PLACEFORM-046: clicking search with an empty box makes no request', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
http.post('/api/maps/search', () => {
calls += 1;
return HttpResponse.json({ places: [] });
}),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
const searchBtn = within(searchInput.closest('.flex') as HTMLElement).getByRole('button');
await user.click(searchBtn);
expect(calls).toBe(0);
});
it('FE-PLANNER-PLACEFORM-047: pasting a Google Maps URL resolves it straight into the form', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
http.post('/api/maps/resolve-url', () =>
HttpResponse.json({ name: 'Notre-Dame', address: 'Parvis Notre-Dame, Paris', lat: 48.8530, lng: 2.3499, google_ftid: 'ftid-1' }),
),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame');
await user.keyboard('{Enter}');
expect(await screen.findByDisplayValue('Notre-Dame')).toBeInTheDocument();
expect(screen.getByDisplayValue('48.853')).toBeInTheDocument();
expect(screen.getByDisplayValue('Parvis Notre-Dame, Paris')).toBeInTheDocument();
// The URL is consumed, so the search box empties out.
expect(screen.getByPlaceholderText('Search places...')).toHaveValue('');
expect(addToast).toHaveBeenCalledWith('Place imported from URL', 'success', undefined);
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-048: an unresolvable Google Maps URL falls through to the text search', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/resolve-url', () => HttpResponse.json({ name: 'Notre-Dame', lat: null, lng: null })),
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Notre-Dame de Paris', address: 'Paris', lat: '48.853', lng: '2.3499' }] }),
),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame');
await user.keyboard('{Enter}');
expect(await screen.findByText('Notre-Dame de Paris')).toBeInTheDocument();
});
// ── Autocomplete keyboard navigation ───────────────────────────────────────
const twoSuggestions = () =>
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [
{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' },
{ placeId: 'node:2', mainText: 'Eiffel Museum', secondaryText: 'Berlin, Germany' },
],
}),
);
it('FE-PLANNER-PLACEFORM-049: ArrowDown/ArrowUp move the highlight and Enter picks the highlighted suggestion', async () => {
const user = userEvent.setup();
server.use(
twoSuggestions(),
http.get('/api/maps/details/:placeId', () =>
HttpResponse.json({ place: { name: 'Eiffel Museum', address: 'Berlin', lat: 52.52, lng: 13.405 } }),
),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiffel');
await screen.findByText('Paris, France');
// -1 → wraps to the last entry, then ArrowDown wraps back to the first.
await user.keyboard('{ArrowUp}');
expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-surface-tertiary');
await user.keyboard('{ArrowDown}');
expect(screen.getByText('Eiffel Tower').closest('button')).toHaveClass('bg-surface-tertiary');
await user.keyboard('{ArrowDown}');
expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-surface-tertiary');
await user.keyboard('{Enter}');
expect(await screen.findByDisplayValue('52.52')).toBeInTheDocument();
expect(screen.getByDisplayValue('13.405')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-050: Escape closes the dropdown without searching', async () => {
const user = userEvent.setup();
let searches = 0;
server.use(
twoSuggestions(),
http.post('/api/maps/search', () => {
searches += 1;
return HttpResponse.json({ places: [] });
}),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await screen.findByText('Paris, France');
await user.keyboard('{Escape}');
expect(screen.queryByText('Paris, France')).not.toBeInTheDocument();
expect(searches).toBe(0);
});
it('FE-PLANNER-PLACEFORM-051: Enter with nothing highlighted closes the dropdown and runs the full search', async () => {
const user = userEvent.setup();
server.use(
twoSuggestions(),
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Champ de Mars', lat: '48.8584', lng: '2.2945' }] }),
),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await screen.findByText('Paris, France');
await user.keyboard('{Enter}');
expect(await screen.findByText('Champ de Mars')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-052: when both details and the search fallback fail the query is restored', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
server.use(
twoSuggestions(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
http.post('/api/maps/search', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await user.click(await screen.findByText('Paris, France'));
await waitFor(() =>
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Place search failed|nope/i), 'error', undefined),
);
// The typed query comes back so the user can retry instead of starting over.
await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('Eiffel'));
consoleError.mockRestore();
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-053: a Google place id shared with an existing trip place is flagged as duplicate', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
places: [buildPlace({ name: 'Already Here', google_place_id: 'gp-1', lat: null, lng: null })],
});
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Same Spot', address: 'Somewhere', lat: '10', lng: '10', google_place_id: 'gp-1' }] }),
),
);
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'same spot');
await user.keyboard('{Enter}');
await user.click(await screen.findByText('Same Spot'));
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Already Here'), 'warning', undefined);
delete window.__addToast;
});
// ── Paste-to-attach ────────────────────────────────────────────────────────
function pasteItems(target: Element, items: { type: string; file: File | null }[]) {
fireEvent.paste(target, {
clipboardData: {
items: items.map(i => ({ type: i.type, getAsFile: () => i.file })),
},
});
}
it('FE-PLANNER-PLACEFORM-054: pasting an image attaches it as a pending file', () => {
render();
const form = document.querySelector('form') as HTMLFormElement;
pasteItems(form, [
{ type: 'text/plain', file: null },
{ type: 'image/png', file: new File(['x'], 'screenshot.png', { type: 'image/png' }) },
]);
expect(screen.getByText('screenshot.png')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-057: a prefilled place drives the detail column, not the previous one', () => {
// A POI tapped on the map opens the dialog through prefillCoords, never
// through the search handler — the column used to keep the last place.
const { rerender } = render(
,
);
expect(screen.getByDisplayValue('Helgas Kitchen')).toBeInTheDocument();
rerender(
,
);
expect(screen.getByDisplayValue('Museum Ludwig')).toBeInTheDocument();
expect(screen.queryByDisplayValue('Helgas Kitchen')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-055: pasting plain text alone attaches nothing', () => {
render();
const form = document.querySelector('form') as HTMLFormElement;
pasteItems(form, [{ type: 'text/plain', file: null }]);
// The list only renders once something is attached, so its absence is the
// assertion (this used to lean on the clipboard hint, which is gone).
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
// Counter-check, so the assertion above cannot pass on a missing testid:
// a real file does show up in that list.
pasteItems(form, [{ type: 'image/png', file: new File(['x'], 'shot.png', { type: 'image/png' }) }]);
expect(screen.getByTestId('pending-files')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-056: paste is ignored entirely without upload permission', () => {
seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } });
render();
const form = document.querySelector('form') as HTMLFormElement;
pasteItems(form, [{ type: 'application/pdf', file: new File(['x'], 'ticket.pdf', { type: 'application/pdf' }) }]);
expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
const user = userEvent.setup();
render();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i }));
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
// ── Remaining form fields ──────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-058: description, notes, website and category all reach the save payload', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const cat = buildCategory({ name: 'Museums' });
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.type(screen.getByPlaceholderText(/Short description/i), 'Glass pyramid');
await user.type(screen.getByPlaceholderText(/Personal notes/i), 'Book ahead');
await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli');
await user.type(screen.getByPlaceholderText(/Longitude/i), '2.3376');
await user.type(screen.getByPlaceholderText('https://...'), 'https://louvre.fr');
// Pick the category through the CustomSelect trigger.
await user.click(screen.getByText(/No category/i));
await user.click(screen.getByRole('button', { name: 'Museums' }));
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Louvre',
description: 'Glass pyramid',
notes: 'Book ahead',
address: 'Rue de Rivoli',
lng: 2.3376,
website: 'https://louvre.fr',
category_id: String(cat.id),
}),
);
});
it('FE-PLANNER-PLACEFORM-059: the Collections column only appears on desktop with the addon enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
loaded: true,
});
const { unmount } = render();
expect(screen.getByPlaceholderText(/Search your saved places/i)).toBeInTheDocument();
unmount();
render();
expect(screen.queryByPlaceholderText(/Search your saved places/i)).not.toBeInTheDocument();
});
// ── Time section edge cases ────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-060: editing the times through the pickers updates the save payload', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ name: 'Museum' });
const assignment = buildAssignment({ id: 12, day_id: 4, place });
render();
const [start, end] = screen.getAllByTestId('time-picker');
fireEvent.change(start, { target: { value: '09:00' } });
fireEvent.change(end, { target: { value: '10:30' } });
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ place_time: '09:00', end_time: '10:30' }));
});
it('FE-PLANNER-PLACEFORM-061: an assignment id that is not in the day list reports no collisions', () => {
const place = buildPlace({ name: 'Ghost', place_time: '12:00', end_time: '13:00' });
render();
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText(/Time overlap with:/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-062: collisions ignore other days and timeless assignments', () => {
const current = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' });
const otherDay = buildPlace({ name: 'Other Day Event', place_time: '13:00', end_time: '14:00' });
const timeless = buildPlace({ name: 'Timeless Event' });
const overlapping = buildPlace({ name: 'Overlapping Event', place_time: '13:00', end_time: '14:00' });
render(
,
);
const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement;
expect(warning).toHaveTextContent('Overlapping Event');
expect(warning).not.toHaveTextContent('Other Day Event');
expect(warning).not.toHaveTextContent('Timeless Event');
});
});
// FE-W5PFM-001 to FE-W5PFM-014 — the remaining prefill, response-shape and
// error branches of the place form.
describe('PlaceFormModal remaining branches', () => {
it('FE-W5PFM-001: an existing place with empty fields opens on the defaults', () => {
const place = buildPlace({
name: null, description: null, address: null, lat: null, lng: null,
category_id: null, notes: null, transport_mode: null, website: null,
});
render();
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
expect(screen.getByPlaceholderText('https://...')).toHaveValue('');
});
it('FE-W5PFM-002: prefilled coordinates without a name only fill the coordinates', () => {
render();
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('');
});
it('FE-W5PFM-003: an autocomplete response without a suggestions array shows no dropdown', async () => {
const user = userEvent.setup();
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({})));
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
expect(screen.queryByText('Eiffel Tower, Paris')).not.toBeInTheDocument();
});
it('FE-W5PFM-004: an aborted autocomplete is swallowed without logging', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const user = userEvent.setup();
const gate: { release?: () => void } = {};
let seen = 0;
server.use(
http.post('/api/maps/autocomplete', async () => {
seen += 1;
if (seen === 1) await new Promise(res => { gate.release = res; });
return HttpResponse.json({
suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
});
}),
);
render();
const searchInput = screen.getByPlaceholderText('Search places...');
await user.type(searchInput, 'Eiff');
await waitFor(() => expect(seen).toBe(1));
// The second query aborts the still-open first one
await user.type(searchInput, 'el tower');
await waitFor(() => expect(seen).toBe(2));
gate.release?.();
expect(await screen.findByText('Paris, France')).toBeInTheDocument();
expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1);
expect(consoleError).not.toHaveBeenCalledWith('Autocomplete failed:', expect.anything());
consoleError.mockRestore();
});
it('FE-W5PFM-005: a resolved URL without a name or address keeps the form values', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: 48.853, lng: 2.3499 })),
);
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Kept Name');
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=x');
await user.keyboard('{Enter}');
expect(await screen.findByDisplayValue('48.853')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Kept Name');
});
it('FE-W5PFM-006: a search response without a places array renders no results', async () => {
const user = userEvent.setup();
server.use(http.post('/api/maps/search', () => HttpResponse.json({})));
render();
await user.type(screen.getByPlaceholderText('Search places...'), 'nowhere');
await user.keyboard('{Enter}');
await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('nowhere'));
expect(screen.queryByRole('button', { name: /nowhere/i })).not.toBeInTheDocument();
});
it('FE-W5PFM-007: picking a bare search result leaves the typed fields alone', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Bare Result' }] })),
);
render();
await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli');
await user.type(screen.getByPlaceholderText('Search places...'), 'bare');
await user.keyboard('{Enter}');
await user.click(await screen.findByText('Bare Result'));
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Bare Result');
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('Rue de Rivoli');
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
});
it('FE-W5PFM-008: a place sharing only the name is flagged as a 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: 'Louvre', lat: null, lng: null })] });
render();
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 second click is the explicit confirmation
await user.click(screen.getByRole('button', { name: /Add anyway/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
delete window.__addToast;
});
it('FE-W5PFM-009: a place sharing only the coordinates is flagged as a 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: null, lat: 48.8584, lng: 2.2945 })] });
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Tour Eiffel');
await user.type(screen.getByPlaceholderText(/Latitude/i), '48.85841');
await user.type(screen.getByPlaceholderText(/Longitude/i), '2.29451');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
// the existing place has no name, so the warning falls back to the typed one
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined);
delete window.__addToast;
});
it('FE-W5PFM-010: a rejected save without an Error surfaces the generic message', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
const user = userEvent.setup();
const onSave = vi.fn().mockRejectedValue('nope');
const onClose = vi.fn();
render();
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined));
expect(onClose).not.toHaveBeenCalled();
delete window.__addToast;
});
it('FE-W5PFM-011: pasting a coordinate pair into latitude fills both fields', () => {
render();
const lat = screen.getByPlaceholderText(/Latitude/i);
fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8584, 2.2945 ' } });
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
});
it('FE-W5PFM-012: pasting anything else into latitude is left to the input', () => {
render();
const lat = screen.getByPlaceholderText(/Latitude/i);
fireEvent.paste(lat, { clipboardData: { getData: () => 'not a coordinate' } });
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
});
it('FE-W5PFM-013: a missing category list still renders the no-category option', () => {
render();
expect(screen.getByText(/No category/i)).toBeInTheDocument();
});
it('FE-W5PFM-014: an open-ended assignment collapses to a point in time', () => {
const current = buildPlace({ name: 'My Event', place_time: '11:30', end_time: '' });
const spanning = buildPlace({ name: 'Spanning Event', place_time: '11:00', end_time: '12:00' });
const earlier = buildPlace({ name: 'Earlier Event', place_time: '11:00', end_time: '' });
render(
,
);
const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement;
expect(warning).toHaveTextContent('Spanning Event');
expect(warning).not.toHaveTextContent('Earlier Event');
});
/**
* FE-PLANNER-PLACEFORM-063..068 — what a second pick may overwrite.
*
* Search Hamburg Airport, then Berlin Hauptbahnhof, and the airport's website
* was still in the field: `result.website || prev.website` cannot tell "the
* user typed this" from "the last result wrote this". Saving then gave the
* station the airport's website.
*/
describe('picking a second place', () => {
const searchFor = async (user: ReturnType, place: Record) => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [place] })));
const searchInput = screen.getByPlaceholderText('Search places...');
await user.clear(searchInput);
await user.type(searchInput, String(place.name));
const searchRow = searchInput.closest('.flex') as HTMLElement;
await user.click(within(searchRow).getByRole('button'));
await user.click(await screen.findByText(String(place.name)));
};
const AIRPORT = {
name: 'Hamburg Airport',
address: 'Hamburg',
lat: '53.6323',
lng: '10.0067',
website: 'https://www.hamburg-airport.de/',
};
const STATION = { name: 'Berlin Hauptbahnhof', address: 'Berlin', lat: '52.525', lng: '13.3694' };
const websiteInput = () => screen.getByPlaceholderText('https://...') as HTMLInputElement;
it('FE-PLANNER-PLACEFORM-063: a place with no website clears the previous one', async () => {
const user = userEvent.setup();
render();
await searchFor(user, AIRPORT);
expect(websiteInput().value).toBe('https://www.hamburg-airport.de/');
await searchFor(user, STATION);
expect(websiteInput().value).toBe('');
});
it('FE-PLANNER-PLACEFORM-064: a website the user typed survives the next pick', async () => {
const user = userEvent.setup();
render();
await user.type(websiteInput(), 'https://mine.example');
await searchFor(user, STATION);
expect(websiteInput().value).toBe('https://mine.example');
});
it('FE-PLANNER-PLACEFORM-065: a picked value the user then edited also survives', async () => {
const user = userEvent.setup();
render();
await searchFor(user, AIRPORT);
await user.clear(websiteInput());
await user.type(websiteInput(), 'https://corrected.example');
await searchFor(user, STATION);
expect(websiteInput().value).toBe('https://corrected.example');
});
it('FE-PLANNER-PLACEFORM-066: the ids of the previous place do not travel with the new one', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render();
await searchFor(user, { ...AIRPORT, google_place_id: 'ChIJham' });
await searchFor(user, { ...STATION, osm_id: 'relation:3600565' });
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.osm_id).toBe('relation:3600565');
expect(payload.google_place_id).toBeFalsy();
});
it('FE-PLANNER-PLACEFORM-067: editing a saved place keeps its stored website', async () => {
// The dangerous direction: the form was filled from the database, so a
// search result that says nothing about a field must not empty it.
const user = userEvent.setup();
render(
,
);
expect(websiteInput().value).toBe('https://stored.example');
await searchFor(user, STATION);
expect(websiteInput().value).toBe('https://stored.example');
});
});
// ── Linked expense (#1298) — the same block bookings have ──────────────────
describe('Costs section', () => {
const withBudget = () => seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => {
const { unmount } = render();
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
unmount();
withBudget();
render();
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => {
withBudget();
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render();
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled());
expect(onSave).toHaveBeenCalled();
expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]);
expect(onOpenExpense).toHaveBeenCalledWith({
prefill: { placeId: 42, name: 'Louvre', category: 'activities' },
});
});
it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => {
withBudget();
const onSave = vi.fn().mockResolvedValue(undefined);
const onOpenExpense = vi.fn();
render();
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onOpenExpense).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => {
withBudget();
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render();
// No name yet — the save is refused and the intent must not survive it.
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onSave).not.toHaveBeenCalled());
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onOpenExpense).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => {
withBudget();
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }],
});
render();
expect(screen.getByText('Louvre tickets')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => {
withBudget();
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }],
});
render();
expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
});
});
});