Spaces:
Sleeping
Sleeping
| // FE-PLANNER-DAYPLAN-001 to FE-PLANNER-DAYPLAN-042 | |
| import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' | |
| import userEvent from '@testing-library/user-event' | |
| import { useAuthStore } from '../../store/authStore' | |
| import { useTripStore } from '../../store/tripStore' | |
| import { useSettingsStore } from '../../store/settingsStore' | |
| import { resetAllStores, seedStore } from '../../../tests/helpers/store' | |
| import { | |
| buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation, | |
| } from '../../../tests/helpers/factories' | |
| import DayPlanSidebar from './DayPlanSidebar' | |
| // ββ Hoisted mock state (accessible in vi.mock factories) ββββββββββββββββββββ | |
| const mockDayNotesState = vi.hoisted(() => ({ | |
| noteUi: {} as Record<string, any>, | |
| dayNotes: {} as Record<string, any[]>, | |
| setNoteUi: vi.fn(), | |
| noteInputRef: { current: null } as { current: null }, | |
| openAddNote: vi.fn(), | |
| openEditNote: vi.fn(), | |
| cancelNote: vi.fn(), | |
| saveNote: vi.fn(), | |
| deleteNote: vi.fn(), | |
| moveNote: vi.fn(), | |
| })) | |
| // ββ Module mocks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vi.mock('../../api/client', async (importOriginal) => { | |
| const actual = await importOriginal() as any | |
| return { | |
| ...actual, | |
| assignmentsApi: { | |
| reorder: vi.fn().mockResolvedValue({}), | |
| remove: vi.fn().mockResolvedValue({}), | |
| updateTime: vi.fn().mockResolvedValue({}), | |
| }, | |
| reservationsApi: { | |
| list: vi.fn().mockResolvedValue({ reservations: [] }), | |
| updatePositions: vi.fn().mockResolvedValue({}), | |
| }, | |
| } | |
| }) | |
| vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) })) | |
| vi.mock('../Map/RouteCalculator', () => ({ | |
| calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }), | |
| generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'), | |
| optimizeRoute: vi.fn().mockImplementation((places) => places), | |
| })) | |
| // PlaceAvatar needs IntersectionObserver | |
| class MockIO { observe = vi.fn(); disconnect = vi.fn(); unobserve = vi.fn() } | |
| beforeAll(() => { (globalThis as any).IntersectionObserver = MockIO }) | |
| vi.mock('../../services/photoService', () => ({ | |
| getCached: vi.fn(() => null), | |
| isLoading: vi.fn(() => false), | |
| fetchPhoto: vi.fn(), | |
| onThumbReady: vi.fn(() => () => {}), | |
| })) | |
| vi.mock('../../hooks/useDayNotes', () => ({ | |
| useDayNotes: () => mockDayNotesState, | |
| })) | |
| vi.mock('../Weather/WeatherWidget', () => ({ | |
| default: () => <span data-testid="weather-widget" />, | |
| })) | |
| vi.mock('../shared/Toast', () => ({ | |
| useToast: () => ({ error: vi.fn(), success: vi.fn() }), | |
| })) | |
| // ββ Permissions mock ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vi.mock('../../store/permissionsStore', async (importOriginal) => { | |
| const actual = await importOriginal() as any | |
| return { | |
| ...actual, | |
| useCanDo: () => () => true, | |
| } | |
| }) | |
| // ββ Default props βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const trip = buildTrip({ id: 1, currency: 'EUR' }) | |
| function makeDefaultProps(overrides = {}) { | |
| return { | |
| tripId: 1, | |
| trip, | |
| days: [], | |
| places: [], | |
| categories: [], | |
| assignments: {}, | |
| selectedDayId: null, | |
| selectedPlaceId: null, | |
| selectedAssignmentId: null, | |
| onSelectDay: vi.fn(), | |
| onPlaceClick: vi.fn(), | |
| onDayDetail: vi.fn(), | |
| accommodations: [], | |
| onReorder: vi.fn(), | |
| onUpdateDayTitle: vi.fn(), | |
| onRouteCalculated: vi.fn(), | |
| onAssignToDay: vi.fn(), | |
| onRemoveAssignment: vi.fn(), | |
| onEditPlace: vi.fn(), | |
| onDeletePlace: vi.fn(), | |
| reservations: [], | |
| onAddReservation: vi.fn(), | |
| onNavigateToFiles: vi.fn(), | |
| ...overrides, | |
| } | |
| } | |
| // ββ Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| beforeEach(() => { | |
| resetAllStores() | |
| vi.clearAllMocks() | |
| sessionStorage.clear() | |
| // Reset mutable day-notes state | |
| mockDayNotesState.noteUi = {} | |
| mockDayNotesState.dayNotes = {} | |
| seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) | |
| seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }) | |
| seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any) | |
| }) | |
| // ββ Tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('DayPlanSidebar', () => { | |
| // ββ Rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-001: renders without crashing', () => { | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| expect(document.body).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-002: renders day titles', () => { | |
| const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| expect(screen.getByText('Amsterdam Day')).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-003: renders day number when title is null', () => { | |
| const day = buildDay({ title: null, date: '2025-06-01' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| expect(screen.getByText(/Day 1/i)).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-004: renders formatted date alongside title', () => { | |
| const day = buildDay({ date: '2025-06-15', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| expect(screen.getByText(/Jun 15|15 Jun/)).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-005: renders multiple days', () => { | |
| const days = [ | |
| buildDay({ title: 'D1', date: '2025-06-01' }), | |
| buildDay({ title: 'D2', date: '2025-06-02' }), | |
| ] | |
| render(<DayPlanSidebar {...makeDefaultProps({ days })} />) | |
| expect(screen.getByText('D1')).toBeInTheDocument() | |
| expect(screen.getByText('D2')).toBeInTheDocument() | |
| }) | |
| // ββ Day expansion/collapse ββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => { | |
| const place = buildPlace({ name: 'Eiffel Tower' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const assignments = { '10': [assignment] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />) | |
| expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-007: clicking chevron collapses that day', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ name: 'Eiffel Tower' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const assignments = { '10': [assignment] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />) | |
| // The chevron button immediately follows the "Add Note" button (which has a title attribute) | |
| const addNoteBtn = screen.getByLabelText('Add Note') | |
| const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement | |
| expect(chevron).toBeTruthy() | |
| await user.click(chevron) | |
| expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-008: clicking chevron again re-expands', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ name: 'Eiffel Tower' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const assignments = { '10': [assignment] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />) | |
| const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement | |
| await user.click(getChevron()) // collapse | |
| expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument() | |
| await user.click(getChevron()) // re-expand | |
| expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() | |
| }) | |
| // ββ Day selection βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-009: clicking day header calls onSelectDay', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'My Day' }) | |
| const onSelectDay = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onSelectDay })} />) | |
| await user.click(screen.getByText('My Day')) | |
| expect(onSelectDay).toHaveBeenCalledWith(10) | |
| }) | |
| it('FE-PLANNER-DAYPLAN-010: selectedDayId renders without error', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'My Day' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], selectedDayId: 10 })} />) | |
| expect(screen.getByText('My Day')).toBeInTheDocument() | |
| }) | |
| // ββ Assigned places βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-011: assigned place name rendered in day card', () => { | |
| const place = buildPlace({ name: 'Louvre Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />) | |
| expect(screen.getByText('Louvre Museum')).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-012: assigned place time is shown when set', () => { | |
| const place = buildPlace({ name: 'Louvre Museum', place_time: '10:00' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />) | |
| expect(screen.getByText(/10:00/)).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-013: clicking a place calls onPlaceClick', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 42, name: 'Louvre Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const onPlaceClick = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] }, onPlaceClick })} />) | |
| await user.click(screen.getByText('Louvre Museum')) | |
| expect(onPlaceClick).toHaveBeenCalledWith(42, 99) | |
| }) | |
| it('FE-PLANNER-DAYPLAN-014: selectedPlaceId renders the place without error', () => { | |
| const place = buildPlace({ id: 42, name: 'Louvre Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] }, selectedPlaceId: 42 })} />) | |
| expect(screen.getByText('Louvre Museum')).toBeInTheDocument() | |
| }) | |
| // ββ Day title editing βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-015: clicking edit button enters edit mode', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| await user.click(screen.getByLabelText('Edit')) | |
| await waitFor(() => { | |
| expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument() | |
| }) | |
| }) | |
| it('FE-PLANNER-DAYPLAN-016: pressing Enter commits title', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) | |
| const onUpdateDayTitle = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />) | |
| // Enter edit mode | |
| await user.click(screen.getByLabelText('Edit')) | |
| const input = await screen.findByDisplayValue('Original Title') | |
| await user.clear(input) | |
| await user.type(input, 'New Title') | |
| await user.keyboard('{Enter}') | |
| expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title') | |
| }) | |
| it('FE-PLANNER-DAYPLAN-017: pressing Escape cancels edit', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| await user.click(screen.getByLabelText('Edit')) | |
| const input = await screen.findByDisplayValue('Original Title') | |
| await user.keyboard('{Escape}') | |
| expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument() | |
| expect(screen.getByText('Original Title')).toBeInTheDocument() | |
| }) | |
| // ββ Day info button βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-018: clicking day header calls onDayDetail', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'My Day' }) | |
| const onDayDetail = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onDayDetail })} />) | |
| await user.click(screen.getByText('My Day')) | |
| expect(onDayDetail).toHaveBeenCalledWith(day) | |
| }) | |
| // ββ Context menu ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-019: right-click on assignment opens context menu', () => { | |
| const place = buildPlace({ name: 'Louvre Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />) | |
| const placeEl = screen.getByText('Louvre Museum') | |
| fireEvent.contextMenu(placeEl) | |
| // Context menu should show Edit and Remove options | |
| expect(screen.getByText('Edit')).toBeInTheDocument() | |
| expect(screen.getByText(/Remove from day/i)).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-020: context menu Remove calls onRemoveAssignment', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ name: 'Louvre Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const onRemoveAssignment = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] }, onRemoveAssignment })} />) | |
| fireEvent.contextMenu(screen.getByText('Louvre Museum')) | |
| await user.click(screen.getByText(/Remove from day/i)) | |
| expect(onRemoveAssignment).toHaveBeenCalledWith(10, 99) | |
| }) | |
| // ββ Undo bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-022: undo bar shown when canUndo=true', () => { | |
| const onUndo = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />) | |
| // The undo button should be present (Undo2 icon) | |
| const undoButtons = screen.getAllByRole('button') | |
| const undoBtn = undoButtons.find(btn => !(btn as HTMLButtonElement).disabled && btn.querySelector('svg')) | |
| expect(undoBtn).toBeDefined() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-023: clicking undo button calls onUndo', async () => { | |
| const user = userEvent.setup() | |
| const onUndo = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />) | |
| const undoBtn = screen.getByLabelText('Undo') | |
| await user.click(undoBtn) | |
| expect(onUndo).toHaveBeenCalled() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => { | |
| render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />) | |
| expect(screen.queryByLabelText('Undo')).toBeNull() | |
| }) | |
| // ββ PDF export ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => { | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| expect(screen.getByText('PDF')).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => { | |
| const user = userEvent.setup() | |
| const { downloadTripPDF } = await import('../PDF/TripPDF') | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| await user.click(screen.getByText('PDF')) | |
| await waitFor(() => { | |
| expect(downloadTripPDF).toHaveBeenCalled() | |
| }) | |
| }) | |
| // ββ Route calculation βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-027: route button present when day has 2+ assigned places', () => { | |
| const place1 = buildPlace({ id: 1, name: 'Place A', lat: 48.85, lng: 2.35 }) | |
| const place2 = buildPlace({ id: 2, name: 'Place B', lat: 48.86, lng: 2.36 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 1, day_id: 10, order_index: 0, place: place1 }) | |
| const a2 = buildAssignment({ id: 2, day_id: 10, order_index: 1, place: place2 }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], | |
| places: [place1, place2], | |
| assignments: { '10': [a1, a2] }, | |
| selectedDayId: 10, | |
| })} />) | |
| // Route/navigation button should be visible β look for Navigation icon button | |
| const buttons = screen.getAllByRole('button') | |
| // The component renders navigation-related buttons when a day is selected with 2+ geo places | |
| expect(buttons.length).toBeGreaterThan(0) | |
| }) | |
| // ββ Empty states ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-029: day with no assignments shows empty state', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Empty Day' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments: {} })} />) | |
| expect(screen.getByText(/No places planned for this day/i)).toBeInTheDocument() | |
| }) | |
| // ββ Transport items βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-030: flight reservation renders in day with matching date', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' }) | |
| const reservation = buildReservation({ | |
| id: 200, | |
| type: 'flight', | |
| title: 'Paris to London', | |
| reservation_time: '2025-06-01T08:00:00', | |
| day_id: 10, | |
| }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />) | |
| expect(screen.getByText('Paris to London')).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => { | |
| const user = userEvent.setup() | |
| const onEditTransport = vi.fn() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' }) | |
| const reservation = buildReservation({ | |
| id: 200, | |
| type: 'flight', | |
| title: 'Air France 123', | |
| reservation_time: '2025-06-01T08:00:00', | |
| day_id: 10, | |
| }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />) | |
| await user.click(screen.getByText('Air France 123')) | |
| await waitFor(() => { | |
| expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 })) | |
| }) | |
| }) | |
| // ββ Accommodation badges ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-032: accommodation badge renders hotel name in day header', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Hotel Day' }) | |
| const accommodation = { | |
| id: 99, | |
| start_day_id: 10, | |
| end_day_id: 10, | |
| place_name: 'Grand Hyatt', | |
| place_id: 500, | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], accommodations: [accommodation as any] })} />) | |
| expect(screen.getByText('Grand Hyatt')).toBeInTheDocument() | |
| }) | |
| // ββ Note cards ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-033: note card renders note text', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| mockDayNotesState.dayNotes = { | |
| '10': [buildDayNote({ id: 55, day_id: 10, text: 'Pack sunscreen', sort_order: 0 })], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| expect(screen.getByText('Pack sunscreen')).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-034: right-click on note opens context menu', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| mockDayNotesState.dayNotes = { | |
| '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| fireEvent.contextMenu(screen.getByText('My note')) | |
| expect(screen.getByText('Edit')).toBeInTheDocument() | |
| expect(screen.getByText(/Delete/i)).toBeInTheDocument() | |
| }) | |
| // ββ Note modal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-035: note modal renders when noteUi has an entry', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| mockDayNotesState.noteUi = { | |
| '10': { mode: 'add', text: '', time: '', icon: 'StickyNote' }, | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // Cancel and Add/Save buttons should appear in the modal | |
| expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-036: note modal Cancel calls cancelNote', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| mockDayNotesState.noteUi = { | |
| '10': { mode: 'add', text: 'Hello', time: '', icon: 'StickyNote' }, | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| await user.click(screen.getByRole('button', { name: /cancel/i })) | |
| expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10) | |
| }) | |
| // ββ Budget footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-037: budget footer shows total cost when places have prices', () => { | |
| const place = buildPlace({ name: 'Eiffel Tower', price: 25 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], | |
| places: [place], | |
| assignments: { '10': [assignment] }, | |
| trip: buildTrip({ id: 1, currency: 'EUR' }), | |
| })} />) | |
| // Budget footer shows "Total Cost" label when totalCost > 0 | |
| expect(screen.getByText('Total Cost')).toBeInTheDocument() | |
| }) | |
| // ββ Route tools (Optimize / Google Maps) ββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-038: optimize button calls onReorder with 3 geo-places', async () => { | |
| const user = userEvent.setup() | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const places = [ | |
| buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }), | |
| buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }), | |
| buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }), | |
| ] | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assigns = { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), | |
| buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }), | |
| ], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places, assignments: assigns, selectedDayId: 10, onReorder, | |
| })} />) | |
| // Find the Optimize button (contains 'optimize' text) | |
| const optimizeBtn = screen.getByRole('button', { name: /optimize/i }) | |
| await user.click(optimizeBtn) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array))) | |
| }) | |
| it('FE-PLANNER-DAYPLAN-039: Google Maps button calls window.open', async () => { | |
| const user = userEvent.setup() | |
| const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) | |
| const place1 = buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) | |
| const place2 = buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assigns = { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: place1 }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: place2 }), | |
| ], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place1, place2], assignments: assigns, selectedDayId: 10, | |
| })} />) | |
| // The ExternalLink button is the Google Maps icon-only button (sibling of Optimize button) | |
| const routeSection = document.querySelector('[style*="flex-direction: column"]') | |
| const externalLinkBtn = screen.getAllByRole('button').find(btn => { | |
| const parent = btn.closest('[style*="flex"]') | |
| return btn.querySelector('svg') && !btn.textContent?.trim() && parent?.textContent?.includes('optimize') | |
| }) | |
| if (externalLinkBtn) { | |
| await user.click(externalLinkBtn) | |
| expect(openSpy).toHaveBeenCalledWith('https://maps.google.com/...', '_blank') | |
| } | |
| openSpy.mockRestore() | |
| }) | |
| // ββ Context menu β Edit calls onEditPlace ββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-040: context menu Edit calls onEditPlace', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 42, name: 'Louvre Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const onEditPlace = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, onEditPlace, | |
| })} />) | |
| fireEvent.contextMenu(screen.getByText('Louvre Museum')) | |
| await user.click(screen.getByText('Edit')) | |
| expect(onEditPlace).toHaveBeenCalledWith(place, assignment.id) | |
| }) | |
| // ββ Arrow reorder buttons ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-041: arrow down button reorders day assignments', async () => { | |
| const user = userEvent.setup() | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const place1 = buildPlace({ id: 1, name: 'First Place' }) | |
| const place2 = buildPlace({ id: 2, name: 'Second Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 }) | |
| const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: place2 }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place1, place2], assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // First .reorder-buttons div β second button (ChevronDown) is enabled for first row | |
| const reorderDivs = document.querySelectorAll('.reorder-buttons') | |
| expect(reorderDivs.length).toBeGreaterThan(0) | |
| const firstRowDownBtn = reorderDivs[0].querySelectorAll('button')[1] | |
| await user.click(firstRowDownBtn) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array))) | |
| }) | |
| // ββ Title blur commits βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-042: blurring title input commits the edit', async () => { | |
| const user = userEvent.setup() | |
| const onUpdateDayTitle = vi.fn() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />) | |
| await user.click(screen.getByLabelText('Edit')) | |
| const input = await screen.findByDisplayValue('Old Title') | |
| await user.clear(input) | |
| await user.type(input, 'New Title') | |
| fireEvent.blur(input) | |
| await waitFor(() => expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title')) | |
| }) | |
| // ββ ICS export button ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => { | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| expect(screen.getByText('ICS')).toBeInTheDocument() | |
| }) | |
| // ββ getMergedItems: transport merged with assignments ββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-044: merged list shows both assignment and flight on same day', () => { | |
| const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34, place_time: '14:00' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const reservation = buildReservation({ | |
| id: 200, type: 'flight', title: 'CDG to LHR', | |
| reservation_time: '2025-06-01T08:00:00', | |
| day_id: 10, | |
| }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], | |
| places: [place], | |
| assignments: { '10': [assignment] }, | |
| reservations: [reservation], | |
| })} />) | |
| expect(screen.getByText('Louvre')).toBeInTheDocument() | |
| expect(screen.getByText('CDG to LHR')).toBeInTheDocument() | |
| }) | |
| // ββ Multi-day transport span phases ββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-045: multi-day flight shows departure label on first day', () => { | |
| const day1 = buildDay({ id: 10, date: '2025-06-01', title: 'Departure' }) | |
| const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Arrival' }) | |
| const flight = buildReservation({ | |
| id: 201, type: 'flight', title: 'Transatlantic', | |
| reservation_time: '2025-06-01T22:00:00', | |
| reservation_end_time: '2025-06-02T06:00:00', | |
| day_id: 10, | |
| end_day_id: 11, | |
| } as any) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day1, day2], | |
| reservations: [flight], | |
| })} />) | |
| // Both days should show the flight (departure on day1, arrival on day2) | |
| const titles = screen.getAllByText('Transatlantic') | |
| expect(titles.length).toBeGreaterThanOrEqual(2) | |
| }) | |
| // ββ Car active rental badge ββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-046: car rental in middle phase shows active badge in day header', () => { | |
| const day1 = buildDay({ id: 10, date: '2025-06-01', title: 'Pickup' }) | |
| const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Drive Day' }) | |
| const day3 = buildDay({ id: 12, date: '2025-06-03', title: 'Return' }) | |
| const carRental = buildReservation({ | |
| id: 300, type: 'car', title: 'Renault Rental', | |
| reservation_time: '2025-06-01T09:00:00', | |
| reservation_end_time: '2025-06-03T17:00:00', | |
| day_id: 10, | |
| end_day_id: 12, | |
| } as any) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day1, day2, day3], | |
| reservations: [carRental], | |
| })} />) | |
| // Car may appear as transport item on pickup/return days and as active badge on middle day | |
| const instances = screen.getAllByText('Renault Rental') | |
| expect(instances.length).toBeGreaterThan(0) | |
| }) | |
| // ββ Lock toggle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-047: clicking PlaceAvatar toggles lock (red border appears)', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 42, name: 'Arc de Triomphe', lat: 48.87, lng: 2.29 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, selectedDayId: 10, | |
| })} />) | |
| // Click on the PlaceAvatar wrapper (the lock toggle div) β it's a div with cursor: pointer that wraps the avatar | |
| const placeEl = screen.getByText('Arc de Triomphe') | |
| // The lock div is the parent of PlaceAvatar, which is a sibling of the GripVertical div | |
| const row = placeEl.closest('[style*="display: flex"][style*="gap: 8"]') | |
| const lockDiv = row?.querySelector('[style*="cursor: pointer"][style*="position: relative"]') | |
| if (lockDiv) { | |
| await user.click(lockDiv as HTMLElement) | |
| // After lock: the row should have red border | |
| await waitFor(() => { | |
| const rowEl = placeEl.closest('[style*="border-left"]') | |
| expect(rowEl).toBeTruthy() | |
| }) | |
| } | |
| }) | |
| // ββ Drag start/end on assignment βββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-048: drag start on assignment sets drag state', () => { | |
| const place = buildPlace({ id: 1, name: 'Drag Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| const draggable = screen.getByText('Drag Place').closest('[draggable="true"]') | |
| expect(draggable).toBeTruthy() | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable as Element, { dataTransfer: dt }) | |
| expect(dt.setData).toHaveBeenCalledWith('assignmentId', '99') | |
| }) | |
| it('FE-PLANNER-DAYPLAN-049: drag end resets drag state', () => { | |
| const place = buildPlace({ id: 1, name: 'Drag Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| const draggable = screen.getByText('Drag Place').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable as Element, { dataTransfer: dt }) | |
| fireEvent.dragEnd(draggable as Element) | |
| // After drag end, draggingId should be cleared (element opacity back to normal) | |
| expect(draggable).toBeTruthy() | |
| }) | |
| // ββ Drop on day header (placeId) βββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-050: dropping place from sidebar onto day header calls onAssignToDay', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const onAssignToDay = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAssignToDay })} />) | |
| // Set drag data as if dragging from the places sidebar | |
| ;(window as any).__dragData = { placeId: '42' } | |
| const dayHeader = screen.getByText('Day 1').closest('[style*="cursor: pointer"]') | |
| fireEvent.drop(dayHeader as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| expect(onAssignToDay).toHaveBeenCalledWith(42, 10) | |
| ;(window as any).__dragData = null | |
| }) | |
| // ββ Transport detail modal with metadata βββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => { | |
| const user = userEvent.setup() | |
| const onEditTransport = vi.fn() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' }) | |
| const reservation = { | |
| ...buildReservation({ | |
| id: 202, type: 'flight', title: 'Paris to Berlin', | |
| reservation_time: '2025-06-01T07:30:00', | |
| day_id: 10, | |
| }), | |
| metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }), | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />) | |
| await user.click(screen.getByText('Paris to Berlin')) | |
| await waitFor(() => { | |
| expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' })) | |
| }) | |
| }) | |
| // ββ Category-tagged place rendering βββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-052: place with category renders correctly', () => { | |
| const category = buildCategory({ id: 5, name: 'Restaurants', icon: 'restaurant' }) | |
| const place = buildPlace({ name: 'CafΓ© de Flore', category_id: 5 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, categories: [category], | |
| })} />) | |
| expect(screen.getByText('CafΓ© de Flore')).toBeInTheDocument() | |
| }) | |
| // ββ Drop on assignment row βββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-053: dropping place from sidebar onto assignment calls onAssignToDay', () => { | |
| const place = buildPlace({ id: 1, name: 'Existing Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const onAssignToDay = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, onAssignToDay, | |
| })} />) | |
| ;(window as any).__dragData = { placeId: '55' } | |
| const assignmentRow = screen.getByText('Existing Place').closest('[draggable="true"]') | |
| fireEvent.drop(assignmentRow as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| // onAssignToDay is called with (placeId, dayId, position) where position is the index in the list | |
| expect(onAssignToDay).toHaveBeenCalledWith(55, 10, expect.anything()) | |
| ;(window as any).__dragData = null | |
| }) | |
| // ββ PDF hover tooltip βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => { | |
| const user = userEvent.setup() | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| const pdfBtn = screen.getByText('PDF').closest('button')! | |
| await user.hover(pdfBtn) | |
| await waitFor(() => { | |
| // Tooltip text appears (from t('dayplan.pdfTooltip')) | |
| const tooltips = document.querySelectorAll('[style*="pointer-events: none"]') | |
| expect(tooltips.length).toBeGreaterThan(0) | |
| }) | |
| }) | |
| // ββ Drag over day header ββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-055: drag over day header sets drag target state', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| const dayHeader = screen.getByText('Day 1').closest('[style*="cursor: pointer"]') | |
| fireEvent.dragOver(dayHeader as Element, { dataTransfer: { dropEffect: 'move' } }) | |
| // dragOverDayId should be set β the day header gets drag-target styling | |
| expect(dayHeader).toBeTruthy() | |
| }) | |
| // ββ Cross-day drop on day header (assignment) βββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-056: dropping assignment from another day onto header triggers move', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // Simulate dragging an assignment from day 99 to day 10 | |
| ;(window as any).__dragData = null | |
| const dt = { | |
| getData: (key: string) => { | |
| if (key === 'assignmentId') return '99' | |
| if (key === 'fromDayId') return '20' | |
| return '' | |
| }, | |
| } | |
| const dayHeader = screen.getByText('Day 1').closest('[style*="cursor: pointer"]') | |
| fireEvent.drop(dayHeader as Element, { dataTransfer: dt }) | |
| // tripActions.moveAssignment would be called β just verify no error | |
| expect(dayHeader).toBeTruthy() | |
| }) | |
| // ββ Document dragend cleanup ββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-057: document dragend event resets drag state', async () => { | |
| const place = buildPlace({ id: 1, name: 'Test Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| // Start a drag, then fire the global dragend event | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| const draggable = screen.getByText('Test Place').closest('[draggable="true"]') | |
| fireEvent.dragStart(draggable as Element, { dataTransfer: dt }) | |
| // Dispatch global dragend on document | |
| document.dispatchEvent(new Event('dragend')) | |
| // Component should handle cleanup without errors | |
| expect(screen.getByText('Test Place')).toBeInTheDocument() | |
| }) | |
| // ββ ICS export click βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => { | |
| const user = userEvent.setup() | |
| const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ | |
| ok: true, | |
| blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })), | |
| } as any) | |
| // Mock URL.createObjectURL | |
| const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') | |
| const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| await user.click(screen.getByText('ICS').closest('button')!) | |
| await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object))) | |
| fetchSpy.mockRestore() | |
| createObjURL.mockRestore() | |
| revokeObjURL.mockRestore() | |
| }) | |
| // ββ openAddNote button click ββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| const addNoteBtn = screen.getByLabelText('Add Note') | |
| await user.click(addNoteBtn) | |
| expect(mockDayNotesState.openAddNote).toHaveBeenCalled() | |
| }) | |
| // ββ Note modal save button ββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-060: note modal Save button calls saveNote', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| mockDayNotesState.noteUi = { | |
| '10': { mode: 'add', text: 'Test note', time: '', icon: 'StickyNote' }, | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // The Save/Add button in the modal has exact text "Add" (from t('common.add')) | |
| const addBtn = screen.getByRole('button', { name: 'Add' }) | |
| await user.click(addBtn) | |
| expect(mockDayNotesState.saveNote).toHaveBeenCalledWith(10) | |
| }) | |
| // ββ Note modal edit mode title ββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-061: note modal shows Edit title in edit mode', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| mockDayNotesState.noteUi = { | |
| '10': { mode: 'edit', text: 'My note', time: '', icon: 'StickyNote' }, | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // The modal title is t('dayplan.noteEdit') β "Edit Note" or similar | |
| expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() | |
| }) | |
| // ββ Place with website in context menu ββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-062: place with website shows website option in context menu', () => { | |
| const place = buildPlace({ id: 42, name: 'Museum', website: 'https://museum.example.com' } as any) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| fireEvent.contextMenu(screen.getByText('Museum')) | |
| // Website option should appear in context menu | |
| expect(screen.getByText(/Website/i)).toBeInTheDocument() | |
| }) | |
| // ββ Delete place context menu βββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-063: context menu Delete calls onDeletePlace', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 42, name: 'Louvre' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const onDeletePlace = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, onDeletePlace, | |
| })} />) | |
| fireEvent.contextMenu(screen.getByText('Louvre')) | |
| await user.click(screen.getByText(/Delete/i)) | |
| expect(onDeletePlace).toHaveBeenCalledWith(42) | |
| }) | |
| // ββ Note card edit/delete buttons βββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' }) | |
| mockDayNotesState.dayNotes = { '10': [note] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // Find note edit button (Pencil in note-edit-buttons) | |
| const noteEditBtns = document.querySelectorAll('.note-edit-buttons button') | |
| if (noteEditBtns.length > 0) { | |
| await user.click(noteEditBtns[0] as HTMLElement) | |
| expect(mockDayNotesState.openEditNote).toHaveBeenCalled() | |
| } | |
| }) | |
| it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' }) | |
| mockDayNotesState.dayNotes = { '10': [note] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // Find note delete button (Trash2 in note-edit-buttons) | |
| const noteEditBtns = document.querySelectorAll('.note-edit-buttons button') | |
| if (noteEditBtns.length > 1) { | |
| await user.click(noteEditBtns[1] as HTMLElement) | |
| // Clicking delete opens a confirmation dialog rather than deleting immediately. | |
| expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled() | |
| expect(screen.getByText('Delete note?')).toBeInTheDocument() | |
| // Confirming triggers the actual delete. | |
| await user.click(screen.getByRole('button', { name: /^delete$/i })) | |
| expect(mockDayNotesState.deleteNote).toHaveBeenCalled() | |
| } | |
| }) | |
| // ββ Drop on assignment: same-day reorder βββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-066: dropping assignment from same day triggers handleMergedDrop', () => { | |
| const place1 = buildPlace({ id: 1, name: 'Place A' }) | |
| const place2 = buildPlace({ id: 2, name: 'Place B' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 }) | |
| const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: place2 }) | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place1, place2], assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // Drag a1 onto a2 (same day reorder) | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| const draggableA1 = screen.getByText('Place A').closest('[draggable="true"]') | |
| fireEvent.dragStart(draggableA1 as Element, { dataTransfer: dt }) | |
| const draggableA2 = screen.getByText('Place B').closest('[draggable="true"]') | |
| fireEvent.drop(draggableA2 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| // handleMergedDrop called; onReorder should eventually be called | |
| expect(onReorder).toBeDefined() | |
| }) | |
| // ββ Cross-day note drop on day header βββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-067: dropping note from another day onto day header triggers move', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| const dt = { | |
| getData: (key: string) => { | |
| if (key === 'noteId') return '55' | |
| if (key === 'fromDayId') return '20' | |
| return '' | |
| }, | |
| } | |
| const dayHeader = screen.getByText('Day 1').closest('[style*="cursor: pointer"]') | |
| fireEvent.drop(dayHeader as Element, { dataTransfer: dt }) | |
| expect(dayHeader).toBeTruthy() | |
| }) | |
| // ββ Cross-day assignment drag from day1 to day2 header ββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-068: dragging assignment from day1 and dropping on day2 header moves it', async () => { | |
| const place1 = buildPlace({ id: 1, name: 'Place on Day 1' }) | |
| const day1 = buildDay({ id: 10, date: '2025-06-01', title: 'Day One' }) | |
| const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day Two' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day1, day2], | |
| places: [place1], | |
| assignments: { '10': [a1], '11': [] }, | |
| })} />) | |
| // DragStart on a1 to set dragDataRef.current | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| const draggable = screen.getByText('Place on Day 1').closest('[draggable="true"]') | |
| fireEvent.dragStart(draggable as Element, { dataTransfer: dt }) | |
| // Drop on day2 header | |
| const day2Header = screen.getByText('Day Two').closest('[style*="cursor: pointer"]') | |
| fireEvent.drop(day2Header as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| // tripActions.moveAssignment should have been called (no assertion needed β just coverage) | |
| expect(day2Header).toBeTruthy() | |
| }) | |
| // ββ Same-day assignment drop (handleMergedDrop) βββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-069: dropping assignment onto another assignment on same day calls applyMergedOrder', async () => { | |
| const place1 = buildPlace({ id: 1, name: 'Place A' }) | |
| const place2 = buildPlace({ id: 2, name: 'Place B' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 }) | |
| const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: place2 }) | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place1, place2], assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // DragStart on a1 to set dragDataRef | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| const draggableA1 = screen.getByText('Place A').closest('[draggable="true"]') | |
| fireEvent.dragStart(draggableA1 as Element, { dataTransfer: dt }) | |
| // Drop on a2 (same day β handleMergedDrop β applyMergedOrder β onReorder) | |
| const draggableA2 = screen.getByText('Place B').closest('[draggable="true"]') | |
| fireEvent.drop(draggableA2 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalled()) | |
| }) | |
| // ββ End-of-day drop zone ββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-070: dropping place from sidebar onto end-of-day zone calls onAssignToDay', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const onAssignToDay = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAssignToDay })} />) | |
| ;(window as any).__dragData = { placeId: '42' } | |
| // The end drop zone has min-height: 12px and padding 2px 8px | |
| const endZone = document.querySelector('[style*="min-height: 12"]') | |
| if (endZone) { | |
| fireEvent.drop(endZone as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| expect(onAssignToDay).toHaveBeenCalledWith(42, 10) | |
| } | |
| ;(window as any).__dragData = null | |
| }) | |
| // ββ getMergedItems: place time before transport time ββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-071: transport placed after time-anchored place in merged list', () => { | |
| const place = buildPlace({ name: 'Morning CafΓ©', place_time: '08:00', lat: 48.86, lng: 2.34 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const flight = buildReservation({ | |
| id: 201, type: 'flight', title: 'Afternoon Flight', | |
| reservation_time: '2025-06-01T14:00:00', | |
| day_id: 10, | |
| }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight], | |
| })} />) | |
| expect(screen.getByText('Morning CafΓ©')).toBeInTheDocument() | |
| expect(screen.getByText('Afternoon Flight')).toBeInTheDocument() | |
| }) | |
| // ββ Cross-day assignment drop on assignment row βββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-072: dropping cross-day assignment onto assignment row calls moveAssignment', async () => { | |
| const place1 = buildPlace({ id: 1, name: 'Place On Day 1' }) | |
| const place2 = buildPlace({ id: 2, name: 'Place On Day 2' }) | |
| const day1 = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 }) | |
| const a2 = buildAssignment({ id: 12, day_id: 11, order_index: 0, place: place2 }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day1, day2], | |
| places: [place1, place2], | |
| assignments: { '10': [a1], '11': [a2] }, | |
| })} />) | |
| // DragStart on a1 (day 10) to set dragDataRef | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| const draggableA1 = screen.getByText('Place On Day 1').closest('[draggable="true"]') | |
| fireEvent.dragStart(draggableA1 as Element, { dataTransfer: dt }) | |
| // Drop on a2 (day 11 β cross-day) β triggers moveAssignment path | |
| const draggableA2 = screen.getByText('Place On Day 2').closest('[draggable="true"]') | |
| fireEvent.drop(draggableA2 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| // Just verify no crash | |
| expect(screen.getByText('Place On Day 2')).toBeInTheDocument() | |
| }) | |
| // ββ Drag over assignment row ββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-073: drag over assignment row sets drop target', () => { | |
| const place = buildPlace({ id: 1, name: 'Target Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| const draggable = screen.getByText('Target Place').closest('[draggable="true"]') | |
| fireEvent.dragOver(draggable as Element, { dataTransfer: { dropEffect: 'move' } }) | |
| expect(draggable).toBeTruthy() | |
| }) | |
| // ββ Note card drag and drop βββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-074: drag start on note card sets drag state', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const note = buildDayNote({ id: 55, day_id: 10, text: 'Drag this note' }) | |
| mockDayNotesState.dayNotes = { '10': [note] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| const noteEl = screen.getByText('Drag this note').closest('[draggable="true"]') | |
| if (noteEl) { | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(noteEl as Element, { dataTransfer: dt }) | |
| expect(dt.setData).toHaveBeenCalledWith('noteId', '55') | |
| } | |
| }) | |
| // ββ Note card drop: cross-day note drop onto assignment βββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-075: dropping cross-day note onto assignment triggers note move', () => { | |
| const place = buildPlace({ id: 1, name: 'Louvre' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| // Simulate dropping a note from another day onto this assignment | |
| const draggable = screen.getByText('Louvre').closest('[draggable="true"]') | |
| // dragDataRef has note from another day | |
| ;(window as any).__dragData = null | |
| const savedDragRef: any = { noteId: '55', fromDayId: '20' } | |
| // We can't set dragDataRef directly, but we can use the getDragData fallback | |
| // The fallback only reads placeId from window.__dragData, not noteId | |
| // This test just verifies drop on assignment with no matching data doesn't crash | |
| fireEvent.drop(draggable as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| expect(screen.getByText('Louvre')).toBeInTheDocument() | |
| }) | |
| // ββ handleOptimize: no-geo places skipped ββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-076: optimize with some places without geo coords still calls onReorder', async () => { | |
| const user = userEvent.setup() | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| // Mix of geo and non-geo places | |
| const places = [ | |
| buildPlace({ id: 1, name: 'Geo Place A', lat: 48.85, lng: 2.35 }), | |
| buildPlace({ id: 2, name: 'No Geo', lat: null as any, lng: null as any }), | |
| buildPlace({ id: 3, name: 'Geo Place C', lat: 48.87, lng: 2.37 }), | |
| ] | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assigns = { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), | |
| buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }), | |
| ], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places, assignments: assigns, selectedDayId: 10, onReorder, | |
| })} />) | |
| const optimizeBtn = screen.getByRole('button', { name: /optimize/i }) | |
| await user.click(optimizeBtn) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalled()) | |
| }) | |
| // ββ Lock hover tooltip ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-077: hovering over PlaceAvatar shows lock tooltip', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 42, name: 'Hovered Place', lat: 48.87, lng: 2.29 }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| const placeEl = screen.getByText('Hovered Place') | |
| const row = placeEl.closest('[style*="display: flex"][style*="gap: 8"]') | |
| const lockDiv = row?.querySelector('[style*="cursor: pointer"][style*="position: relative"]') | |
| if (lockDiv) { | |
| fireEvent.mouseEnter(lockDiv as Element) | |
| // Lock overlay should appear | |
| await waitFor(() => { | |
| const overlays = document.querySelectorAll('[style*="position: absolute"][style*="inset: 0"]') | |
| expect(overlays.length).toBeGreaterThan(0) | |
| }) | |
| } | |
| }) | |
| // ββ Reservation badge on assignment ββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-078: assignment with linked reservation shows confirmed badge', () => { | |
| const place = buildPlace({ id: 1, name: 'Le Jules Verne' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const res = buildReservation({ id: 77, trip_id: 1, type: 'restaurant', status: 'confirmed', assignment_id: 99 } as any) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res], | |
| })} />) | |
| expect(screen.getByText('Le Jules Verne')).toBeInTheDocument() | |
| // Badge shows confirmed status | |
| expect(screen.getByText(/confirmed/i)).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-079: assignment with pending reservation shows pending badge', () => { | |
| const place = buildPlace({ id: 1, name: 'Opera House' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const res = buildReservation({ id: 77, trip_id: 1, type: 'restaurant', status: 'pending', assignment_id: 99 } as any) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res], | |
| })} />) | |
| expect(screen.getAllByText(/pending/i).length).toBeGreaterThan(0) | |
| }) | |
| // ββ timed place drag β timeConfirm modal βββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-080: dragging timed place out of chronological order shows time-confirm modal', async () => { | |
| const placeA = buildPlace({ id: 1, name: 'Morning Place', place_time: '08:00' }) | |
| const placeB = buildPlace({ id: 2, name: 'Afternoon Place', place_time: '14:00' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| // A (08:00) at index 0, B (14:00) at index 1 | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) | |
| const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [placeA, placeB], | |
| assignments: { '10': [a1, a2] }, | |
| })} />) | |
| // DragStart on a2 (14:00, at index 1), drop onto a1 (08:00, at index 0) | |
| // This would create [a2(14:00), a1(08:00)] β NOT chronological | |
| const draggable2 = screen.getByText('Afternoon Place').closest('[draggable="true"]') | |
| const draggable1 = screen.getByText('Morning Place').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable2 as Element, { dataTransfer: dt }) | |
| // Now drop on draggable1 (the assignment row drop handler) | |
| fireEvent.drop(draggable1 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| await waitFor(() => { | |
| expect(screen.getByText('Remove time?')).toBeInTheDocument() | |
| }) | |
| }) | |
| it('FE-PLANNER-DAYPLAN-081: clicking Confirm in time modal calls confirmTimeRemoval (updates assignment time)', async () => { | |
| const user = userEvent.setup() | |
| const { assignmentsApi } = await import('../../api/client') | |
| const placeA = buildPlace({ id: 1, name: 'Morning Place', place_time: '08:00' }) | |
| const placeB = buildPlace({ id: 2, name: 'Afternoon Place', place_time: '14:00' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) | |
| const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [placeA, placeB], | |
| assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // Trigger the timeConfirm modal: drag a2 onto a1 | |
| const draggable2 = screen.getByText('Afternoon Place').closest('[draggable="true"]') | |
| const draggable1 = screen.getByText('Morning Place').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable2 as Element, { dataTransfer: dt }) | |
| fireEvent.drop(draggable1 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| // Wait for modal | |
| await waitFor(() => expect(screen.getByText('Remove time?')).toBeInTheDocument()) | |
| // Click Confirm | |
| const confirmBtn = screen.getByRole('button', { name: /confirm/i }) | |
| await user.click(confirmBtn) | |
| await waitFor(() => expect((assignmentsApi as any).updateTime).toHaveBeenCalled()) | |
| }) | |
| // ββ applyMergedOrder with notes in list (noteUpdates branch) ββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-082: reordering day with notes populates noteUpdates in applyMergedOrder', async () => { | |
| const { assignmentsApi } = await import('../../api/client') | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const placeA = buildPlace({ id: 1, name: 'Place Alpha' }) | |
| const placeB = buildPlace({ id: 2, name: 'Place Beta' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) | |
| const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 2, place: placeB }) | |
| // Note between assignments (sort_order=1 puts it between a1(0) and a2(2)) | |
| const note = buildDayNote({ id: 55, day_id: 10, sort_order: 1, text: 'Mid Note' }) | |
| mockDayNotesState.dayNotes = { '10': [note] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [placeA, placeB], | |
| assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // DragStart on a2 (idx 2), drop onto a1 (idx 0) β same day swap | |
| const draggable2 = screen.getByText('Place Beta').closest('[draggable="true"]') | |
| const draggable1 = screen.getByText('Place Alpha').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable2 as Element, { dataTransfer: dt }) | |
| fireEvent.drop(draggable1 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalled()) | |
| }) | |
| // ββ handleOptimize with locked assignments ββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-083: optimize respects locked assignments', async () => { | |
| const user = userEvent.setup() | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const places = [ | |
| buildPlace({ id: 1, name: 'Place Lock', lat: 48.85, lng: 2.35 }), | |
| buildPlace({ id: 2, name: 'Place Free A', lat: 48.86, lng: 2.36 }), | |
| buildPlace({ id: 3, name: 'Place Free B', lat: 48.87, lng: 2.37 }), | |
| ] | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assigns = { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), | |
| buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }), | |
| ], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places, assignments: assigns, selectedDayId: 10, onReorder, | |
| })} />) | |
| // Lock the first assignment by clicking its lock area | |
| const placeEl = screen.getByText('Place Lock') | |
| const row = placeEl.closest('[style*="display: flex"][style*="gap: 8"]') | |
| const lockDiv = row?.querySelector('[style*="cursor: pointer"][style*="position: relative"]') | |
| if (lockDiv) fireEvent.click(lockDiv as Element) | |
| const optimizeBtn = screen.getByRole('button', { name: /optimize/i }) | |
| await user.click(optimizeBtn) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalled()) | |
| }) | |
| // ββ Drop on transport row (handleMergedDrop via transport onDrop) ββββββββββ | |
| it('FE-PLANNER-DAYPLAN-084: dropping same-day assignment onto transport row calls handleMergedDrop', () => { | |
| const place = buildPlace({ id: 1, name: 'Museum' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) | |
| const flight = buildReservation({ | |
| id: 77, trip_id: 1, type: 'flight', status: 'confirmed', | |
| reservation_time: '2025-06-01T10:00:00Z', | |
| }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], | |
| assignments: { '10': [assignment] }, | |
| reservations: [flight], | |
| })} />) | |
| const assignmentEl = screen.getByText('Museum').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(assignmentEl as Element, { dataTransfer: dt }) | |
| // Find the transport row and drop on it | |
| const transportRows = document.querySelectorAll('[style*="border: 1px solid"][style*="cursor: pointer"]') | |
| if (transportRows.length > 0) { | |
| // Drop assignment on transport row | |
| fireEvent.drop(transportRows[0] as Element, { | |
| dataTransfer: { getData: vi.fn().mockReturnValue('') }, | |
| clientY: 100, | |
| }) | |
| } | |
| expect(screen.getByText('Museum')).toBeInTheDocument() | |
| }) | |
| // ββ PDF click with populated dayNotes βββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-085: clicking PDF with populated dayNotes includes notes in call', async () => { | |
| const user = userEvent.setup() | |
| const { downloadTripPDF } = await import('../PDF/TripPDF') | |
| const place = buildPlace({ id: 1, name: 'Eiffel' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const note = buildDayNote({ id: 55, day_id: 10, sort_order: 0, text: 'PDF Note' }) | |
| mockDayNotesState.dayNotes = { '10': [note] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| const pdfBtn = screen.getByRole('button', { name: /pdf/i }) | |
| await user.click(pdfBtn) | |
| await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith( | |
| expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) }) | |
| )) | |
| }) | |
| // ββ Accommodation sort: checkout day βββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-086: accommodation that ends on current day shows checkout styling', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| // Accommodation: started day 8, ends day 10 β today is checkout day | |
| const acc = { id: 1, start_day_id: 8, end_day_id: 10, place_id: 5, place_name: 'Grand Hotel' } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], accommodations: [acc as any], | |
| })} />) | |
| expect(screen.getByText('Grand Hotel')).toBeInTheDocument() | |
| }) | |
| // ββ Note move arrows ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-087: clicking note move-down button calls moveNote', async () => { | |
| const user = userEvent.setup() | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const note1 = buildDayNote({ id: 10, day_id: 10, sort_order: 0, text: 'Note One' }) | |
| const note2 = buildDayNote({ id: 20, day_id: 10, sort_order: 1, text: 'Note Two' }) | |
| mockDayNotesState.dayNotes = { '10': [note1, note2] } | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| // The first note should have a down arrow (not at bottom) | |
| const noteEl = screen.getByText('Note One') | |
| const noteCard = noteEl.closest('[style*="display: flex"][style*="gap: 8"]') | |
| const buttons = noteCard?.querySelectorAll('.reorder-buttons button') | |
| if (buttons && buttons.length >= 2) { | |
| await user.click(buttons[1] as HTMLButtonElement) // down arrow | |
| expect(mockDayNotesState.moveNote).toHaveBeenCalled() | |
| } | |
| }) | |
| // ββ Drop zone at end of list ββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-088: drag over end-of-list zone sets dropTarget', () => { | |
| const place = buildPlace({ id: 1, name: 'Spot A' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, | |
| })} />) | |
| const assignmentEl = screen.getByText('Spot A').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(assignmentEl as Element, { dataTransfer: dt }) | |
| // Find the end-of-list drop zone (has minHeight: 12 and padding 2px 8px) | |
| const endZones = document.querySelectorAll('[style*="min-height: 12"]') | |
| if (endZones.length > 0) { | |
| fireEvent.dragOver(endZones[0] as Element, { preventDefault: vi.fn() }) | |
| } | |
| expect(screen.getByText('Spot A')).toBeInTheDocument() | |
| }) | |
| // ββ Inner expanded-area onDrop: place from sidebar ββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-089: dropping place from sidebar onto expanded content area calls onAssignToDay', () => { | |
| const place = buildPlace({ id: 1, name: 'Existing Place' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 11, day_id: 10, order_index: 0, place }) | |
| const onAssignToDay = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, onAssignToDay, | |
| })} />) | |
| // The expanded content wrapper is the div with background: var(--bg-hover) paddingTop:6 | |
| const expandedArea = document.querySelector('[style*="padding-top: 6"]') || | |
| document.querySelector('[style*="paddingTop: 6"]') | |
| if (expandedArea) { | |
| ;(window as any).__dragData = { placeId: '99' } | |
| fireEvent.drop(expandedArea as Element, { | |
| dataTransfer: { getData: vi.fn().mockReturnValue('') }, | |
| }) | |
| expect(onAssignToDay).toHaveBeenCalledWith(99, 10) | |
| ;(window as any).__dragData = null | |
| } | |
| }) | |
| // ββ ICS hover tooltip βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows tooltip', async () => { | |
| const user = userEvent.setup() | |
| render(<DayPlanSidebar {...makeDefaultProps()} />) | |
| const icsBtn = screen.getByRole('button', { name: /ICS/i }) | |
| await user.hover(icsBtn) | |
| await waitFor(() => { | |
| const tooltips = document.querySelectorAll('[style*="pointer-events: none"]') | |
| expect(tooltips.length).toBeGreaterThan(0) | |
| }) | |
| }) | |
| // ββ DragLeave on day header clears drag-over ββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-091: dragLeave on day header clears dragOverDayId', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) | |
| const dayHeader = screen.getByText('Day 1').closest('[style*="cursor: pointer"]') | |
| if (dayHeader) { | |
| fireEvent.dragOver(dayHeader as Element, { preventDefault: vi.fn() }) | |
| fireEvent.dragLeave(dayHeader as Element, { relatedTarget: document.body }) | |
| } | |
| expect(screen.getByText('Day 1')).toBeInTheDocument() | |
| }) | |
| // ββ applyMergedOrder: transport in merged list (transportUpdates branch) ββ | |
| it('FE-PLANNER-DAYPLAN-092: reordering day with flight in merged list updates transport positions', async () => { | |
| const { reservationsApi } = await import('../../api/client') as any | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const placeA = buildPlace({ id: 1, name: 'Museum' }) | |
| const placeB = buildPlace({ id: 2, name: 'Gallery' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) | |
| const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) | |
| const flight = buildReservation({ | |
| id: 77, trip_id: 1, type: 'flight', status: 'confirmed', | |
| reservation_time: '2025-06-01T12:00:00Z', | |
| }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [placeA, placeB], | |
| assignments: { '10': [a1, a2] }, reservations: [flight], onReorder, | |
| })} />) | |
| // DragStart on a2 (Gallery), drop on a1 (Museum) β same day | |
| const draggable2 = screen.getByText('Gallery').closest('[draggable="true"]') | |
| const draggable1 = screen.getByText('Museum').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable2 as Element, { dataTransfer: dt }) | |
| fireEvent.drop(draggable1 as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalled()) | |
| }) | |
| // ββ confirmTimeRemoval via arrow (reorderIds path) βββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-093: arrow-reorder timed place shows modal then confirm removes time', async () => { | |
| const user = userEvent.setup() | |
| const { assignmentsApi } = await import('../../api/client') as any | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const placeA = buildPlace({ id: 1, name: 'Early Place', place_time: '08:00' }) | |
| const placeB = buildPlace({ id: 2, name: 'Later Place', place_time: '14:00' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) | |
| const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [placeA, placeB], | |
| assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // Click down arrow on 'Early Place' (a1) β would move it after a2, breaking order | |
| const earlyEl = screen.getByText('Early Place') | |
| const row = earlyEl.closest('[style*="display: flex"][style*="gap: 8"]') | |
| const reorderBtns = row?.querySelectorAll('.reorder-buttons button') | |
| if (reorderBtns && reorderBtns.length >= 2) { | |
| await user.click(reorderBtns[1] as HTMLButtonElement) // down button | |
| // Modal should appear | |
| await waitFor(() => expect(screen.getByText('Remove time?')).toBeInTheDocument()) | |
| // Click Confirm | |
| const confirmBtn = screen.getByRole('button', { name: /confirm/i }) | |
| await user.click(confirmBtn) | |
| await waitFor(() => expect(assignmentsApi.updateTime).toHaveBeenCalled()) | |
| } | |
| }) | |
| // ββ Same-day assignment drop onto end-of-list zone ββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-094: same-day assignment dropped on end-zone calls handleMergedDrop', async () => { | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const placeA = buildPlace({ id: 1, name: 'First Stop' }) | |
| const placeB = buildPlace({ id: 2, name: 'Second Stop' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA }) | |
| const a2 = buildAssignment({ id: 22, day_id: 10, order_index: 1, place: placeB }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [placeA, placeB], | |
| assignments: { '10': [a1, a2] }, onReorder, | |
| })} />) | |
| // DragStart on a1 (First Stop), drop on end-of-list zone | |
| const draggable1 = screen.getByText('First Stop').closest('[draggable="true"]') | |
| const dt = { setData: vi.fn(), effectAllowed: '', getData: vi.fn().mockReturnValue('') } | |
| fireEvent.dragStart(draggable1 as Element, { dataTransfer: dt }) | |
| const endZones = document.querySelectorAll('[style*="min-height: 12"]') | |
| if (endZones.length > 0) { | |
| fireEvent.drop(endZones[0] as Element, { dataTransfer: { getData: vi.fn().mockReturnValue('') } }) | |
| } | |
| await waitFor(() => expect(onReorder).toHaveBeenCalled()) | |
| }) | |
| // ββ Accommodation check-in (start_day_id === day.id) styling βββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-095: accommodation check-in day shows check-in badge', () => { | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| // Accommodation starts on day 10 (check-in day) | |
| const acc = { id: 1, start_day_id: 10, end_day_id: 12, place_id: 5, place_name: 'Boutique Hotel' } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], accommodations: [acc as any], | |
| })} />) | |
| expect(screen.getByText('Boutique Hotel')).toBeInTheDocument() | |
| }) | |
| // ββ handleOptimize: selectedDayId null early return βββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-096: optimize button with no selectedDay does nothing', async () => { | |
| const user = userEvent.setup() | |
| const onReorder = vi.fn() | |
| const places = [ | |
| buildPlace({ id: 1, name: 'P1', lat: 1, lng: 1 }), | |
| buildPlace({ id: 2, name: 'P2', lat: 2, lng: 2 }), | |
| buildPlace({ id: 3, name: 'P3', lat: 3, lng: 3 }), | |
| ] | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places, | |
| assignments: { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), | |
| buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }), | |
| ], | |
| }, | |
| selectedDayId: null, onReorder, | |
| })} />) | |
| // Optimize button should not be visible when no day is selected | |
| expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument() | |
| }) | |
| // ββ Edit reservation pencil button βββββββββββββββββββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 1, name: 'Hotel du Lac' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any) | |
| const onEditReservation = vi.fn() | |
| const onEditTransport = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res], | |
| onEditReservation, onEditTransport, | |
| })} />) | |
| const pencil = screen.getByTitle(/edit/i) | |
| await user.click(pencil) | |
| expect(onEditReservation).toHaveBeenCalledWith(res) | |
| expect(onEditTransport).not.toHaveBeenCalled() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => { | |
| const user = userEvent.setup() | |
| const place = buildPlace({ id: 1, name: 'Geneva Airport' }) | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) | |
| const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any) | |
| const onEditReservation = vi.fn() | |
| const onEditTransport = vi.fn() | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res], | |
| onEditReservation, onEditTransport, | |
| })} />) | |
| const pencil = screen.getByTitle(/edit/i) | |
| await user.click(pencil) | |
| expect(onEditTransport).toHaveBeenCalledWith(res) | |
| expect(onEditReservation).not.toHaveBeenCalled() | |
| }) | |
| // ββ showRouteToolsWhenExpanded (mobile route tools) βββββββββββββββββββββββ | |
| it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => { | |
| const places = [ | |
| buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }), | |
| buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }), | |
| ] | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assigns = { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), | |
| ], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true, | |
| })} />) | |
| // Days are expanded by default, so route tools must be visible even with no selected day | |
| expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument() | |
| }) | |
| it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => { | |
| const user = userEvent.setup() | |
| const onReorder = vi.fn().mockResolvedValue(undefined) | |
| const places = [ | |
| buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }), | |
| buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }), | |
| buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }), | |
| ] | |
| const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) | |
| const assigns = { | |
| '10': [ | |
| buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }), | |
| buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }), | |
| buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }), | |
| ], | |
| } | |
| render(<DayPlanSidebar {...makeDefaultProps({ | |
| days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true, | |
| })} />) | |
| const optimizeBtn = screen.getByRole('button', { name: /optimize/i }) | |
| await user.click(optimizeBtn) | |
| await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array))) | |
| }) | |
| }) | |