mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
b598b97865
The phone trip sheet host wrote out by hand three pieces of planner wiring the desktop planner already had. useBookingExpenseEditor now holds the expense editor both planners open over the trip: the booking request state, the receipt fallback, the base currency, the current user and the save that reloads the budget. TripPlannerPage and MTripSheets both read it. applyTripCoverUpdate patches a new trip cover into the store for the trip form on both sides. The transport editor and the transit route change moved into openTransportEditorWith and changeTransitRouteWith, which take the planner's setters. usePlannerDialogs builds openTransportEditor and changeTransitRoute on them, and the phone sheet calls the same helpers on the planner it receives. There are no differences between the two views to keep as options.
564 lines
28 KiB
TypeScript
564 lines
28 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ReactNode } from 'react'
|
|
import { BedDouble, MapPin } from 'lucide-react'
|
|
import type { BookingExpenseRequest } from '../../../../src/components/Planner/BookingCostsSection.types'
|
|
import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel'
|
|
import type { TourListItem } from '@trek/shared'
|
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import type { BudgetItem, Reservation, Trip } from '../../../../src/types'
|
|
import { useAuthStore } from '../../../../src/store/authStore'
|
|
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
|
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
|
|
|
// FE-MOB-SHOST-001 to FE-MOB-SHOST-029 and FE-MOB-SHOST-032 to FE-MOB-SHOST-034 (030 and 031 live in MTripSheets.members.test.tsx)
|
|
//
|
|
// Every child sheet is stubbed: this file is about the host — which sheet is
|
|
// mounted for which shell.sheet id, and how the host's own callbacks wire the
|
|
// planner, the trip store and the booking-linked expense editor together.
|
|
|
|
interface StubProps { shell: MTripShellApi; planner: TripPlanner }
|
|
|
|
/** Sheets that route on shell.sheet themselves — the stub reports what it saw. */
|
|
function selfRouted(testid: string) {
|
|
return ({ planner, shell }: StubProps) => (
|
|
<div data-testid={testid} data-sheet={shell.sheet?.id ?? 'none'} data-trip={planner.tripId} />
|
|
)
|
|
}
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: selfRouted('stub-place') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: selfRouted('stub-day') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaysSheet', () => ({ default: selfRouted('stub-days') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MAccommodationSheet', () => ({ default: selfRouted('stub-accommodation') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportSheet', () => ({ default: selfRouted('stub-transport') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet', () => ({ default: selfRouted('stub-bract') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MMehrSheet', () => ({ default: selfRouted('stub-mehr') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MExportSheet', () => ({ default: selfRouted('stub-export') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtCorridorSheet', () => ({ default: selfRouted('stub-rtsearch') }))
|
|
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtDraftSheet', () => ({
|
|
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="stub-rtdraft" data-trip={planner.tripId} />,
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MNoteSheet', () => ({
|
|
default: ({ open, payload, onClose }: { open: boolean; payload?: { dayId?: number }; onClose: () => void }) => (
|
|
<div data-testid="stub-note" data-open={String(open)} data-day={payload?.dayId ?? 'none'}>
|
|
<button type="button" onClick={onClose}>close note</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MImportSheet', () => ({
|
|
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => (
|
|
<div data-testid="stub-import" data-open={String(open)}>
|
|
<button type="button" onClick={onClose}>close import</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet', () => ({
|
|
default: ({ planner }: StubProps) => <div data-testid="stub-placeedit" data-trip={planner.tripId} />,
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MReservationSheet', () => ({
|
|
default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => (
|
|
<div data-testid="stub-reservation">
|
|
<button type="button" onClick={() => onOpenExpense({ editItem: { id: 99, name: 'Ticket' } as BudgetItem })}>
|
|
expense edit
|
|
</button>
|
|
<button type="button" onClick={() => onOpenExpense({})}>expense noop</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportFormSheet', () => ({
|
|
default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => (
|
|
<div data-testid="stub-transportform">
|
|
<button type="button" onClick={() => onOpenExpense({ prefill: { name: 'Shinkansen', amount: 120 } })}>
|
|
expense prefill
|
|
</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
|
|
default: ({ tripId, base, me, editing, prefill, onClose, onSaved }: {
|
|
tripId: number; base: string; me: number
|
|
editing: BudgetItem | null; prefill?: ExpensePrefill
|
|
onClose: () => void; onSaved: () => void
|
|
}) => (
|
|
<div
|
|
data-testid="stub-cost"
|
|
data-trip={tripId}
|
|
data-base={base}
|
|
data-me={me}
|
|
data-editing={editing?.id ?? 'none'}
|
|
data-prefill={prefill?.name ?? 'none'}
|
|
>
|
|
<button type="button" onClick={onClose}>close cost</button>
|
|
<button type="button" onClick={onSaved}>save cost</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({
|
|
default: ({ open, title, message, onClose, onConfirm, danger, confirmLabel, children }: {
|
|
open: boolean; title: string; message: ReactNode; onClose: () => void; onConfirm?: () => void
|
|
danger?: boolean; confirmLabel?: string; children?: ReactNode
|
|
}) =>
|
|
open ? (
|
|
<div data-testid="stub-confirm" data-title={title} data-danger={String(!!danger)} data-confirm={confirmLabel}>
|
|
<span>{message}</span>
|
|
{children}
|
|
<button type="button" onClick={onConfirm}>confirm delete</button>
|
|
<button type="button" onClick={onClose}>cancel delete</button>
|
|
</div>
|
|
) : null,
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({
|
|
default: ({ isOpen, onClose, tripId }: { isOpen: boolean; onClose: () => void; tripId: number }) => (
|
|
<div data-testid="stub-bookingimport" data-open={String(isOpen)} data-trip={tripId}>
|
|
<button type="button" onClick={onClose}>close booking import</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({
|
|
default: ({ isOpen, onClose, pushUndo }: { isOpen: boolean; onClose: () => void; pushUndo: unknown }) => (
|
|
<div data-testid="stub-airtrail" data-open={String(isOpen)} data-hasundo={String(typeof pushUndo === 'function')}>
|
|
<button type="button" onClick={onClose}>close airtrail</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
|
|
default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: {
|
|
isOpen: boolean; onClose: () => void
|
|
onSave: (data: Record<string, unknown>) => Promise<void>
|
|
trip?: Trip | null
|
|
onCoverUpdate: (id: number, coverUrl: string) => void
|
|
}) => (
|
|
<div data-testid="stub-tripform" data-open={String(isOpen)} data-title={trip?.title ?? 'none'}>
|
|
<button type="button" onClick={() => void onSave({ title: 'Japan 2027' })}>save trip</button>
|
|
<button type="button" onClick={() => onCoverUpdate(1, '/uploads/covers/new.jpg')}>update cover</button>
|
|
<button type="button" onClick={onClose}>close trip form</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({
|
|
default: ({ isOpen, onClose, tripTitle, onMembersChanged }: {
|
|
isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
|
|
}) => (
|
|
<div data-testid="stub-members" data-open={String(isOpen)} data-title={tripTitle ?? 'none'}>
|
|
<button type="button" onClick={onMembersChanged}>members changed</button>
|
|
<button type="button" onClick={onClose}>close members</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({
|
|
default: ({ reservation, canEdit, onClose, onSave, onDelete, onChangeRoute, onEditDetails }: {
|
|
reservation: Reservation
|
|
canEdit: boolean
|
|
onClose: () => void
|
|
onSave: (fields: Record<string, unknown>) => Promise<void>
|
|
onDelete: () => Promise<void>
|
|
onChangeRoute: () => void
|
|
onEditDetails: () => void
|
|
}) => (
|
|
<div data-testid="stub-transit" data-title={reservation.title} data-canedit={String(canEdit)}>
|
|
<button type="button" onClick={() => void onSave({ title: 'Renamed' })}>save transit</button>
|
|
<button type="button" onClick={() => void onDelete()}>delete transit</button>
|
|
<button type="button" onClick={onChangeRoute}>change route</button>
|
|
<button type="button" onClick={onEditDetails}>edit details</button>
|
|
<button type="button" onClick={onClose}>close transit</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
|
|
|
|
const JOURNEY = {
|
|
id: 55, trip_id: 1, day_id: 4, type: 'transit', title: 'Tokyo → Kyoto',
|
|
endpoints: [
|
|
{ role: 'from', name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
|
|
{ role: 'to', name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
|
|
],
|
|
} as unknown as Reservation
|
|
|
|
function renderHost(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
|
|
const planner = buildPlanner({ isTourPlace: vi.fn(() => false), ...plannerOverrides })
|
|
const shell = buildShell(shellOverrides)
|
|
render(<MTripSheets planner={planner} shell={shell} />)
|
|
return { planner, shell }
|
|
}
|
|
|
|
describe('MTripSheets', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
seedStore(useAuthStore, { user: { id: 7, username: 'maurice', email: 'm@example.com' } })
|
|
})
|
|
|
|
it('FE-MOB-SHOST-001: mounts every sheet of the host with the same planner and shell', () => {
|
|
renderHost({}, { sheet: null })
|
|
for (const id of ['stub-place', 'stub-day', 'stub-days', 'stub-accommodation', 'stub-transport',
|
|
'stub-bract', 'stub-mehr', 'stub-export', 'stub-note', 'stub-import', 'stub-placeedit',
|
|
'stub-reservation', 'stub-transportform', 'stub-bookingimport', 'stub-airtrail',
|
|
'stub-tripform', 'stub-members']) {
|
|
expect(screen.getByTestId(id)).toBeInTheDocument()
|
|
}
|
|
expect(screen.getByTestId('stub-day')).toHaveAttribute('data-sheet', 'none')
|
|
expect(screen.getByTestId('stub-placeedit')).toHaveAttribute('data-trip', '1')
|
|
})
|
|
|
|
it.each([
|
|
['day', 'stub-day'],
|
|
['days', 'stub-days'],
|
|
['accommodation', 'stub-accommodation'],
|
|
['transport', 'stub-transport'],
|
|
['bract', 'stub-bract'],
|
|
['mehr', 'stub-mehr'],
|
|
['export', 'stub-export'],
|
|
])('FE-MOB-SHOST-002: forwards the "%s" sheet id to %s', (id, testid) => {
|
|
renderHost({}, { sheet: { id } })
|
|
expect(screen.getByTestId(testid)).toHaveAttribute('data-sheet', id)
|
|
})
|
|
|
|
it.each([
|
|
['note', 'stub-note'],
|
|
['import', 'stub-import'],
|
|
['tripedit', 'stub-tripform'],
|
|
['members', 'stub-members'],
|
|
])('FE-MOB-SHOST-003: opens only %s for its own id', (id, testid) => {
|
|
renderHost({}, { sheet: { id } })
|
|
const hostRouted = ['stub-note', 'stub-import', 'stub-tripform', 'stub-members']
|
|
for (const other of hostRouted) {
|
|
expect(screen.getByTestId(other)).toHaveAttribute('data-open', String(other === testid))
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-SHOST-033: the map\'s places filter opens for its own id and closes through the shell', async () => {
|
|
const { shell } = renderHost({}, { sheet: { id: 'placesFilter' } })
|
|
const sheet = await screen.findByRole('dialog', { name: 'Filters' })
|
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Close' }))
|
|
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-033b: no places filter while another sheet is open', () => {
|
|
renderHost({}, { sheet: { id: 'day' } })
|
|
expect(screen.queryByRole('dialog', { name: 'Filters' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-004: hands the note payload to the note sheet and only for that id', () => {
|
|
renderHost({}, { sheet: { id: 'note', payload: { dayId: 4 } } })
|
|
expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', '4')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-005: withholds the payload while a different sheet is open', () => {
|
|
renderHost({}, { sheet: { id: 'day', payload: { dayId: 4 } } })
|
|
expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', 'none')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-006: the note and import sheets close through the shell', () => {
|
|
const { shell } = renderHost({}, { sheet: { id: 'note' } })
|
|
fireEvent.click(screen.getByText('close note'))
|
|
fireEvent.click(screen.getByText('close import'))
|
|
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-007: saving the trip form updates the trip and toasts', async () => {
|
|
const { planner } = renderHost({}, { sheet: { id: 'tripedit' } })
|
|
fireEvent.click(screen.getByText('save trip'))
|
|
await waitFor(() => expect(planner.tripActions.updateTrip).toHaveBeenCalledWith(1, { title: 'Japan 2027' }))
|
|
expect(planner.toast.success).toHaveBeenCalledWith('trip.toast.tripUpdated')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-008: a new cover is patched straight into the trip store', () => {
|
|
seedStore(useTripStore, { trip: { id: 1, title: 'Japan 2026', cover_image: '/uploads/covers/old.jpg' } })
|
|
renderHost({}, { sheet: { id: 'tripedit' } })
|
|
fireEvent.click(screen.getByText('update cover'))
|
|
expect(useTripStore.getState().trip?.cover_image).toBe('/uploads/covers/new.jpg')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-009: a cover update without a loaded trip leaves the store alone', () => {
|
|
seedStore(useTripStore, { trip: null })
|
|
renderHost({}, { sheet: { id: 'tripedit' } })
|
|
fireEvent.click(screen.getByText('update cover'))
|
|
expect(useTripStore.getState().trip).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-010: the trip form and members modal close through the shell', () => {
|
|
const { shell } = renderHost({}, { sheet: { id: 'members' } })
|
|
fireEvent.click(screen.getByText('close trip form'))
|
|
fireEvent.click(screen.getByText('close members'))
|
|
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-011: the members modal gets the trip title and reports changes to the planner', () => {
|
|
const { planner } = renderHost({}, { sheet: { id: 'members' } })
|
|
expect(screen.getByTestId('stub-members')).toHaveAttribute('data-title', 'Japan 2026')
|
|
fireEvent.click(screen.getByText('members changed'))
|
|
expect(planner.refreshMembers).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-012: the booking and AirTrail importers follow their planner flags', () => {
|
|
const { planner } = renderHost({ showBookingImport: true, showAirTrailImport: true })
|
|
expect(screen.getByTestId('stub-bookingimport')).toHaveAttribute('data-open', 'true')
|
|
expect(screen.getByTestId('stub-bookingimport')).toHaveAttribute('data-trip', '1')
|
|
expect(screen.getByTestId('stub-airtrail')).toHaveAttribute('data-hasundo', 'true')
|
|
|
|
fireEvent.click(screen.getByText('close booking import'))
|
|
fireEvent.click(screen.getByText('close airtrail'))
|
|
expect(planner.setShowBookingImport).toHaveBeenCalledWith(false)
|
|
expect(planner.setShowAirTrailImport).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-013: no transit journey modal without a selected journey', () => {
|
|
renderHost()
|
|
expect(screen.queryByTestId('stub-transit')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-014: prefers the stored reservation over the journey snapshot', () => {
|
|
const stored = { ...JOURNEY, title: 'Tokyo → Kyoto (saved)' } as unknown as Reservation
|
|
renderHost({ transitJourney: JOURNEY, reservations: [stored] })
|
|
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-title', 'Tokyo → Kyoto (saved)')
|
|
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-canedit', 'true')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-015: falls back to the journey itself when it is not in the list', () => {
|
|
const planner = buildPlanner({ transitJourney: JOURNEY, reservations: [], can: vi.fn(() => false) as TripPlanner['can'] })
|
|
render(<MTripSheets planner={planner} shell={buildShell()} />)
|
|
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-title', 'Tokyo → Kyoto')
|
|
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-canedit', 'false')
|
|
expect(planner.can).toHaveBeenCalledWith('day_edit', planner.trip)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-016: saving the journey updates the reservation and clears the selection', async () => {
|
|
const { planner } = renderHost({ transitJourney: JOURNEY })
|
|
fireEvent.click(screen.getByText('save transit'))
|
|
await waitFor(() =>
|
|
expect(planner.tripActions.updateReservation).toHaveBeenCalledWith(1, 55, { title: 'Renamed' }))
|
|
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(null)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-017: deleting the journey goes through the planner and clears the selection', async () => {
|
|
const { planner } = renderHost({ transitJourney: JOURNEY })
|
|
fireEvent.click(screen.getByText('delete transit'))
|
|
await waitFor(() => expect(planner.handleDeleteReservation).toHaveBeenCalledWith(55))
|
|
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(null)
|
|
fireEvent.click(screen.getByText('close transit'))
|
|
expect(planner.setTransitJourney).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-018: changing the route reopens the transport search seeded with both endpoints', () => {
|
|
const { planner } = renderHost({ transitJourney: JOURNEY })
|
|
fireEvent.click(screen.getByText('change route'))
|
|
expect(planner.setTransitPrefill).toHaveBeenCalledWith({
|
|
from: { name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
|
|
to: { name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
|
|
})
|
|
expect(planner.setEditingTransport).toHaveBeenCalledWith(JOURNEY)
|
|
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(4)
|
|
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(true)
|
|
expect(planner.setTransitJourney).toHaveBeenCalledWith(null)
|
|
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-019: a journey without endpoints seeds empty prefills and no day', () => {
|
|
const bare = { id: 56, trip_id: 1, type: 'transit', title: 'Unknown leg' } as unknown as Reservation
|
|
const { planner } = renderHost({ transitJourney: bare })
|
|
fireEvent.click(screen.getByText('change route'))
|
|
expect(planner.setTransitPrefill).toHaveBeenCalledWith({ from: null, to: null })
|
|
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-034: editing the details opens the transport editor on the newer store copy', () => {
|
|
const fresh = { ...JOURNEY, title: 'Tokyo → Kyoto (saved)' } as Reservation
|
|
const { planner } = renderHost({ transitJourney: JOURNEY, reservations: [fresh] })
|
|
fireEvent.click(screen.getByText('edit details'))
|
|
expect(planner.openTransportEditor).toHaveBeenCalledWith(fresh)
|
|
expect(planner.setEditingTransport).toHaveBeenCalledWith(fresh)
|
|
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
|
|
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-020: a booking opens the expense editor for its linked item and closes again', () => {
|
|
renderHost()
|
|
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('expense noop'))
|
|
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('expense edit'))
|
|
const cost = screen.getByTestId('stub-cost')
|
|
expect(cost).toHaveAttribute('data-editing', '99')
|
|
expect(cost).toHaveAttribute('data-prefill', 'none')
|
|
expect(cost).toHaveAttribute('data-me', '7')
|
|
expect(cost).toHaveAttribute('data-trip', '1')
|
|
|
|
fireEvent.click(screen.getByText('close cost'))
|
|
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-021: a transport prefill opens a new expense and reloads the budget on save', () => {
|
|
const loadBudgetItems = vi.fn()
|
|
seedStore(useTripStore, { loadBudgetItems })
|
|
renderHost()
|
|
fireEvent.click(screen.getByText('expense prefill'))
|
|
const cost = screen.getByTestId('stub-cost')
|
|
expect(cost).toHaveAttribute('data-editing', 'none')
|
|
expect(cost).toHaveAttribute('data-prefill', 'Shinkansen')
|
|
|
|
fireEvent.click(screen.getByText('save cost'))
|
|
expect(loadBudgetItems).toHaveBeenCalledWith(1)
|
|
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-022: the expense base currency prefers the display setting, then the trip', () => {
|
|
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'jpy' } })
|
|
renderHost()
|
|
fireEvent.click(screen.getByText('expense edit'))
|
|
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-base', 'JPY')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-023: falls back to the trip currency and finally to EUR', () => {
|
|
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
|
|
const { planner } = renderHost()
|
|
fireEvent.click(screen.getByText('expense edit'))
|
|
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-base', 'EUR')
|
|
|
|
const noCurrency = { ...planner.trip, currency: '' } as unknown as Trip
|
|
render(<MTripSheets planner={buildPlanner({ trip: noCurrency })} shell={buildShell()} />)
|
|
fireEvent.click(screen.getAllByText('expense edit')[1])
|
|
expect(screen.getAllByTestId('stub-cost')[1]).toHaveAttribute('data-base', 'EUR')
|
|
})
|
|
|
|
it('FE-MOB-SHOST-024: an anonymous session pays as user -1', () => {
|
|
seedStore(useAuthStore, { user: null })
|
|
renderHost()
|
|
fireEvent.click(screen.getByText('expense edit'))
|
|
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-me', '-1')
|
|
})
|
|
|
|
it.each([[false, false], [true, false], [false, true], [true, true]])(
|
|
'keeps the mobile Tour host View/Attach-only with place_edit=%s day_edit=%s', (placeEdit, dayEdit) => {
|
|
const mobilePlace = {
|
|
id: 42, trip_id: 1, name: 'Mobile ridge', lat: 48, lng: 11,
|
|
route_geometry: '[[48,11,500],[48.01,11.01,510]]', route_color: '#ff0000',
|
|
} as never
|
|
const mobileTour = {
|
|
place_id: 42, name: 'Mobile ridge', tour_type: 'hike', distance: 1,
|
|
elevation_gain: 10, elevation_loss: 0, duration: null, difficulty: null,
|
|
wanderer_ref: null, match_confidence: 1,
|
|
max_hiking_difficulty: 2, planned: false, caution: false,
|
|
} as TourListItem
|
|
const readOnlyFile = {
|
|
id: 19, trip_id: 1, place_id: 42, filename: 'ridge.gpx', original_name: 'ridge.gpx',
|
|
file_size: 32, mime_type: 'application/gpx+xml', url: '/api/files/19', created_at: '2026-01-01',
|
|
}
|
|
const can = vi.fn((permission: string) => permission === 'place_edit' ? placeEdit : permission === 'day_edit' && dayEdit)
|
|
renderHost({
|
|
selectedPlace: mobilePlace,
|
|
selectedTour: mobileTour,
|
|
selectedDayId: 7,
|
|
days: [{ id: 7, trip_id: 1, day_number: 1, title: 'Day one' } as never],
|
|
files: [readOnlyFile as never],
|
|
can: can as TripPlanner['can'],
|
|
canUploadFiles: true,
|
|
})
|
|
|
|
expect(document.querySelectorAll('[aria-modal="true"]')).toHaveLength(1)
|
|
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Edit track colour' })).not.toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: '1 file' }))
|
|
expect(screen.getByText('ridge.gpx')).toBeInTheDocument()
|
|
expect(Boolean(screen.queryByRole('button', { name: /Add to day/i }))).toBe(dayEdit)
|
|
},
|
|
)
|
|
|
|
it('FE-MOB-SHOST-025: the delete-place confirm runs the planner confirmation and disarms', () => {
|
|
const { planner } = renderHost({ deletePlaceId: 101 })
|
|
expect(screen.getByTestId('stub-confirm')).toHaveAttribute('data-title', 'common.delete')
|
|
expect(screen.getByText('trip.confirm.deletePlace')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('confirm delete'))
|
|
expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1)
|
|
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('shows permanent Tour deletion language in the mobile confirmation', () => {
|
|
const { planner } = renderHost({ deletePlaceId: 101, isTourPlace: vi.fn(() => true) })
|
|
const confirm = screen.getByTestId('stub-confirm')
|
|
|
|
expect(confirm).toHaveAttribute('data-confirm', 'tours.delete.confirmAction')
|
|
expect(confirm).toHaveTextContent('tours.delete.confirmBody')
|
|
expect(confirm).not.toHaveTextContent('Undo')
|
|
fireEvent.click(screen.getByText('confirm delete'))
|
|
expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-029: a night booked at the place is said before the yes, and only then', () => {
|
|
// The server takes the night down with the place, and the booking and the
|
|
// expense with the night. The planner builds the sentence; the sheet has to
|
|
// show it under the question.
|
|
const note = 'The booking at Hotel Okura and its expense go with it.'
|
|
renderHost({ deletePlaceId: 101, deletePlaceNote: note })
|
|
|
|
const confirm = screen.getByTestId('stub-confirm')
|
|
expect(confirm).toHaveTextContent('trip.confirm.deletePlace')
|
|
expect(screen.getByText(note)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-026: cancelling the confirm only disarms the flag', () => {
|
|
const { planner } = renderHost({ deletePlaceId: 101 })
|
|
fireEvent.click(screen.getByText('cancel delete'))
|
|
expect(planner.confirmDeletePlace).not.toHaveBeenCalled()
|
|
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-032: the delete-day confirm names the day, lists what goes with it and runs the planner confirmation', () => {
|
|
const lines = [
|
|
{ key: 'stay-9', icon: BedDouble, tone: 'danger' as const, text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
|
|
{ key: 'places', icon: MapPin, tone: 'neutral' as const, text: 'Planned places: 2' },
|
|
]
|
|
const { planner } = renderHost({ deleteDayId: 12, deleteDayTitle: 'Delete Tue, Oct 13?', deleteDayLines: lines })
|
|
|
|
const confirm = screen.getByTestId('stub-confirm')
|
|
expect(confirm).toHaveAttribute('data-title', 'Delete Tue, Oct 13?')
|
|
expect(confirm).toHaveAttribute('data-danger', 'true')
|
|
expect(confirm).toHaveAttribute('data-confirm', 'dayplan.deleteDay')
|
|
expect(confirm).toHaveTextContent('dayplan.deleteDayBody')
|
|
const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
|
|
expect(list).toHaveTextContent('Stay at Harbour Hotel')
|
|
expect(list).toHaveTextContent('Planned places: 2')
|
|
|
|
fireEvent.click(screen.getByText('confirm delete'))
|
|
expect(planner.confirmDeleteDay).toHaveBeenCalledTimes(1)
|
|
fireEvent.click(screen.getByText('cancel delete'))
|
|
expect(planner.setDeleteDayId).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('FE-MOB-SHOST-027: the place edit sheet owns the confirm while its own form is open', () => {
|
|
renderHost({ deletePlaceId: 101, showPlaceForm: true })
|
|
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-SHOST-028: the search sheet is mounted before the draft it opens', () => {
|
|
renderHost({}, { sheet: null })
|
|
|
|
const order = [...document.querySelectorAll('[data-testid]')]
|
|
.map(el => el.getAttribute('data-testid'))
|
|
.filter(id => id === 'stub-rtsearch' || id === 'stub-rtdraft')
|
|
|
|
// Both sit at the same z. Portal order is what decides which paints on top, and
|
|
// taking a hit onto the trip has to open the draft OVER the search it came from.
|
|
expect(order).toEqual(['stub-rtsearch', 'stub-rtdraft'])
|
|
})
|
|
|
|
})
|