Spaces:
Sleeping
Sleeping
| 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<typeof import('../../api/client')>(); | |
| 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<string, any[]>, | |
| 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(<PlaceInspector {...defaultProps} place={null} />); | |
| expect(container.firstChild).toBeNull(); | |
| }); | |
| it('FE-PLANNER-INSPECTOR-002: renders without crashing with a valid place', () => { | |
| render(<PlaceInspector {...defaultProps} />); | |
| expect(document.body).toBeTruthy(); | |
| }); | |
| it('FE-PLANNER-INSPECTOR-003: shows place name in header', () => { | |
| render(<PlaceInspector {...defaultProps} />); | |
| expect(screen.getByText('Eiffel Tower')).toBeTruthy(); | |
| }); | |
| it('FE-PLANNER-INSPECTOR-004: shows place address', () => { | |
| render(<PlaceInspector {...defaultProps} />); | |
| 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(<PlaceInspector {...defaultProps} place={placeWithCat} categories={[cat]} />); | |
| const matches = screen.getAllByText('Landmark'); | |
| expect(matches.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-PLANNER-INSPECTOR-006: shows lat/lng coordinates', () => { | |
| render(<PlaceInspector {...defaultProps} />); | |
| // 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} onClose={onClose} />); | |
| // 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(<PlaceInspector {...defaultProps} />); | |
| // 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(<PlaceInspector {...defaultProps} onEdit={onEdit} />); | |
| // 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(<PlaceInspector {...defaultProps} onDelete={onDelete} />); | |
| 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(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />); | |
| 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| selectedDayId={1} | |
| assignments={{ '1': [] }} | |
| onAssignToDay={onAssignToDay} | |
| /> | |
| ); | |
| 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| selectedDayId={1} | |
| assignments={{ '1': assignmentInDay }} | |
| /> | |
| ); | |
| 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| selectedDayId={1} | |
| assignments={{ '1': assignmentInDay }} | |
| onRemoveAssignment={onRemoveAssignment} | |
| /> | |
| ); | |
| // 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(<PlaceInspector {...defaultProps} />); | |
| 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(<PlaceInspector {...defaultProps} onUpdatePlace={onUpdatePlace} />); | |
| 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(<PlaceInspector {...defaultProps} />); | |
| 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(<PlaceInspector {...defaultProps} onUpdatePlace={onUpdatePlace} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| // 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| // 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(<PlaceInspector {...defaultProps} files={[file as any]} />); | |
| // 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(<PlaceInspector {...defaultProps} />); | |
| 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| selectedDayId={1} | |
| selectedAssignmentId={99} | |
| assignments={{ '1': assignmentInDay }} | |
| reservations={[reservation]} | |
| /> | |
| ); | |
| 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| tripMembers={members} | |
| selectedDayId={1} | |
| selectedAssignmentId={99} | |
| assignments={{ '1': assignmentInDay }} | |
| /> | |
| ); | |
| // 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} files={[file as any]} />); | |
| // 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(<PlaceInspector {...defaultProps} files={[file as any]} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| // Track distance should be visible (e.g. "x.x km" or "xxx m") | |
| const { container } = render(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| // 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| tripMembers={members} | |
| selectedDayId={1} | |
| selectedAssignmentId={99} | |
| assignments={{ '1': assignmentInDay }} | |
| /> | |
| ); | |
| // 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(<PlaceInspector {...defaultProps} place={p} />); | |
| // 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(<PlaceInspector {...defaultProps} onFileUpload={onFileUpload} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| // 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} />); | |
| // 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( | |
| <PlaceInspector {...defaultProps} files={[]} onFileUpload={undefined} /> | |
| ); | |
| 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( | |
| <PlaceInspector | |
| {...defaultProps} | |
| tripMembers={[member]} | |
| selectedDayId={1} | |
| selectedAssignmentId={99} | |
| assignments={{ '1': [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }] }} | |
| /> | |
| ); | |
| // "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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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(<PlaceInspector {...defaultProps} place={p} />); | |
| 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'); | |
| } | |
| }); | |
| }); | |