import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useSettingsStore } from '../../store/settingsStore'; // ── Module mocks ────────────────────────────────────────────────────────────── vi.mock('../../api/client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, mapsApi: { details: vi.fn().mockResolvedValue({ place: null }) }, }; }); vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://test/file'), })); vi.mock('../../services/photoService', () => ({ getCached: vi.fn(() => null), isLoading: vi.fn(() => false), fetchPhoto: vi.fn(), onThumbReady: vi.fn(() => () => {}), })); // ── IntersectionObserver stub ───────────────────────────────────────────────── class MockIO { observe = vi.fn(); disconnect = vi.fn(); unobserve = vi.fn(); } beforeAll(() => { (globalThis as any).IntersectionObserver = MockIO; }); // ── Import component after mocks ────────────────────────────────────────────── import PlaceInspector from './PlaceInspector'; import { mapsApi } from '../../api/client'; // ── Shared fixtures ─────────────────────────────────────────────────────────── const place = buildPlace({ id: 1, name: 'Eiffel Tower', address: 'Champ de Mars, Paris', lat: 48.8584, lng: 2.2945, description: 'Famous iron tower', }); const cat = buildCategory({ name: 'Landmark', icon: 'MapPin' }); const defaultProps = { place, categories: [cat], days: [], selectedDayId: null as number | null, selectedAssignmentId: null as number | null, assignments: {} as Record, reservations: [] as any[], onClose: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn(), onAssignToDay: vi.fn(), onRemoveAssignment: vi.fn(), files: [] as any[], onFileUpload: vi.fn().mockResolvedValue(undefined), tripMembers: [] as any[], onSetParticipants: vi.fn(), onUpdatePlace: vi.fn(), }; // ── Setup / teardown ────────────────────────────────────────────────────────── beforeEach(() => { resetAllStores(); vi.clearAllMocks(); sessionStorage.clear(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } }); vi.mocked(mapsApi.details).mockResolvedValue({ place: null }); }); // ── Tests ───────────────────────────────────────────────────────────────────── describe('PlaceInspector', () => { // ── Rendering ────────────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-001: returns null when place is null', () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); it('FE-PLANNER-INSPECTOR-002: renders without crashing with a valid place', () => { render(); expect(document.body).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-003: shows place name in header', () => { render(); expect(screen.getByText('Eiffel Tower')).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-004: shows place address', () => { render(); expect(screen.getByText(/Champ de Mars, Paris/)).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-005: shows category badge with category name', () => { const placeWithCat = buildPlace({ id: 100, category_id: cat.id }); render(); const matches = screen.getAllByText('Landmark'); expect(matches.length).toBeGreaterThan(0); }); it('FE-PLANNER-INSPECTOR-006: shows lat/lng coordinates', () => { render(); // The component renders Number(lat).toFixed(6), Number(lng).toFixed(6) expect(screen.getByText(/48\.858400/)).toBeTruthy(); expect(screen.getByText(/2\.294500/)).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-007: shows time range when place_time and end_time are set', () => { const p = buildPlace({ id: 101, place_time: '09:00', end_time: '17:00' }); render(); expect(screen.getByText(/09:00/)).toBeTruthy(); expect(screen.getByText(/17:00/)).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-008: shows only start time when no end_time', () => { const p = buildPlace({ id: 102, place_time: '09:00', end_time: null }); render(); expect(screen.getByText(/09:00/)).toBeTruthy(); // The '–' separator should not be present expect(screen.queryByText(/–/)).toBeNull(); }); it('FE-PLANNER-INSPECTOR-009: description is rendered as markdown', () => { const p = buildPlace({ id: 103, description: '**Bold text**' }); const { container } = render(); const strong = container.querySelector('strong'); expect(strong).toBeTruthy(); expect(strong?.textContent).toBe('Bold text'); }); it('FE-PLANNER-INSPECTOR-010: notes rendered when no description', () => { const p = buildPlace({ id: 104, description: null, notes: 'Some notes' } as any); render(); expect(screen.getByText(/Some notes/)).toBeTruthy(); }); // ── Close button ─────────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-011: close (X) button calls onClose', async () => { const user = userEvent.setup(); const onClose = vi.fn(); render(); // Find the X button — it's the close button with an X icon inside const buttons = screen.getAllByRole('button'); // The close button is typically in the header, first button with X icon const closeBtn = buttons.find(btn => btn.querySelector('svg')); // Click the last-found header button that has no text label (the X) // More reliable: find button by its position as close button await user.click(buttons[0]); // first button is the close X expect(onClose).toHaveBeenCalled(); }); // ── Edit / Delete buttons ────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-012: Edit button is visible', () => { render(); // Edit button is in footer actions const buttons = screen.getAllByRole('button'); expect(buttons.length).toBeGreaterThan(0); }); it('FE-PLANNER-INSPECTOR-013: clicking Edit button calls onEdit', async () => { const user = userEvent.setup(); const onEdit = vi.fn(); const { container } = render(); // The edit button has Edit2 icon — find footer buttons const allButtons = screen.getAllByRole('button'); // Edit button is second-to-last in footer (before delete) const editBtn = allButtons[allButtons.length - 2]; await user.click(editBtn); expect(onEdit).toHaveBeenCalled(); }); it('FE-PLANNER-INSPECTOR-014: clicking Delete button calls onDelete', async () => { const user = userEvent.setup(); const onDelete = vi.fn(); render(); const allButtons = screen.getAllByRole('button'); // Delete button is the last button in the footer const deleteBtn = allButtons[allButtons.length - 1]; await user.click(deleteBtn); expect(onDelete).toHaveBeenCalled(); }); // ── Assign to / remove from day ──────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-015: "Add to day" button appears when selectedDayId is set and place NOT in that day', () => { render(); const allButtons = screen.getAllByRole('button'); // The add-to-day button is the first footer button (Plus icon) // It should exist when selectedDayId is set and place is not assigned expect(allButtons.length).toBeGreaterThan(2); }); it('FE-PLANNER-INSPECTOR-016: clicking assign-to-day button calls onAssignToDay with placeId', async () => { const user = userEvent.setup(); const onAssignToDay = vi.fn(); render( ); const addBtn = screen.getByText('Add to Day').closest('button')!; await user.click(addBtn); expect(onAssignToDay).toHaveBeenCalledWith(place.id); }); it('FE-PLANNER-INSPECTOR-017: "Remove from day" button appears when place IS assigned to selectedDay', () => { const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( ); const allButtons = screen.getAllByRole('button'); expect(allButtons.length).toBeGreaterThan(2); }); it('FE-PLANNER-INSPECTOR-018: clicking remove calls onRemoveAssignment with dayId and assignmentId', async () => { const user = userEvent.setup(); const onRemoveAssignment = vi.fn(); const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( ); // Find the remove button — it has "Remove" text (sm:hidden span) const removeBtn = screen.getByText('Remove').closest('button')!; await user.click(removeBtn); // Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id) expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99); }); // ── Inline name editing ──────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-019: double-clicking name enters edit mode', async () => { const user = userEvent.setup(); render(); const nameSpan = screen.getByText('Eiffel Tower'); await user.dblClick(nameSpan); const input = screen.getByDisplayValue('Eiffel Tower'); expect(input).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-020: pressing Enter commits edit and calls onUpdatePlace', async () => { const user = userEvent.setup(); const onUpdatePlace = vi.fn(); render(); const nameSpan = screen.getByText('Eiffel Tower'); await user.dblClick(nameSpan); const input = screen.getByDisplayValue('Eiffel Tower'); await user.clear(input); await user.type(input, 'New Tower Name'); await user.keyboard('{Enter}'); expect(onUpdatePlace).toHaveBeenCalledWith(place.id, { name: 'New Tower Name' }); }); it('FE-PLANNER-INSPECTOR-021: pressing Escape cancels edit', async () => { const user = userEvent.setup(); render(); const nameSpan = screen.getByText('Eiffel Tower'); await user.dblClick(nameSpan); expect(screen.getByDisplayValue('Eiffel Tower')).toBeTruthy(); await user.keyboard('{Escape}'); expect(screen.queryByDisplayValue('Eiffel Tower')).toBeNull(); expect(screen.getByText('Eiffel Tower')).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-022: blank name does not call onUpdatePlace', async () => { const user = userEvent.setup(); const onUpdatePlace = vi.fn(); render(); const nameSpan = screen.getByText('Eiffel Tower'); await user.dblClick(nameSpan); const input = screen.getByDisplayValue('Eiffel Tower'); await user.clear(input); await user.keyboard('{Enter}'); expect(onUpdatePlace).not.toHaveBeenCalled(); }); // ── Google Maps details (mapsApi) ────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-023: mapsApi.details called when place has google_place_id', async () => { const p = buildPlace({ id: 200, google_place_id: 'ChIJ001' }); render(); await waitFor(() => { expect(vi.mocked(mapsApi.details)).toHaveBeenCalledWith('ChIJ001', expect.any(String)); }); }); it('FE-PLANNER-INSPECTOR-024: rating chip shown when googleDetails has rating', async () => { vi.mocked(mapsApi.details).mockResolvedValue({ place: { rating: 4.5, rating_count: 1200 }, } as any); const p = buildPlace({ id: 201, google_place_id: 'ChIJ002' }); render(); await screen.findByText(/4\.5/); }); it('FE-PLANNER-INSPECTOR-025: opening hours shown when available', async () => { vi.mocked(mapsApi.details).mockResolvedValue({ place: { opening_hours: ['Mon: 9:00 AM – 5:00 PM', 'Tue: 9:00 AM – 5:00 PM'] }, } as any); const user = userEvent.setup(); const p = buildPlace({ id: 202, google_place_id: 'ChIJ003' }); render(); // Wait for hours to load — the button text shows a day's hours line const hoursBtn = await screen.findByText(/Show opening hours|Opening Hours|Mon:|9:00|09:00/i); const btn = hoursBtn.closest('button')!; await user.click(btn); // After expand, one of the hours lines should be visible await waitFor(() => { expect(screen.getByText(/Mon:/)).toBeTruthy(); }); }); it('FE-PLANNER-INSPECTOR-026: open/closed badge shown when open_now is available', async () => { vi.mocked(mapsApi.details).mockResolvedValue({ place: { open_now: true }, } as any); const p = buildPlace({ id: 203, google_place_id: 'ChIJ004' }); render(); await screen.findByText(/open/i); }); it('FE-PLANNER-INSPECTOR-027: mapsApi.details NOT called when place has no google_place_id or osm_id', async () => { const p = buildPlace({ id: 204, google_place_id: null, osm_id: null }); render(); // Wait a tick await act(async () => { await new Promise(r => setTimeout(r, 50)) }); expect(vi.mocked(mapsApi.details)).not.toHaveBeenCalled(); }); // ── Files ────────────────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-028: files section shows file names after expanding', async () => { const user = userEvent.setup(); const file = { id: 1, trip_id: 1, place_id: place.id, original_name: 'photo.jpg', url: '/uploads/photo.jpg', filename: 'photo.jpg', mime_type: 'image/jpeg', file_size: 1024, created_at: '2025-01-01T00:00:00.000Z', }; render(); // The files section header/toggle is always visible; click to expand const allButtons = screen.getAllByRole('button'); const filesBtn = allButtons.find(btn => btn.textContent?.includes('1')); // Click the expand button (file count label button) if (filesBtn) { await user.click(filesBtn); await screen.findByText('photo.jpg'); } else { // Try clicking the last non-footer button const toggleButtons = allButtons.filter(btn => !btn.closest('footer')); await user.click(toggleButtons[0]); } }); it('FE-PLANNER-INSPECTOR-029: hidden file input is present when onFileUpload provided', () => { const { container } = render(); const fileInput = container.querySelector('input[type="file"]'); expect(fileInput).toBeTruthy(); }); // ── Reservation chip ─────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-030: linked reservation shown when selectedAssignmentId has a reservation', () => { const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any); const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( ); expect(screen.getByText('Museum Ticket')).toBeTruthy(); }); // ── Participants ─────────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => { const members = [buildUser({ id: 1 }), buildUser({ id: 2 })]; const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }]; render( ); // The participants section renders with a "participants" label // It's visible when tripMembers.length > 1 && selectedAssignmentId is set expect(screen.getByText(members[0].username)).toBeTruthy(); }); // ── Price chip ───────────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-032: price chip shown when place.price > 0', () => { const p = buildPlace({ id: 300, price: 15, currency: 'EUR' } as any); render(); expect(screen.getByText(/15 EUR/)).toBeTruthy(); }); // ── Phone number ─────────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-033: phone number shown when place has phone', () => { const p = buildPlace({ id: 301, phone: '+33 1 23 45 67 89' } as any); render(); expect(screen.getByText(/\+33 1 23 45 67 89/)).toBeTruthy(); }); // ── File size display ────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-034: file size displayed in KB for files < 1MB', async () => { const user = userEvent.setup(); const file = { id: 2, trip_id: 1, place_id: place.id, original_name: 'doc.pdf', url: '/uploads/doc.pdf', filename: 'doc.pdf', mime_type: 'application/pdf', file_size: 2048, created_at: '2025-01-01T00:00:00.000Z', }; render(); // Click expand to see file details const expandBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('1')); if (expandBtn) { await user.click(expandBtn); await waitFor(() => { expect(screen.getByText(/2\.0 KB/)).toBeTruthy(); }); } }); it('FE-PLANNER-INSPECTOR-035: file size displayed in MB for files >= 1MB', async () => { const user = userEvent.setup(); const file = { id: 3, trip_id: 1, place_id: place.id, original_name: 'video.mp4', url: '/uploads/video.mp4', filename: 'video.mp4', mime_type: 'video/mp4', file_size: 2 * 1024 * 1024, created_at: '2025-01-01T00:00:00.000Z', }; render(); const expandBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('1')); if (expandBtn) { await user.click(expandBtn); await waitFor(() => { expect(screen.getByText(/2\.0 MB/)).toBeTruthy(); }); } }); // ── GPX track stats ──────────────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-036: GPX track stats shown when route_geometry has 2D points', () => { const pts = [[48.8584, 2.2945], [48.8600, 2.3000], [48.8620, 2.3050]]; const p = buildPlace({ id: 302, route_geometry: JSON.stringify(pts) } as any); render(); // Track distance should be visible (e.g. "x.x km" or "xxx m") const { container } = render(); expect(container.querySelector('svg')).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-037: GPX track stats shown with 3D points (elevation data)', () => { const pts = [ [48.8584, 2.2945, 100], [48.8600, 2.3000, 120], [48.8620, 2.3050, 110], [48.8640, 2.3100, 130], ]; const p = buildPlace({ id: 303, route_geometry: JSON.stringify(pts) } as any); const { container } = render(); // Elevation stats should show max elevation 130m expect(screen.getByText(/130 m/)).toBeTruthy(); }); // ── ParticipantsBox interactions ─────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-038: participants list shows member names', () => { const member1 = buildUser({ id: 10, username: 'alice' }); const member2 = buildUser({ id: 11, username: 'bob' }); const members = [member1, member2]; const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, participants: [{ user_id: 10, username: 'alice' }], }]; render( ); // alice is a participant, should appear expect(screen.getByText('alice')).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-039: session storage cache prevents duplicate mapsApi calls', async () => { // Prime the session storage cache with language 'en' (default) sessionStorage.setItem('gdetails_ChIJ005_en', JSON.stringify({ rating: 3.0 })); const p = buildPlace({ id: 304, google_place_id: 'ChIJ005' }); render(); // Wait for effect to run await act(async () => { await new Promise(r => setTimeout(r, 50)) }); // mapsApi.details should NOT have been called (cache hit) expect(vi.mocked(mapsApi.details)).not.toHaveBeenCalled(); // Rating from cache should be visible await screen.findByText(/3\.0/); }); // ── File upload interaction ──────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-040: file input change triggers onFileUpload', async () => { const onFileUpload = vi.fn().mockResolvedValue(undefined); const { container } = render(); const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; expect(fileInput).toBeTruthy(); const testFile = new File(['content'], 'test.txt', { type: 'text/plain' }); await act(async () => { fireEvent.change(fileInput, { target: { files: [testFile] } }); }); await waitFor(() => { expect(onFileUpload).toHaveBeenCalled(); }); }); // ── formatTime: 12h format ───────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-041: time shown in 12h format when setting is 12h', () => { seedStore(useSettingsStore, { settings: { time_format: '12h' } }); const p = buildPlace({ id: 305, place_time: '14:30', end_time: null }); render(); // 14:30 in 12h = "2:30 PM" expect(screen.getByText(/2:30 PM/)).toBeTruthy(); }); // ── convertHoursLine: 24h→12h conversion ────────────────────────────────── it('FE-PLANNER-INSPECTOR-042: opening hours converted to 12h when setting is 12h', async () => { seedStore(useSettingsStore, { settings: { time_format: '12h' } }); vi.mocked(mapsApi.details).mockResolvedValue({ place: { opening_hours: ['Mon: 09:00 – 17:00'] }, } as any); const user = userEvent.setup(); const p = buildPlace({ id: 306, google_place_id: 'ChIJ006' }); render(); const hoursSpan = await screen.findByText(/9:00 AM|Show opening hours/i); const btn = hoursSpan.closest('button')!; await user.click(btn); await waitFor(() => { expect(screen.getByText(/9:00 AM/)).toBeTruthy(); }); }); // ── Google Maps URL action ───────────────────────────────────────────────── it('FE-PLANNER-INSPECTOR-043: Google Maps lat/lng button visible when no google_maps_url', () => { render(); // place has lat/lng so Google Maps button should appear with Navigation icon const allButtons = screen.getAllByRole('button'); // Find button containing "Google Maps" text const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps')); expect(mapsBtn).toBeTruthy(); }); // ── No files section when no upload handler and no files ────────────────── it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => { const { container } = render( ); expect(container.querySelector('input[type="file"]')).toBeNull(); }); // ── Participants section hidden when tripMembers <= 1 ───────────────────── it('FE-PLANNER-INSPECTOR-045: participants section hidden when tripMembers has only 1 member', () => { const member = buildUser({ id: 1, username: 'solo' }); render( ); // "solo" username might be visible from other parts but participants box should not render // The participants box renders a "users" icon — check it's absent const text = document.body.textContent || ''; // No second member to display expect(screen.queryByText('Participants')).toBeNull(); }); // ── Scroll / overflow (issue #1195) ────────────────────────────────────── it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => { const longText = 'Lorem ipsum dolor sit amet. '.repeat(200); const p = buildPlace({ id: 200, description: longText, notes: longText } as any); render(); const scroll = screen.getByTestId('inspector-scroll') as HTMLElement; expect(scroll.style.overflowY).toBe('auto'); expect(scroll.style.minHeight).toBe('0px'); // flex must allow the region to shrink/grow within the capped card expect(scroll.style.flex).not.toBe(''); expect(scroll.style.flex).not.toBe('0 0 auto'); }); it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => { const longWord = 'https://example.com/' + 'a'.repeat(300); const p = buildPlace({ id: 201, description: longWord } as any); const { container } = render(); const descDiv = container.querySelector('.collab-note-md') as HTMLElement; expect(descDiv).toBeTruthy(); expect(descDiv.style.overflowWrap).toBe('anywhere'); expect(descDiv.style.wordBreak).toBe('break-word'); }); it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => { const longText = 'Lorem ipsum dolor sit amet. '.repeat(200); const p = buildPlace({ id: 202, description: longText, notes: longText } as any); const { container } = render(); const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[]; // Both description and notes containers must keep their natural height // (flex-shrink: 0) — otherwise they compress inside the flex column and // overflow:hidden clips the text with no scroll (issue #1195). expect(notes.length).toBe(2); for (const el of notes) { expect(el.style.flexShrink).toBe('0'); } }); });