mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
777b68f87b
- DayPlanSidebar: add aria-label to undo button, replace title with aria-label
so tests can still locate buttons by accessible name after tooltip refactor
- tests: switch getByTitle("Add Note") to getByLabelText
- tests: find undo button via aria-label (new expand/collapse button also uses
width:30, breaking the old style-based lookup)
- PlacesSidebar tests: loosen "All" button regex to account for count badge
- DisplaySettingsTab tests: use getByRole for Auto button (two "Auto" spans
coexist for mobile/desktop); handle multiple English matches in lang test
- weatherService tests: past-date case now expects an archive fetch instead
of an immediate no_forecast error
1720 lines
88 KiB
TypeScript
1720 lines
88 KiB
TypeScript
// 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] })} />)
|
|
// Find the pencil/edit button next to the title
|
|
const editButtons = screen.getAllByRole('button')
|
|
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
|
|
// Click the edit (pencil) button — it's the small one near the title
|
|
// The pencil button is inside the title area with opacity 0.35
|
|
const titleEl = screen.getByText('Original Title')
|
|
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
if (pencilBtn) await user.click(pencilBtn)
|
|
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
|
|
const titleEl = screen.getByText('Original Title')
|
|
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
if (pencilBtn) await user.click(pencilBtn)
|
|
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] })} />)
|
|
const titleEl = screen.getByText('Original Title')
|
|
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
if (pencilBtn) await user.click(pencilBtn)
|
|
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.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.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] },
|
|
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 })} />)
|
|
const titleEl = screen.getByText('Old Title')
|
|
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
if (pencilBtn) await user.click(pencilBtn)
|
|
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: note card delete button calls 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)
|
|
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',
|
|
date: '2025-06-01', 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',
|
|
date: '2025-06-01', 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()
|
|
})
|
|
})
|