Files
TREK/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx
T
Maurice 2a8071f642 feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside
search(). Its places then show up in the place search's typed-ahead list,
after the core suggestions and marked with the plugin's name, on desktop
and phone. An index that cannot take a request per keystroke leaves it
out and keeps answering the full search only.

The child reports which functions each hook object carries at load, so
GET /api/plugin-search/suggest only asks the plugins that implement
suggest: from the second character, three rows at most across all of
them, 800 ms to answer. The feed marks plugins holding
hook:search-provider and the client only asks the route while one is
installed. A plugin row carries its whole place, so picking it skips the
details lookup, which knows no plugin id. The phone's search results now
name their source as the desktop ones do, and the desktop dropdown
scrolls instead of running past the dialog.

Addresses discussion #2221.
2026-09-25 09:19:11 +02:00

574 lines
26 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpResponse, delay, http } from 'msw'
import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { resetAllStores, seedStore } from '../../../helpers/store'
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-025
// planner.t echoes the key, so labels/toasts are asserted as their keys.
const LOUVRE = {
name: 'Louvre Museum',
address: 'Rue de Rivoli, Paris',
lat: 48.8606,
lng: 2.3376,
google_place_id: 'ChIJ_louvre',
google_ftid: '0x47e:0x1',
osm_id: 'W7444,',
website: 'https://louvre.fr',
phone: '+33 1',
}
const SUGGESTION = { placeId: 'sug-1', mainText: 'Louvre', secondaryText: 'Paris, France' }
/** Bodies of every autocomplete request the component fired. */
let autocompleteBodies: Record<string, unknown>[] = []
let searchBodies: Record<string, unknown>[] = []
function recordAutocomplete(suggestions: unknown[] = [SUGGESTION]) {
return http.post('/api/maps/autocomplete', async ({ request }) => {
autocompleteBodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ suggestions, source: 'osm' })
})
}
function recordSearch(places: unknown[] = [LOUVRE]) {
return http.post('/api/maps/search', async ({ request }) => {
searchBodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ places, source: 'osm' })
})
}
/**
* The index answers with the wrong place; only a search sent to Google on
* purpose answers with the right one. The real source strings, so the test
* pins what the line under the list actually switches on.
*/
function recordGoogleRetry() {
return http.post('/api/maps/search', async ({ request }) => {
const body = await request.json() as Record<string, unknown>
searchBodies.push(body)
return body.provider === 'google'
? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: 35.68, lng: 139.77 }], source: 'google' })
: HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: 47.1, lng: -118.4 }], source: 'trek-places+openstreetmap' })
})
}
function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Parameters<typeof PlPlaceSearch>[0]['locationBias']) {
const onPick = vi.fn()
const onResolvingChange = vi.fn()
const planner = buildPlanner(plannerOverrides)
const view = render(
<PlPlaceSearch planner={planner} locationBias={locationBias} onPick={onPick} onResolvingChange={onResolvingChange} />,
)
const input = screen.getByPlaceholderText('places.mapsSearchPlaceholder')
return { ...view, planner, onPick, onResolvingChange, input }
}
describe('PlPlaceSearch', () => {
beforeEach(() => {
resetAllStores()
autocompleteBodies = []
searchBodies = []
})
it('FE-MOB-PLSRCH-001: stays quiet below two characters', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'L' } })
await new Promise(r => setTimeout(r, 450))
expect(autocompleteBodies).toHaveLength(0)
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-002: debounces the autocomplete and lists both suggestion lines', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(autocompleteBodies).toHaveLength(0)
expect(await screen.findByText('Louvre')).toBeInTheDocument()
expect(screen.getByText('Paris, France')).toBeInTheDocument()
expect(autocompleteBodies[0]).toMatchObject({ input: 'Lou', lang: 'en' })
})
it('FE-MOB-PLSRCH-003: forwards the trip-centre bias', async () => {
server.use(recordAutocomplete())
const bias = { low: { lat: 48.8, lng: 2.3 }, high: { lat: 48.9, lng: 2.4 } }
const { input } = setup({}, bias)
fireEvent.change(input, { target: { value: 'Lou' } })
await waitFor(() => expect(autocompleteBodies).toHaveLength(1))
expect(autocompleteBodies[0].locationBias).toEqual(bias)
})
it('FE-MOB-PLSRCH-004: picking a suggestion applies its resolved details and clears the field', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: LOUVRE })),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
const row = await screen.findByText('Louvre')
// The row swallows pointerdown so the field's blur handler cannot close
// the dropdown before the click lands.
const pointerDown = fireEvent.pointerDown(row)
expect(pointerDown).toBe(false)
fireEvent.click(row)
// Optimistic name first, then the full record.
expect(onPick).toHaveBeenNthCalledWith(1, { name: 'Louvre' })
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
expect(onPick).toHaveBeenLastCalledWith({
name: 'Louvre Museum',
address: 'Rue de Rivoli, Paris',
lat: '48.8606',
lng: '2.3376',
google_place_id: 'ChIJ_louvre',
google_ftid: '0x47e:0x1',
osm_id: 'W7444,',
website: 'https://louvre.fr',
phone: '+33 1',
// The full record rides along so the details block can hand it to the
// enrichment call and skip the server's own details lookup.
details: LOUVRE,
})
expect(input).toHaveValue('')
})
it('FE-MOB-PLSRCH-005: falls back to the text search when the details hop fails', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'disabled' }, { status: 500 })),
recordSearch(),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
expect(searchBodies[0]).toEqual({ query: 'Louvre, Paris, France' })
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Louvre Museum', lat: '48.8606' }))
})
it('FE-MOB-PLSRCH-005b: an OpenStreetMap row keeps its own coordinates instead of searching for its label', async () => {
// The layer's second line is the name written on the building, not an
// address, so joining the two asks a question nobody typed — and whatever
// came back first was taken as the place the user had already picked.
server.use(
recordAutocomplete([{
placeId: 'node:9712313',
mainText: 'Tokio Hauptbahnhof',
secondaryText: '東京駅丸の内駅舎',
source: 'openstreetmap',
lat: 35.6811816,
lng: 139.76598265,
}]),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
recordSearch(),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Tok' } })
fireEvent.click(await screen.findByText('Tokio Hauptbahnhof'))
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
expect(searchBodies).toHaveLength(0)
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({
name: 'Tokio Hauptbahnhof', lat: '35.6811816', lng: '139.76598265',
}))
})
it('FE-MOB-PLSRCH-005c: a suggestion says which index answered', async () => {
// Both indexes answer the keystroke path at once, so the name the response
// carries for the whole list is true of the call and wrong for half its rows.
server.use(recordAutocomplete([
{ ...SUGGESTION, source: 'trek-places' },
{ placeId: 'node:1', mainText: 'Louvre Palace', secondaryText: 'Palais du Louvre', source: 'openstreetmap' },
]))
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(await screen.findByText('TREK')).toBeInTheDocument()
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument()
})
it('FE-MOB-PLSRCH-006: also falls back when the details hop answers without coordinates', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: { name: 'Louvre' } })),
recordSearch(),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(searchBodies).toHaveLength(1))
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ lat: '48.8606' }))
})
it('FE-MOB-PLSRCH-007: restores the typed query and toasts when nothing resolves', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
recordSearch([]),
)
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.mapsSearchError'))
expect(input).toHaveValue('Lou')
})
it('FE-MOB-PLSRCH-008: surfaces the server message when the fallback search rejects', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
http.post('/api/maps/search', () => HttpResponse.json({ error: 'Places API is disabled' }, { status: 502 })),
)
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Places API is disabled'))
expect(input).toHaveValue('Lou')
})
it('FE-MOB-PLSRCH-009: a "lat, lng" query becomes coordinates without any lookup', async () => {
server.use(recordAutocomplete(), recordSearch())
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: '48.8566; 2.3522' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
expect(onPick).toHaveBeenCalledWith({ lat: '48.8566', lng: '2.3522' })
expect(input).toHaveValue('')
await new Promise(r => setTimeout(r, 400))
expect(autocompleteBodies).toHaveLength(0)
expect(searchBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-010: resolves a Google Maps URL and confirms it', async () => {
server.use(
recordAutocomplete(),
http.post('/api/maps/resolve-url', () => HttpResponse.json({
lat: 48.86, lng: 2.33, name: 'Louvre', address: 'Paris', google_ftid: '0x1:0x2',
})),
)
const { input, onPick, planner } = setup()
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('places.urlResolved'))
expect(onPick).toHaveBeenCalledWith({
name: 'Louvre', address: 'Paris', lat: '48.86', lng: '2.33', google_ftid: '0x1:0x2',
})
expect(input).toHaveValue('')
// URLs never hit the autocomplete debounce.
expect(autocompleteBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-011: falls through to the text search when the URL carries no coordinates', async () => {
server.use(
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: null, lng: null })),
recordSearch(),
)
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'https://www.google.com/maps/place/Louvre' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(searchBodies).toHaveLength(1))
expect(await screen.findByText('Louvre Museum')).toBeInTheDocument()
expect(planner.toast.success).not.toHaveBeenCalled()
})
it('FE-MOB-PLSRCH-012: lists text results and applies the tapped one', async () => {
server.use(recordSearch([LOUVRE, { name: 'Louvre Lens', address: 'Lens' }]))
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Louvre Museum')).toBeInTheDocument()
expect(screen.getByText('Louvre Lens')).toBeInTheDocument()
expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()
fireEvent.click(screen.getByText('Louvre Lens'))
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre Lens', address: 'Lens', lat: undefined }))
await waitFor(() => expect(screen.queryByText('Louvre Museum')).not.toBeInTheDocument())
expect(input).toHaveValue('')
})
it('FE-MOB-PLSRCH-013: an empty query does nothing', async () => {
server.use(recordSearch())
const { onPick } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await new Promise(r => setTimeout(r, 50))
expect(searchBodies).toHaveLength(0)
expect(onPick).not.toHaveBeenCalled()
})
it('FE-MOB-PLSRCH-014: reports the resolving state and blocks the button while searching', async () => {
server.use(http.post('/api/maps/search', async () => {
await delay(120)
return HttpResponse.json({ places: [LOUVRE], source: 'osm' })
}))
const { input, onResolvingChange } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'common.search' })).toBeDisabled())
expect(onResolvingChange).toHaveBeenCalledWith(true)
await waitFor(() => expect(onResolvingChange).toHaveBeenLastCalledWith(false))
expect(screen.getByRole('button', { name: 'common.search' })).not.toBeDisabled()
})
it('FE-MOB-PLSRCH-015: toasts the fallback message when the search fails without a body', async () => {
server.use(http.post('/api/maps/search', () => new HttpResponse(null, { status: 500 })))
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.mapsSearchError'))
})
it('FE-MOB-PLSRCH-016: a failing autocomplete empties the dropdown', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(await screen.findByText('Louvre')).toBeInTheDocument()
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({}, { status: 500 })))
fireEvent.change(input, { target: { value: 'Louv' } })
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
})
it('FE-MOB-PLSRCH-017: a superseded autocomplete is aborted and does not clear the newer list', async () => {
server.use(http.post('/api/maps/autocomplete', async ({ request }) => {
const body = await request.json() as { input: string }
autocompleteBodies.push(body)
if (body.input === 'Lou') {
await delay(3000)
return HttpResponse.json({ suggestions: [], source: 'osm' })
}
return HttpResponse.json({ suggestions: [SUGGESTION], source: 'osm' })
}))
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
await waitFor(() => expect(autocompleteBodies).toHaveLength(1))
fireEvent.change(input, { target: { value: 'Louvre mus' } })
expect(await screen.findByText('Louvre')).toBeInTheDocument()
await new Promise(r => setTimeout(r, 100))
expect(screen.getByText('Louvre')).toBeInTheDocument()
})
it('FE-MOB-PLSRCH-018b: a suggestion without a second line searches on its main text alone', async () => {
server.use(
recordAutocomplete([{ placeId: 'sug-2', mainText: 'Louvre', secondaryText: '' }]),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
recordSearch(),
)
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(searchBodies).toHaveLength(1))
expect(searchBodies[0]).toEqual({ query: 'Louvre' })
})
it('FE-MOB-PLSRCH-018c: a response without a places array leaves the result list empty', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'osm' })))
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(screen.getByRole('button', { name: 'common.search' })).not.toBeDisabled())
expect(screen.queryByText('Louvre Museum')).not.toBeInTheDocument()
expect(planner.toast.error).not.toHaveBeenCalled()
})
it('FE-MOB-PLSRCH-018d: a nameless result still renders and can be picked', async () => {
server.use(recordSearch([{ lat: 48.86, lng: 2.33 }]))
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(searchBodies).toHaveLength(1))
// The only unlabelled button is the result row; its two lines stay empty.
const rows = (await screen.findAllByRole('button')).filter(b => !b.getAttribute('aria-label'))
expect(rows).toHaveLength(1)
expect(rows[0].textContent).toBe('')
fireEvent.click(rows[0])
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: undefined, lat: '48.86', lng: '2.33' }))
})
it('FE-MOB-PLSRCH-018e: keys other than Enter do not trigger a search', async () => {
server.use(recordSearch())
const { input } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'ArrowDown' })
await new Promise(r => setTimeout(r, 50))
expect(searchBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-018f: a URL that resolves to bare coordinates picks them without extras', async () => {
server.use(http.post('/api/maps/resolve-url', () => HttpResponse.json({
lat: 48.86, lng: 2.33, name: null, address: null, google_ftid: null,
})))
const { input, onPick, planner } = setup()
fireEvent.change(input, { target: { value: 'https://goo.gl/maps/xyz' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('places.urlResolved'))
expect(onPick).toHaveBeenCalledWith({
name: undefined, address: undefined, lat: '48.86', lng: '2.33', google_ftid: undefined,
})
})
it('FE-MOB-PLSRCH-018: blurring the field dismisses the dropdown', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(await screen.findByText('Louvre')).toBeInTheDocument()
fireEvent.blur(input)
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
})
it('FE-MOB-PLSRCH-019: a list the index answered offers Google instead, and the line sends the same query there alone', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies[0]).not.toHaveProperty('provider')
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
// A list Google produced has nowhere further to go.
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-020: without a Google key the list offers nothing', async () => {
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-021: a key alone is not enough: with Amap or OpenStreetMap picked the list offers nothing', async () => {
// The server only honours the request while Google holds the keyed slot;
// under another provider the line would re-run the same search and stay.
seedStore(useAuthStore, { hasMapsKey: true, placesProvider: 'amap' })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'places.searchGoogleInstead' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-022: the line sends the query the list came from, whatever the field holds by then', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordAutocomplete([]), recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
// The list stays while the field is retyped; the line still means this list.
fireEvent.change(input, { target: { value: 'Kyoto' } })
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies).toHaveLength(2)
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
})
it('FE-MOB-PLSRCH-022b: the line still works after the field was cleared', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
server.use(recordGoogleRetry())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Tokyo Station' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Weigh station')).toBeInTheDocument()
// An empty field used to make the tap a silent no-op.
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'places.searchGoogleInstead' }))
expect(await screen.findByText('Tokyo Station')).toBeInTheDocument()
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
})
})
describe('PlPlaceSearch plugin search (#2221)', () => {
const ATP = { id: 'all-the-places', name: 'All the Places', type: 'integration' as const, icon: null }
const HIT = {
osm_id: 'plugin:all-the-places:ichiran-ueno', name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito',
lat: 35.7101, lng: 139.7745, rating: 4.3, website: 'https://ichiran.com/shop/ueno', phone: null,
category: 'restaurant', description: null, source: 'plugin:all-the-places', pluginId: 'all-the-places',
}
beforeEach(() => {
resetAllStores()
autocompleteBodies = []
searchBodies = []
})
afterEach(() => {
usePluginStore.setState({ plugins: [] })
})
it('FE-MOB-PLSRCH-023: a plugin that answers as you type adds its rows under the core ones, marked with its name', async () => {
usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] })
server.use(recordAutocomplete(), http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [HIT] })))
const { input } = setup()
fireEvent.change(input, { target: { value: 'Ichi' } })
const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!
expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument()
const coreRow = screen.getByText('Louvre').closest('button')!
expect(coreRow.compareDocumentPosition(pluginRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('FE-MOB-PLSRCH-024: picking a plugin row hands over its place without a details lookup', async () => {
usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] })
const details = vi.fn()
server.use(
recordAutocomplete([]),
http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [HIT] })),
http.get('/api/maps/details/:placeId', () => { details(); return HttpResponse.json({ place: null }) }),
recordSearch([]),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Ichi' } })
fireEvent.click(await screen.findByText('Ichiran Ueno'))
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({
name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito', lat: '35.7101', lng: '139.7745',
osm_id: 'plugin:all-the-places:ichiran-ueno', website: 'https://ichiran.com/shop/ueno',
}))
expect(details).not.toHaveBeenCalled()
expect(searchBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-025: a searched list marks every row with its source, as the desktop form does', async () => {
usePluginStore.setState({ plugins: [ATP] })
const perKeystroke = vi.fn()
server.use(
recordAutocomplete([]),
http.get('/api/plugin-search/suggest', () => { perKeystroke(); return HttpResponse.json({ places: [] }) }),
http.post('/api/maps/search', () =>
HttpResponse.json({ places: [{ name: 'Ichiran Shibuya', address: 'Jinnan', lat: 35.66, lng: 139.7, source: 'trek-places' }], source: 'trek-places' })),
http.get('/api/plugin-search', () => HttpResponse.json({ places: [HIT] })),
)
const { input } = setup()
fireEvent.change(input, { target: { value: 'Ichiran' } })
fireEvent.keyDown(input, { key: 'Enter' })
const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!
expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument()
expect(within(screen.getByText('Ichiran Shibuya').closest('button')!).getByText('TREK')).toBeInTheDocument()
expect(perKeystroke).not.toHaveBeenCalled()
})
})