Spaces:
Sleeping
Sleeping
| // FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036 | |
| 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 { 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 }) => ( | |
| <input | |
| data-testid="time-picker" | |
| type="text" | |
| value={value} | |
| onChange={e => 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(<PlaceFormModal {...defaultProps} />); | |
| expect(document.body).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PLACEFORM-002: shows Add Place title for new place', () => { | |
| render(<PlaceFormModal {...defaultProps} place={null} />); | |
| // 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(<PlaceFormModal {...defaultProps} place={place} />); | |
| expect(screen.getByText('Edit Place')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PLACEFORM-004: shows Name field with placeholder', () => { | |
| render(<PlaceFormModal {...defaultProps} />); | |
| expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PLACEFORM-005: shows Description field', () => { | |
| render(<PlaceFormModal {...defaultProps} />); | |
| expect(screen.getByPlaceholderText(/Short description/i)).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PLACEFORM-006: shows Address field', () => { | |
| render(<PlaceFormModal {...defaultProps} />); | |
| expect(screen.getByPlaceholderText(/Street, City, Country/i)).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PLACEFORM-007: shows Add button for new place', () => { | |
| render(<PlaceFormModal {...defaultProps} place={null} />); | |
| 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(<PlaceFormModal {...defaultProps} place={place} />); | |
| expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PLACEFORM-009: shows Cancel button', () => { | |
| render(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} onClose={onClose} />); | |
| 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(<PlaceFormModal {...defaultProps} place={place} />); | |
| 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(<PlaceFormModal {...defaultProps} place={place} />); | |
| 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(<PlaceFormModal {...defaultProps} onSave={onSave} />); | |
| 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(<PlaceFormModal {...defaultProps} onSave={onSave} />); | |
| 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(<PlaceFormModal {...defaultProps} categories={cats} />); | |
| // Category label is present | |
| expect(screen.getByText('Category')).toBeInTheDocument(); | |
| }); | |
| // ββ Form initialization ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-PLACEFORM-016: prefillCoords populates lat/lng/name', () => { | |
| render( | |
| <PlaceFormModal | |
| {...defaultProps} | |
| place={null} | |
| prefillCoords={{ lat: 48.8566, lng: 2.3522, name: 'Paris', address: 'Paris, France' }} | |
| />, | |
| ); | |
| 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(<PlaceFormModal {...defaultProps} place={place} isOpen={true} />); | |
| expect(screen.getByDisplayValue('Old Place')).toBeInTheDocument(); | |
| rerender(<PlaceFormModal {...defaultProps} place={null} isOpen={false} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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); | |
| await screen.findByText('Eiffel Tower'); | |
| }); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| const searchInput = screen.getByPlaceholderText('Search places...'); | |
| await user.type(searchInput, 'Eiffel Tower'); | |
| await user.keyboard('{Enter}'); | |
| await screen.findByText('Eiffel Tower'); | |
| }); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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<typeof userEvent.setup>) { | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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: hasMapsKey=false shows OSM active message', () => { | |
| // hasMapsKey is false by default in beforeEach | |
| render(<PlaceFormModal {...defaultProps} />); | |
| expect(screen.getByText(/OpenStreetMap/i)).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(<PlaceFormModal {...defaultProps} categories={cats} />); | |
| // 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(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />); | |
| // 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 onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); | |
| // Directly invoke handleCreateCategory by setting showNewCategory via the category name input | |
| // Since there's no UI trigger for showNewCategory, we test that the prop is accepted | |
| // and category creation works by checking the modal renders correctly | |
| render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />); | |
| expect(screen.getByText('Category')).toBeInTheDocument(); | |
| // onCategoryCreated not called unless the new-category form is shown and submitted | |
| expect(onCategoryCreated).not.toHaveBeenCalled(); | |
| }); | |
| // ββ Time section (edit mode only) ββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-PLACEFORM-025: time section is NOT shown in create mode', () => { | |
| render(<PlaceFormModal {...defaultProps} place={null} />); | |
| // 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 shown in edit mode', () => { | |
| const place = buildPlace({ name: 'Test' }); | |
| render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />); | |
| // Time pickers are rendered when editing | |
| expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2); | |
| }); | |
| it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => { | |
| // Build a place with end_time before place_time | |
| const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' }); | |
| render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />); | |
| // 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( | |
| <PlaceFormModal | |
| {...defaultProps} | |
| place={currentPlace} | |
| assignmentId={10} | |
| dayAssignments={[currentAssignment, otherAssignment]} | |
| />, | |
| ); | |
| // 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(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| expect(screen.queryByText('Attach')).not.toBeInTheDocument(); | |
| }); | |
| it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => { | |
| render(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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(<PlaceFormModal {...defaultProps} onSave={onSave} />); | |
| 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(<PlaceFormModal {...defaultProps} onSave={onSave} />); | |
| 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(<PlaceFormModal {...defaultProps} onSave={onSave} />); | |
| 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(<PlaceFormModal {...defaultProps} />); | |
| 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(); | |
| }); | |
| }); | |