mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
622 lines
30 KiB
TypeScript
622 lines
30 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { HttpResponse, http } from 'msw'
|
|
import MPlaceEditSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet'
|
|
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import type { Assignment, Category, Place } from '../../../../src/types'
|
|
import { buildPlanner } from '../../../helpers/mobileTrip'
|
|
import { useAddonStore } from '../../../../src/store/addonStore'
|
|
import { useAuthStore } from '../../../../src/store/authStore'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import { server } from '../../../helpers/msw/server'
|
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
|
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
|
|
|
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-045, plus the 009b, 025b and 029b variants
|
|
// planner.t echoes the key, so every label/placeholder is asserted as its key.
|
|
|
|
const CATEGORIES = [
|
|
{ id: 3, name: 'Food', color: '#ef4444', icon: 'Coffee', user_id: 1 },
|
|
] as unknown as Category[]
|
|
|
|
const EXISTING = {
|
|
id: 41, trip_id: 1, name: 'Ueno Park', address: 'Taito City', lat: 35.715, lng: 139.773,
|
|
google_place_id: 'ChIJ_ueno',
|
|
} as unknown as Place
|
|
|
|
const EDITED = {
|
|
id: 42, trip_id: 1, name: 'Senso-ji', description: 'Oldest temple', address: '2-3-1 Asakusa',
|
|
lat: 35.7148, lng: 139.7967, category_id: 3, notes: 'Go early', website: 'https://senso-ji.jp',
|
|
place_time: '08:00', end_time: '09:30', transport_mode: 'transit',
|
|
} as unknown as Place
|
|
|
|
function setup(overrides: Partial<TripPlanner> = {}) {
|
|
const planner = buildPlanner({ showPlaceForm: true, categories: CATEGORIES, ...overrides })
|
|
const onOpenExpense = vi.fn()
|
|
const view = render(<MPlaceEditSheet planner={planner} onOpenExpense={onOpenExpense} />)
|
|
return { ...view, planner, onOpenExpense }
|
|
}
|
|
|
|
const nameField = () => screen.getByPlaceholderText('places.formNamePlaceholder')
|
|
const submit = () => screen.getByRole('button', { name: /^(common\.add|common\.save|common\.saving|places\.addAnyway)$/ })
|
|
|
|
describe('MPlaceEditSheet', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
server.use(
|
|
http.post('/api/maps/search', () => HttpResponse.json({
|
|
source: 'osm',
|
|
places: [{ name: 'Ueno Koen', address: 'Taito', lat: 35.7, lng: 139.7, google_place_id: 'ChIJ_ueno' }],
|
|
})),
|
|
http.post('/api/maps/autocomplete', () => HttpResponse.json({ source: 'osm', suggestions: [] })),
|
|
)
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-001: stays unmounted while the planner has no editor open', () => {
|
|
setup({ showPlaceForm: false })
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-002: opens blank in add mode', () => {
|
|
setup()
|
|
expect(screen.getByRole('dialog', { name: 'places.addPlace' })).toBeInTheDocument()
|
|
expect(screen.getAllByText('places.addPlace').length).toBeGreaterThan(0)
|
|
expect(nameField()).toHaveValue('')
|
|
expect(submit()).toHaveTextContent('common.add')
|
|
// No place in context — no delete affordance and no time fields.
|
|
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
|
expect(screen.queryByText('places.startTime')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-003: prefills every field from the place under edit', () => {
|
|
setup({ editingPlace: EDITED })
|
|
expect(screen.getByRole('dialog', { name: 'places.editPlace' })).toBeInTheDocument()
|
|
expect(nameField()).toHaveValue('Senso-ji')
|
|
expect(screen.getByPlaceholderText('places.formDescriptionPlaceholder')).toHaveValue('Oldest temple')
|
|
expect(screen.getByPlaceholderText('places.formNotesPlaceholder')).toHaveValue('Go early')
|
|
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('2-3-1 Asakusa')
|
|
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('35.7148')
|
|
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.7967')
|
|
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://senso-ji.jp')
|
|
expect(screen.getByRole('button', { name: /Food/ }).className).toContain('bg-m-act')
|
|
expect(submit()).toHaveTextContent('common.save')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-004: leaves optional fields blank when the place has none', () => {
|
|
const bare = { id: 43, trip_id: 1, name: 'Nameless' } as unknown as Place
|
|
setup({ editingPlace: bare })
|
|
expect(nameField()).toHaveValue('Nameless')
|
|
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('')
|
|
expect(screen.getByPlaceholderText('places.formNotesPlaceholder')).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-005: reads the times off the in-context assignment, not off the place', () => {
|
|
const assignment = {
|
|
id: 7, day_id: 2, place_id: 42, order_index: 0,
|
|
place: { ...EDITED, place_time: '14:00', end_time: '15:00' },
|
|
} as unknown as Assignment
|
|
// A visit the plan tab lists is in both of the planner's lists.
|
|
const visits = { '2': [assignment] }
|
|
setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: visits, storedAssignments: visits })
|
|
expect(screen.getByText('places.startTime')).toBeInTheDocument()
|
|
const times = screen.getAllByDisplayValue(/^1[45]:00$/)
|
|
expect(times.map(el => (el as HTMLInputElement).value)).toEqual(['14:00', '15:00'])
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-006: prefills from a map long-press / POI pick', () => {
|
|
setup({
|
|
prefillCoords: {
|
|
lat: 35.6586, lng: 139.7454, name: 'Tokyo Tower', address: '4-2-8 Shibakoen',
|
|
website: 'https://tokyotower.co.jp', phone: '+81 3', osm_id: 'W123',
|
|
},
|
|
})
|
|
expect(nameField()).toHaveValue('Tokyo Tower')
|
|
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('35.6586')
|
|
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.7454')
|
|
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('4-2-8 Shibakoen')
|
|
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://tokyotower.co.jp')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-007: falls back to bare coordinates when the pick carries no metadata', () => {
|
|
setup({ prefillCoords: { lat: 1.5, lng: 2.5 } })
|
|
expect(nameField()).toHaveValue('')
|
|
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('1.5')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-008: refuses to save without a name', () => {
|
|
const { planner } = setup()
|
|
fireEvent.change(nameField(), { target: { value: ' ' } })
|
|
fireEvent.click(submit())
|
|
expect(planner.toast.error).toHaveBeenCalledWith('places.nameRequired')
|
|
expect(planner.handleSavePlace).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-009: saves the parsed form and clears every editor flag', async () => {
|
|
const { planner } = setup()
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.change(screen.getByPlaceholderText('places.formLat'), { target: { value: '35.71' } })
|
|
fireEvent.change(screen.getByPlaceholderText('places.formLng'), { target: { value: '139.79' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({
|
|
name: 'Nakamise', lat: 35.71, lng: 139.79, category_id: '3', _pendingFiles: undefined,
|
|
}))
|
|
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
|
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
|
|
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
|
|
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-009b: carries the free-text fields into the save payload', async () => {
|
|
const { planner } = setup()
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.change(screen.getByPlaceholderText('places.formDescriptionPlaceholder'), { target: { value: 'Shopping street' } })
|
|
fireEvent.change(screen.getByPlaceholderText('places.formNotesPlaceholder'), { target: { value: 'Cash only' } })
|
|
fireEvent.change(screen.getByPlaceholderText('places.formAddressPlaceholder'), { target: { value: '1-36-3 Asakusa' } })
|
|
fireEvent.change(screen.getByPlaceholderText('https://'), { target: { value: 'https://asakusa.example' } })
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({
|
|
description: 'Shopping street',
|
|
notes: 'Cash only',
|
|
address: '1-36-3 Asakusa',
|
|
website: 'https://asakusa.example',
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-010: sends null coordinates and no category when the fields stay empty', async () => {
|
|
const { planner } = setup()
|
|
fireEvent.change(nameField(), { target: { value: 'Somewhere' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ lat: null, lng: null, category_id: null }))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-011: strips non-numeric characters from the coordinate fields', () => {
|
|
setup()
|
|
const lat = screen.getByPlaceholderText('places.formLat')
|
|
fireEvent.change(lat, { target: { value: '3a5.7N' } })
|
|
expect(lat).toHaveValue('35.7')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-012: a pasted "lat, lng" pair fills both coordinate fields', () => {
|
|
setup()
|
|
const lat = screen.getByPlaceholderText('places.formLat')
|
|
fireEvent.paste(lat, { clipboardData: { getData: () => ' 35.6895, 139.6917 ' } })
|
|
expect(lat).toHaveValue('35.6895')
|
|
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.6917')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-013: an unparseable paste is left to the browser', () => {
|
|
setup()
|
|
const lat = screen.getByPlaceholderText('places.formLat')
|
|
fireEvent.paste(lat, { clipboardData: { getData: () => 'Tokyo Tower' } })
|
|
expect(lat).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-014: warns once about a duplicate name and saves on the second tap', async () => {
|
|
const { planner } = setup({ places: [EXISTING] })
|
|
fireEvent.change(nameField(), { target: { value: ' ueno park ' } })
|
|
fireEvent.click(submit())
|
|
|
|
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
|
|
expect(planner.handleSavePlace).not.toHaveBeenCalled()
|
|
expect(submit()).toHaveTextContent('places.addAnyway')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-015: treats near-identical coordinates as a duplicate', () => {
|
|
const { planner } = setup({ places: [EXISTING], prefillCoords: { lat: 35.71505, lng: 139.77305 } })
|
|
fireEvent.change(nameField(), { target: { value: 'Park entrance' } })
|
|
fireEvent.click(submit())
|
|
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-016: a maps pick that shares the Google Place ID counts as a duplicate', async () => {
|
|
const { planner } = setup({ places: [EXISTING] })
|
|
fireEvent.change(screen.getByPlaceholderText('places.mapsSearchPlaceholder'), { target: { value: 'ueno koen' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
|
fireEvent.click(await screen.findByText('Ueno Koen'))
|
|
|
|
expect(nameField()).toHaveValue('Ueno Koen')
|
|
fireEvent.click(submit())
|
|
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-017: an existing place never triggers the duplicate guard', async () => {
|
|
const { planner } = setup({ places: [EXISTING], editingPlace: { ...EXISTING, id: 99 } as unknown as Place })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.toast.warning).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-018: surfaces the server message and keeps the sheet open when the save fails', async () => {
|
|
const handleSavePlace = vi.fn().mockRejectedValue(new Error('Trip is locked'))
|
|
const { planner } = setup({ handleSavePlace })
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Trip is locked'))
|
|
expect(planner.setShowPlaceForm).not.toHaveBeenCalled()
|
|
expect(submit()).not.toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-019: falls back to the generic message for a non-Error rejection', async () => {
|
|
const handleSavePlace = vi.fn().mockRejectedValue('nope')
|
|
const { planner } = setup({ handleSavePlace })
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.saveError'))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-020: shows the saving label and blocks the button while the save runs', async () => {
|
|
let release: () => void = () => {}
|
|
const handleSavePlace = vi.fn(() => new Promise<{ id: number }>(resolve => { release = () => resolve({ id: 1 }) }))
|
|
setup({ handleSavePlace })
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(submit()).toHaveTextContent('common.saving'))
|
|
expect(submit()).toBeDisabled()
|
|
release()
|
|
await waitFor(() => expect(handleSavePlace).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-021: deletes on the second tap and stages the id on the first', async () => {
|
|
const { planner } = setup({ editingPlace: EDITED })
|
|
const del = screen.getByRole('button', { name: 'common.delete' })
|
|
fireEvent.click(del)
|
|
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(42)
|
|
expect(planner.toast.warning).toHaveBeenCalledWith('mobileTrip.tapAgainToDelete')
|
|
expect(planner.confirmDeletePlace).not.toHaveBeenCalled()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
|
await waitFor(() => expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('requires explicit permanent confirmation when a dormant Tour is deleted from the Place editor', async () => {
|
|
const tour = { ...EDITED, tour_place_id: EDITED.id } as Place
|
|
const { planner } = setup({ editingPlace: tour, isTourPlace: vi.fn(() => true) })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
|
|
|
expect(planner.toast.warning).toHaveBeenCalledWith('tours.delete.confirmBody')
|
|
fireEvent.click(screen.getByRole('button', { name: 'tours.delete.confirmAction' }))
|
|
await waitFor(() => expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-022: cancelling an armed delete un-stages the id', () => {
|
|
const { planner } = setup({ editingPlace: EDITED })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
|
expect(planner.setDeletePlaceId).toHaveBeenLastCalledWith(null)
|
|
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-023: the header close leaves the staged id alone when nothing was armed', () => {
|
|
const { planner } = setup({ editingPlace: EDITED })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
|
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
|
expect(planner.setDeletePlaceId).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-024: blocks the save while the end time is not after the start', () => {
|
|
const assignment = { id: 7, day_id: 2, place_id: 42, order_index: 0, place: EDITED } as unknown as Assignment
|
|
const visits = { '2': [assignment] }
|
|
setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: visits, storedAssignments: visits })
|
|
const [start, end] = screen.getAllByDisplayValue(/^0[89]:[0-3]0$/) as HTMLInputElement[]
|
|
expect(submit()).not.toBeDisabled()
|
|
fireEvent.change(end, { target: { value: '07:00' } })
|
|
expect(start).toHaveValue('08:00')
|
|
expect(screen.getByText('places.endTimeBeforeStart')).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-025: attaches pasted images and sends them with the save', async () => {
|
|
const { planner } = setup()
|
|
const file = new File(['x'], 'ticket.png', { type: 'image/png' })
|
|
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
|
clipboardData: { items: [{ type: 'text/plain', getAsFile: () => null }, { type: 'image/png', getAsFile: () => file }] },
|
|
})
|
|
expect(await screen.findByText('ticket.png')).toBeInTheDocument()
|
|
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingFiles: [file] }))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-025b: attaches files chosen through the picker', async () => {
|
|
const { planner } = setup()
|
|
const file = new File(['x'], 'map.pdf', { type: 'application/pdf' })
|
|
const picker = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
fireEvent.change(picker, { target: { files: [file] } })
|
|
expect(await screen.findByText('map.pdf')).toBeInTheDocument()
|
|
|
|
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingFiles: [file] })))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-026: a clipboard item without a file is ignored', () => {
|
|
setup()
|
|
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
|
clipboardData: { items: [{ type: 'application/pdf', getAsFile: () => null }] },
|
|
})
|
|
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-027: drops a pending attachment again', async () => {
|
|
setup()
|
|
const file = new File(['x'], 'voucher.pdf', { type: 'application/pdf' })
|
|
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
|
clipboardData: { items: [{ type: 'application/pdf', getAsFile: () => file }] },
|
|
})
|
|
expect(await screen.findByText('voucher.pdf')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
|
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-028: hides the file row and ignores pastes without the upload permission', () => {
|
|
setup({ canUploadFiles: false })
|
|
expect(screen.queryByText('files.title')).not.toBeInTheDocument()
|
|
const file = new File(['x'], 'ticket.png', { type: 'image/png' })
|
|
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
|
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
|
|
})
|
|
expect(screen.queryByText('ticket.png')).not.toBeInTheDocument()
|
|
})
|
|
|
|
/**
|
|
* Types into the search row and returns the bias the autocomplete request
|
|
* carried. The hint comes from useLocationBias, which reads the trip store,
|
|
* not the planner prop, so both are seeded the way the running app has them.
|
|
*/
|
|
async function capturedBias(places: Place[], day?: { id: number; placeIds: number[] }) {
|
|
const bodies: Record<string, unknown>[] = []
|
|
server.use(http.post('/api/maps/autocomplete', async ({ request }) => {
|
|
bodies.push(await request.json() as Record<string, unknown>)
|
|
return HttpResponse.json({ source: 'osm', suggestions: [] })
|
|
}))
|
|
seedStore(useTripStore, {
|
|
places,
|
|
assignments: day ? { [String(day.id)]: day.placeIds.map(id => ({ place_id: id })) } : {},
|
|
selectedDayId: day?.id ?? null,
|
|
})
|
|
setup({ places })
|
|
fireEvent.change(screen.getByPlaceholderText('places.mapsSearchPlaceholder'), { target: { value: 'ueno' } })
|
|
await waitFor(() => expect(bodies).toHaveLength(1))
|
|
return bodies[0].locationBias
|
|
}
|
|
|
|
it('FE-MOB-PLEDIT-029: biases the maps search on the trip box while the trip fits one region', async () => {
|
|
const places = [
|
|
{ id: 1, name: 'A', lat: 35.6, lng: 139.7 },
|
|
{ id: 2, name: 'B', lat: 35.8, lng: 139.9 },
|
|
{ id: 3, name: 'C', lat: null, lng: null },
|
|
] as unknown as Place[]
|
|
// 25km across and no day open, so the trip is the best hint there is.
|
|
expect(await capturedBias(places)).toEqual({ low: { lat: 35.6, lng: 139.7 }, high: { lat: 35.8, lng: 139.9 } })
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-029b: narrows the bias to the open day when the trip is too wide for one', async () => {
|
|
const places = [
|
|
{ id: 1, name: 'Ueno', lat: 35.71, lng: 139.77 },
|
|
{ id: 2, name: 'Asakusa', lat: 35.71, lng: 139.8 },
|
|
{ id: 3, name: 'Nagoya', lat: 35.18, lng: 136.91 },
|
|
] as unknown as Place[]
|
|
// The trip reaches Nagoya and is dropped, but the day being planned still
|
|
// gets a hint. That is what preferring the day buys.
|
|
expect(await capturedBias(places, { id: 7, placeIds: [1, 2] })).toEqual({
|
|
low: { lat: 35.71, lng: 139.77 }, high: { lat: 35.71, lng: 139.8 },
|
|
})
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-030: drops the bias for a trip that outgrows one region', async () => {
|
|
// Tokyo to Nagoya, 264km across: inside the old 500km diagonal rule, outside
|
|
// the 60km radius one. A box that wide has its centre in open country.
|
|
const places = [
|
|
{ id: 1, name: 'Tokyo', lat: 35.68, lng: 139.76 },
|
|
{ id: 2, name: 'Nagoya', lat: 35.18, lng: 136.91 },
|
|
] as unknown as Place[]
|
|
expect(await capturedBias(places)).toBeUndefined()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-031: sends no bias when no place has usable coordinates', async () => {
|
|
const places = [{ id: 1, name: 'Broken', lat: 'north', lng: 'east' }] as unknown as Place[]
|
|
expect(await capturedBias(places)).toBeUndefined()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-032: sends no bias for a trip without any located place', async () => {
|
|
expect(await capturedBias([])).toBeUndefined()
|
|
})
|
|
|
|
// ── Linked expense (#1298) — the same block the booking sheet has ──────────
|
|
|
|
describe('Costs', () => {
|
|
const withBudget = () => useAddonStore.setState({
|
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }] as never,
|
|
loaded: true,
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-033: the button only appears while the Budget addon is on', () => {
|
|
const { unmount } = setup()
|
|
expect(screen.queryByRole('button', { name: 'Create expense' })).not.toBeInTheDocument()
|
|
unmount()
|
|
|
|
withBudget()
|
|
setup()
|
|
expect(screen.getByRole('button', { name: 'Create expense' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-034: creating an expense saves the place first, then opens the editor', async () => {
|
|
withBudget()
|
|
const handleSavePlace = vi.fn(async () => ({ id: 42 }))
|
|
const { onOpenExpense } = setup({ handleSavePlace })
|
|
|
|
fireEvent.change(nameField(), { target: { value: 'Louvre' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
|
|
|
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled())
|
|
expect(handleSavePlace.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0])
|
|
expect(onOpenExpense).toHaveBeenCalledWith({
|
|
prefill: { placeId: 42, name: 'Louvre', category: 'activities' },
|
|
})
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-035: a save that yields no id opens no editor', async () => {
|
|
withBudget()
|
|
const handleSavePlace = vi.fn(async () => undefined)
|
|
const { onOpenExpense } = setup({ handleSavePlace })
|
|
|
|
fireEvent.change(nameField(), { target: { value: 'Louvre' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Create expense' }))
|
|
|
|
await waitFor(() => expect(handleSavePlace).toHaveBeenCalled())
|
|
expect(onOpenExpense).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-044: an edited place lists its expenses and opens one for editing (#2084)', () => {
|
|
withBudget()
|
|
const tickets = { id: 70, trip_id: 1, name: 'Temple tickets', total_price: 5, category: 'activities', place_id: 42 }
|
|
seedStore(useTripStore, { trip: { id: 1, currency: 'JPY' }, budgetItems: [tickets] })
|
|
const { onOpenExpense } = setup({ editingPlace: EDITED })
|
|
expect(screen.getByText('Linked expenses')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByText('Temple tickets'))
|
|
expect(onOpenExpense).toHaveBeenCalledWith({ editItem: tickets })
|
|
// The place hint only shows while nothing is linked.
|
|
expect(screen.queryByText('Saves the place, then opens the Costs editor.')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-045: a new place shows the place hint and nothing to link to yet', () => {
|
|
withBudget()
|
|
setup()
|
|
expect(screen.getByText('Saves the place, then opens the Costs editor.')).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ── A visit the day lists hide (the road trip stop sheet opens the editor on them) ──
|
|
|
|
describe('a visit the day lists hide', () => {
|
|
/**
|
|
* A booked night: the phone's filtered `assignments` never carries it, the stored
|
|
* map does. Its own times differ from the pool place's (08:00 to 09:30), so a
|
|
* lookup in the wrong list shows up as the wrong clock.
|
|
*/
|
|
const NIGHT = {
|
|
id: 7, day_id: 2, place_id: 42, order_index: 0, accommodation_id: 9, notes: 'Late check-in',
|
|
place: { ...EDITED, place_time: '10:00', end_time: '11:00' },
|
|
} as unknown as Assignment
|
|
|
|
const openOnNight = (overrides: Partial<TripPlanner> = {}) => setup({
|
|
editingPlace: EDITED,
|
|
editingAssignmentId: 7,
|
|
assignments: {},
|
|
storedAssignments: { '2': [NIGHT] },
|
|
...overrides,
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-036: reads the times and the day note off the stored visit', () => {
|
|
openOnNight()
|
|
const times = screen.getAllByDisplayValue(/^1[01]:00$/) as HTMLInputElement[]
|
|
expect(times.map(el => el.value)).toEqual(['10:00', '11:00'])
|
|
expect(screen.getByPlaceholderText('places.assignmentNotesPlaceholder')).toHaveValue('Late check-in')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-037: saving it untouched keeps the pinned time and sends no note', async () => {
|
|
const { planner } = openOnNight()
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
const payload = vi.mocked(planner.handleSavePlace).mock.calls[0][0]
|
|
expect(payload).toMatchObject({ place_time: '10:00', end_time: '11:00' })
|
|
expect(payload).not.toHaveProperty('assignment_notes')
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-038: an edited day note on it travels with the save', async () => {
|
|
const { planner } = openOnNight()
|
|
fireEvent.change(screen.getByPlaceholderText('places.assignmentNotesPlaceholder'), { target: { value: 'Key box at the door' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ assignment_notes: 'Key box at the door' }))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-039: clearing Start on it sends the empty time that unpins the stop', async () => {
|
|
const { planner } = openOnNight()
|
|
const [start] = screen.getAllByDisplayValue('10:00')
|
|
fireEvent.change(start, { target: { value: '' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ place_time: '', end_time: '11:00' }))
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-040: the overlap warning names what the day lists show, never a hidden stop', () => {
|
|
const listed = {
|
|
id: 8, day_id: 2, place_id: 50, order_index: 1,
|
|
place: { id: 50, name: 'Kaminarimon', place_time: '10:30', end_time: '11:30' },
|
|
} as unknown as Assignment
|
|
const hiddenPump = {
|
|
id: 9, day_id: 2, place_id: 51, order_index: 2,
|
|
place: { id: 51, name: 'ENEOS Asakusa', stop_type: 'fuel', place_time: '10:15', end_time: '10:45' },
|
|
} as unknown as Assignment
|
|
openOnNight({
|
|
assignments: { '2': [listed] },
|
|
storedAssignments: { '2': [NIGHT, listed, hiddenPump] },
|
|
})
|
|
// The night itself is hidden too, and still gets warned: the warning reads its day off it.
|
|
const warning = screen.getByText(/places\.timeCollision/)
|
|
expect(warning).toHaveTextContent('Kaminarimon')
|
|
expect(warning).not.toHaveTextContent('ENEOS')
|
|
})
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-041: shows the details block for the place under edit', async () => {
|
|
server.use(
|
|
http.post('/api/maps/enrichment', () => HttpResponse.json({
|
|
photos: [],
|
|
description: { text: 'The oldest temple in Tokyo.', source: 'wikipedia', sourceUrl: null, license: null },
|
|
facts: [],
|
|
rating: null,
|
|
hours: null,
|
|
})),
|
|
)
|
|
setup({ editingPlace: EDITED })
|
|
expect(await screen.findByText('The oldest temple in Tokyo.')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-042: keeps the details block off while the instance has enrichment disabled', () => {
|
|
seedStore(useAuthStore, { placesEnrichEnabled: false })
|
|
setup({ editingPlace: EDITED })
|
|
expect(screen.queryByText('places.details.title')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLEDIT-043: a plugin POI pick fills the form and asks the details block about its plugin id, not a Google one', async () => {
|
|
const asked: unknown[] = []
|
|
server.use(
|
|
http.post('/api/maps/enrichment', async ({ request }) => {
|
|
asked.push(await request.json())
|
|
return HttpResponse.json({ photos: [], description: null, facts: [], rating: null, hours: null })
|
|
}),
|
|
)
|
|
const { planner } = setup({
|
|
prefillCoords: {
|
|
lat: 47.1, lng: 11.2, name: 'Trailhead', address: 'Hut 1', website: 'https://trails.example/th-043',
|
|
phone: '+43 1', osm_id: 'plugin:trail-finder:th-043',
|
|
},
|
|
})
|
|
expect(nameField()).toHaveValue('Trailhead')
|
|
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://trails.example/th-043')
|
|
// The server decides who to ask by this id, and never takes a `plugin:` one for Google's.
|
|
await waitFor(() => expect(asked).toEqual([expect.objectContaining({ placeId: 'plugin:trail-finder:th-043', lat: 47.1, lng: 11.2 })]))
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
|
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ name: 'Trailhead', osm_id: 'plugin:trail-finder:th-043' }))
|
|
expect(planner.handleSavePlace).not.toHaveBeenCalledWith(expect.objectContaining({ google_place_id: expect.anything() }))
|
|
})
|
|
})
|