// FE-COMP-PLACES-001 to FE-COMP-PLACES-015 + FE-PLANNER-SIDEBAR-016 to 043 import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { usePermissionsStore } from '../../store/permissionsStore'; import { placesApi } from '../../api/client'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; import PlacesSidebar from './PlacesSidebar'; // Mock photoService so PlaceAvatar doesn't trigger API calls vi.mock('../../services/photoService', () => ({ getCached: vi.fn(() => null), isLoading: vi.fn(() => false), fetchPhoto: vi.fn(), onThumbReady: vi.fn(() => () => {}), })); // PlaceAvatar uses `new IntersectionObserver(...)` — needs a class-based mock class MockIO { observe = vi.fn(); disconnect = vi.fn(); unobserve = vi.fn(); } beforeAll(() => { (globalThis as any).IntersectionObserver = MockIO; }); const defaultProps = { tripId: 1, places: [], categories: [], assignments: {}, selectedDayId: null, selectedPlaceId: null, onPlaceClick: vi.fn(), onAddPlace: vi.fn(), onAssignToDay: vi.fn(), onEditPlace: vi.fn(), onDeletePlace: vi.fn(), days: [], isMobile: false, }; beforeEach(() => { resetAllStores(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); }); describe('PlacesSidebar', () => { it('FE-COMP-PLACES-001: renders without crashing', () => { render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-PLACES-002: shows search input', () => { render(); const searchInput = screen.getByPlaceholderText(/Search places/i); expect(searchInput).toBeInTheDocument(); }); it('FE-COMP-PLACES-003: renders places from props', () => { const places = [ buildPlace({ name: 'Eiffel Tower' }), buildPlace({ name: 'Louvre Museum' }), ]; render(); expect(screen.getByText('Eiffel Tower')).toBeInTheDocument(); expect(screen.getByText('Louvre Museum')).toBeInTheDocument(); }); it('FE-COMP-PLACES-004: shows Add Place button', () => { render(); // Multiple "Add Place/Activity" buttons may exist (top toolbar + empty state) const addBtns = screen.getAllByText(/Add Place\/Activity/i); expect(addBtns.length).toBeGreaterThan(0); }); it('FE-COMP-PLACES-005: clicking Add Place calls onAddPlace', async () => { const user = userEvent.setup(); const onAddPlace = vi.fn(); render(); const addBtns = screen.getAllByText(/Add Place\/Activity/i); await user.click(addBtns[0]); expect(onAddPlace).toHaveBeenCalled(); }); it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => { const user = userEvent.setup(); const onPlaceClick = vi.fn(); const place = buildPlace({ id: 42, name: 'Notre Dame' }); render(); await user.click(screen.getByText('Notre Dame')); expect(onPlaceClick).toHaveBeenCalled(); }); it('FE-COMP-PLACES-007: search filters places by name', async () => { const user = userEvent.setup(); const places = [ buildPlace({ name: 'Arc de Triomphe' }), buildPlace({ name: 'Sacre Coeur' }), ]; render(); const searchInput = screen.getByPlaceholderText(/Search places/i); await user.type(searchInput, 'Arc'); expect(screen.getByText('Arc de Triomphe')).toBeInTheDocument(); expect(screen.queryByText('Sacre Coeur')).not.toBeInTheDocument(); }); it('FE-COMP-PLACES-008: search is case-insensitive', async () => { const user = userEvent.setup(); const places = [buildPlace({ name: 'Museum of Art' })]; render(); const searchInput = screen.getByPlaceholderText(/Search places/i); await user.type(searchInput, 'museum'); expect(screen.getByText('Museum of Art')).toBeInTheDocument(); }); it('FE-COMP-PLACES-009: selected place is highlighted', () => { const place = buildPlace({ id: 10, name: 'Central Park' }); render(); expect(screen.getByText('Central Park')).toBeInTheDocument(); }); it('FE-COMP-PLACES-010: shows place count', () => { const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })]; render(); // i18n: places.count = "{count} places" expect(screen.getByText(/3 places/i)).toBeInTheDocument(); }); it('FE-COMP-PLACES-011: empty list shows no place names', () => { render(); expect(screen.queryByText(/Eiffel/)).not.toBeInTheDocument(); }); it('FE-COMP-PLACES-012: categories from props render without error', () => { const cats = [buildCategory({ name: 'Restaurant' }), buildCategory({ name: 'Hotel' })]; render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-PLACES-013: clearing search shows all places again', async () => { const user = userEvent.setup(); const places = [buildPlace({ name: 'Place A' }), buildPlace({ name: 'Place B' })]; render(); const searchInput = screen.getByPlaceholderText(/Search places/i); await user.type(searchInput, 'Place A'); expect(screen.queryByText('Place B')).not.toBeInTheDocument(); await user.clear(searchInput); expect(screen.getByText('Place B')).toBeInTheDocument(); }); it('FE-COMP-PLACES-014: renders with days prop for day assignment', () => { const days = [buildDay({ id: 1, date: '2025-06-01' })]; render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-PLACES-015: onEditPlace passed to component correctly', () => { const onEditPlace = vi.fn(); const place = buildPlace({ name: 'Test Place' }); render(); expect(screen.getByText('Test Place')).toBeInTheDocument(); }); }); // ── Filter tabs ─────────────────────────────────────────────────────────────── describe('Filter tabs', () => { it('FE-PLANNER-SIDEBAR-016: "All" tab is active by default', () => { const places = [buildPlace({ name: 'Place Alpha' }), buildPlace({ name: 'Place Beta' })]; render(); expect(screen.getByText('Place Alpha')).toBeInTheDocument(); expect(screen.getByText('Place Beta')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-017: "Unplanned" tab filters out planned places', async () => { const user = userEvent.setup(); const planned = buildPlace({ name: 'Planned Place' }); const unplanned = buildPlace({ name: 'Unplanned Place' }); const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; render(); await user.click(screen.getByRole('button', { name: /Unplanned/i })); expect(screen.queryByText('Planned Place')).not.toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-018: "All" tab re-shows planned places', async () => { const user = userEvent.setup(); const planned = buildPlace({ name: 'Planned Place' }); const unplanned = buildPlace({ name: 'Unplanned Place' }); const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; render(); await user.click(screen.getByRole('button', { name: /Unplanned/i })); await user.click(screen.getByRole('button', { name: /^All/i })); expect(screen.getByText('Planned Place')).toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-019: unplanned empty state shows "All places are planned"', async () => { const user = userEvent.setup(); const place = buildPlace({ name: 'Assigned Place' }); const assignments = { '1': [buildAssignment({ place, day_id: 1 })] }; render(); await user.click(screen.getByRole('button', { name: /Unplanned/i })); expect(screen.getByText(/All places are planned/i)).toBeInTheDocument(); }); }); // ── Search ──────────────────────────────────────────────────────────────────── describe('Search', () => { it('FE-PLANNER-SIDEBAR-020: search filters by address', async () => { const user = userEvent.setup(); const place = buildPlace({ name: 'UK Office', address: '10 Downing Street' }); const other = buildPlace({ name: 'Other Place', address: null }); render(); await user.type(screen.getByPlaceholderText(/Search places/i), 'Downing'); expect(screen.getByText('UK Office')).toBeInTheDocument(); expect(screen.queryByText('Other Place')).not.toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-021: clear search (X) button appears and resets search', async () => { const user = userEvent.setup(); const places = [buildPlace({ name: 'Paris Hotel' }), buildPlace({ name: 'Rome Cafe' })]; render(); const searchInput = screen.getByPlaceholderText(/Search places/i); await user.type(searchInput, 'Paris'); expect(screen.queryByText('Rome Cafe')).not.toBeInTheDocument(); // X clear button should appear const clearBtn = document.querySelector('button svg[data-lucide="x"]')?.closest('button') ?? document.querySelector('input[type="text"] ~ button') ?? screen.getByRole('button', { name: '' }); // Find the X button by querying near the search input const inputWrapper = searchInput.closest('div'); const xBtn = inputWrapper?.querySelector('button'); expect(xBtn).toBeTruthy(); await user.click(xBtn!); expect(screen.getByText('Rome Cafe')).toBeInTheDocument(); }); }); // ── Category filter dropdown ────────────────────────────────────────────────── describe('Category filter dropdown', () => { it('FE-PLANNER-SIDEBAR-022: category dropdown renders when categories are present', () => { const cat = buildCategory({ name: 'Museum', color: '#3b82f6' }); render(); expect(screen.getByText(/All Categories/i)).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-023: clicking category dropdown opens options', async () => { const user = userEvent.setup(); const cat = buildCategory({ name: 'Museum', color: '#3b82f6' }); render(); await user.click(screen.getByText(/All Categories/i)); expect(screen.getByText('Museum')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-024: selecting a category filters places', async () => { const user = userEvent.setup(); const cat = buildCategory({ name: 'Park', color: '#22c55e' }); // Give places addresses so category name doesn't appear as subtitle const withCat = buildPlace({ name: 'Central Park', category_id: cat.id, address: 'New York, NY' }); const noCat = buildPlace({ name: 'Random Shop', category_id: null, address: 'London, UK' }); render(); await user.click(screen.getByText(/All Categories/i)); // Click the category option in the dropdown (only one 'Park' now — no subtitle conflict) await user.click(screen.getByText('Park')); expect(screen.getByText('Central Park')).toBeInTheDocument(); expect(screen.queryByText('Random Shop')).not.toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-025: "Clear filter" button appears when filter active and clears it', async () => { const user = userEvent.setup(); const cat = buildCategory({ name: 'Museum', color: '#3b82f6' }); // Give places addresses so category name doesn't appear as subtitle const withCat = buildPlace({ name: 'Art Museum', category_id: cat.id, address: 'Paris' }); const noCat = buildPlace({ name: 'Untagged Place', category_id: null, address: 'Berlin' }); render(); await user.click(screen.getByText(/All Categories/i)); await user.click(screen.getByText('Museum')); expect(screen.queryByText('Untagged Place')).not.toBeInTheDocument(); // Clear filter button should appear expect(screen.getByText(/Clear filter/i)).toBeInTheDocument(); await user.click(screen.getByText(/Clear filter/i)); expect(screen.getByText('Untagged Place')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-026: multi-category selection shows count', async () => { const user = userEvent.setup(); const cat1 = buildCategory({ name: 'Museum', color: '#3b82f6' }); const cat2 = buildCategory({ name: 'Park', color: '#22c55e' }); render(); await user.click(screen.getByText(/All Categories/i)); const museumOpts = screen.getAllByText('Museum'); await user.click(museumOpts[museumOpts.length - 1]); const parkOpts = screen.getAllByText('Park'); await user.click(parkOpts[parkOpts.length - 1]); expect(screen.getByText(/2 categories/i)).toBeInTheDocument(); }); }); // ── Place list interaction ───────────────────────────────────────────────────── describe('Place list interaction', () => { it('FE-PLANNER-SIDEBAR-027: "+" assign button appears when selectedDayId set and place not in day', () => { const place = buildPlace({ name: 'Unassigned Place' }); render(); // Plus button should be visible next to the place const plusBtns = screen.getAllByRole('button'); const plusBtn = plusBtns.find(b => b.querySelector('svg')); expect(plusBtn).toBeTruthy(); // The place row itself should be in the DOM expect(screen.getByText('Unassigned Place')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-028: clicking "+" assign button calls onAssignToDay with placeId', async () => { const user = userEvent.setup(); const onAssignToDay = vi.fn(); const place = buildPlace({ id: 99, name: 'Place To Assign' }); render(); // Find the + button inside the place row (small inline button) const placeRow = screen.getByText('Place To Assign').closest('div[draggable]')!; const plusBtn = placeRow.querySelector('button')!; await user.click(plusBtn); expect(onAssignToDay).toHaveBeenCalledWith(99); }); it('FE-PLANNER-SIDEBAR-029: "+" button not shown when place already assigned to selectedDay', () => { const place = buildPlace({ id: 55, name: 'Already Assigned' }); const assignments = { '5': [buildAssignment({ place, day_id: 5 })] }; render(); const placeRow = screen.getByText('Already Assigned').closest('div[draggable]')!; const plusBtn = placeRow.querySelector('button'); expect(plusBtn).toBeNull(); }); it('FE-PLANNER-SIDEBAR-030: place address shown as subtitle', () => { const place = buildPlace({ name: 'Paris Spot', address: 'Rue de Rivoli', description: null }); render(); expect(screen.getByText('Rue de Rivoli')).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-031: no edit buttons shown when canEditPlaces=false', () => { seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } }); render(); expect(screen.queryByText(/Add Place\/Activity/i)).not.toBeInTheDocument(); expect(screen.queryByText(/GPX/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Google List/i)).not.toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-032: place count shows singular form for 1 place', () => { const place = buildPlace({ name: 'Solo Place' }); render(); expect(screen.getByText('1 place')).toBeInTheDocument(); }); }); // ── Mobile day-picker (portal) ───────────────────────────────────────────────── describe('Mobile day-picker (portal)', () => { it('FE-PLANNER-SIDEBAR-033: on mobile, clicking a place opens day-picker bottom sheet', async () => { const user = userEvent.setup(); const place = buildPlace({ name: 'Mobile Place' }); render(); await user.click(screen.getByText('Mobile Place')); // The bottom sheet portal renders an extra copy of the place name + action buttons expect(await screen.findAllByText('Mobile Place')).toHaveLength(2); // Sheet-specific button is always present expect(screen.getByText(/View details/i)).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-034: day-picker lists days and clicking a day calls onAssignToDay', async () => { const user = userEvent.setup(); const onAssignToDay = vi.fn(); const place = buildPlace({ id: 77, name: 'Day Picker Place' }); const day = buildDay({ id: 7, title: 'Day 1' }); render(); await user.click(screen.getByText('Day Picker Place')); // Click "Add to which day?" to expand the day list const assignBtn = await screen.findByText(/Add to which day\?/i); await user.click(assignBtn); // Click Day 1 expect(await screen.findByText('Day 1')).toBeInTheDocument(); await user.click(screen.getByText('Day 1')); expect(onAssignToDay).toHaveBeenCalledWith(77, 7); }); it('FE-PLANNER-SIDEBAR-035: day-picker backdrop click dismisses sheet', async () => { const user = userEvent.setup(); const place = buildPlace({ name: 'Dismissable Place' }); render(); await user.click(screen.getByText('Dismissable Place')); // Wait for the sheet to open (always shows "View details") await screen.findByText(/View details/i); expect(screen.getAllByText('Dismissable Place')).toHaveLength(2); // Click the backdrop (fixed overlay div — first fixed overlay in body) const backdrop = document.querySelector('[style*="position: fixed"][style*="inset: 0"]') as HTMLElement; expect(backdrop).toBeTruthy(); await user.click(backdrop!); await waitFor(() => { expect(screen.queryByText(/View details/i)).not.toBeInTheDocument(); }); }); it('FE-PLANNER-SIDEBAR-036: day-picker Edit button calls onEditPlace', async () => { const user = userEvent.setup(); const onEditPlace = vi.fn(); const place = buildPlace({ id: 88, name: 'Editable Place' }); render(); await user.click(screen.getByText('Editable Place')); const editBtn = await screen.findByText(/^Edit$/i); await user.click(editBtn); expect(onEditPlace).toHaveBeenCalledWith(expect.objectContaining({ id: 88 })); }); it('FE-PLANNER-SIDEBAR-037: day-picker Delete button calls onDeletePlace', async () => { const user = userEvent.setup(); const onDeletePlace = vi.fn(); const place = buildPlace({ id: 66, name: 'Deletable Place' }); render(); await user.click(screen.getByText('Deletable Place')); const deleteBtn = await screen.findByText(/^Delete$/i); await user.click(deleteBtn); expect(onDeletePlace).toHaveBeenCalledWith(66); }); }); // ── GPX import ──────────────────────────────────────────────────────────────── describe('GPX import', () => { it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText(/Import file/i)); expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => { const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] }); const loadTrip = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { loadTrip }); const addToast = vi.fn(); (window as any).__addToast = addToast; const user = userEvent.setup(); render(); await user.click(screen.getByText(/Import file/i)); const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement; expect(fileInput).toBeTruthy(); const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' }); await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }); }); await user.click(screen.getByRole('button', { name: /^import$/i })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining('2'), 'success', undefined, ); }); importSpy.mockRestore(); }); }); // ── Google Maps list import ─────────────────────────────────────────────────── describe('Google Maps list import', () => { it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText(/List Import/i)); expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText(/List Import/i)); await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); const importBtn = screen.getByRole('button', { name: /^Import$/i }); expect(importBtn).toBeDisabled(); }); it('FE-PLANNER-SIDEBAR-042: successful Google list import shows success toast and closes dialog', async () => { server.use( http.post('/api/trips/1/places/import/google-list', () => HttpResponse.json({ count: 3, listName: 'My List', places: [{ id: 20 }, { id: 21 }, { id: 22 }] }) ), ); const loadTrip = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { loadTrip }); const addToast = vi.fn(); (window as any).__addToast = addToast; const user = userEvent.setup(); render(); await user.click(screen.getByText(/List Import/i)); const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); await user.type(urlInput, 'https://maps.app.goo.gl/abc123'); await user.click(screen.getByRole('button', { name: /^Import$/i })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining('3'), 'success', undefined, ); }); // Dialog should close await waitFor(() => { expect(screen.queryByPlaceholderText(/maps\.app\.goo\.gl/i)).not.toBeInTheDocument(); }); }); it('FE-PLANNER-SIDEBAR-043: pressing Enter in URL field triggers import', async () => { server.use( http.post('/api/trips/1/places/import/google-list', () => HttpResponse.json({ count: 1, listName: 'Test', places: [{ id: 30 }] }) ), ); const loadTrip = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { loadTrip }); const addToast = vi.fn(); (window as any).__addToast = addToast; const user = userEvent.setup(); render(); await user.click(screen.getByText(/List Import/i)); const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}'); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining('1'), 'success', undefined, ); }); }); });