Files
TREK/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx
T
Maurice 8864edda18 feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could
not be tied to the booking it belongs to, and a booking held one expense.

- The Costs block of the booking, transport and place forms lists every
  linked expense with edit, unlink (the expense stays in Costs) and delete,
  and offers the expenses that belong nowhere yet under Link existing
  expense. The phone sheets get the same block in their own design, and
  their Files row lists the attached files and links an existing one.
- PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC)
  take reservation_id / place_id; create and update refuse an id from
  another trip on all three paths.
- The booking's mirrored price is the sum of its expenses when they share
  one currency (none counts as the trip's), else the first one, with that
  currency, and follows every change to a linked expense. The booking
  form's price field leaves a booking with several expenses alone.
- Deleting a booking deletes all of its linked expenses and broadcasts each
  one, also from the MCP delete tools.
- Cards show the price with its currency.
- Booking and transport dialogs: Booking Type and Travelers as dropdowns at
  the top, the URL beside Files (transports gain the URL field). The row is
  one shared component; linking and unlinking files go through the store.

Help center pictures and texts for the booking, transport and expense
guides are redone in all 27 languages, five Costs labels that were still
English in 16 languages are translated, and the wiki follows.
2026-09-25 15:34:32 +02:00

612 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('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() }))
})
})