Files
TREK/client/src/repo/pluginSearchRepo.test.ts
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

93 lines
4.4 KiB
TypeScript

// FE-REPO-PLUGINSEARCH-001 to FE-REPO-PLUGINSEARCH-006
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delay, http, HttpResponse } from 'msw'
import type { PluginSearchHit } from '@trek/shared'
import { server } from '../../tests/helpers/msw/server'
import { PLUGIN_SUGGEST_DEADLINE_MS, pluginSearchRepo } from './pluginSearchRepo'
import { isEffectivelyOffline } from '../sync/networkMode'
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }))
const hit: PluginSearchHit = {
osm_id: 'plugin:all-the-places:ichiran-shibuya', name: 'Ichiran Shibuya', address: 'Jinnan 1-22-7, Shibuya',
lat: 35.6617, lng: 139.6993, rating: 4.4, website: 'https://ichiran.com', phone: null,
category: 'restaurant', description: null, source: 'plugin:all-the-places', pluginId: 'all-the-places',
}
beforeEach(() => {
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('pluginSearchRepo.suggest', () => {
it('FE-REPO-PLUGINSEARCH-001: asks the typed-ahead route with the query, language and bias, and hands back rows that carry their place', async () => {
let url: URL | null = null
server.use(http.get('/api/plugin-search/suggest', ({ request }) => {
url = new URL(request.url)
return HttpResponse.json({ places: [hit] })
}))
const rows = await pluginSearchRepo.suggest('ichi', 'ja', { lat: 35.66, lng: 139.7 }, new AbortController().signal)
expect(Object.fromEntries(url!.searchParams)).toEqual({ q: 'ichi', lang: 'ja', lat: '35.66', lng: '139.7' })
expect(rows).toEqual([{
placeId: 'plugin:all-the-places:ichiran-shibuya',
mainText: 'Ichiran Shibuya',
secondaryText: 'Jinnan 1-22-7, Shibuya',
source: 'plugin:all-the-places',
lat: 35.6617,
lng: 139.6993,
place: hit,
}])
})
it('FE-REPO-PLUGINSEARCH-002: sends no bias when the trip has nowhere to point to', async () => {
let url: URL | null = null
server.use(http.get('/api/plugin-search/suggest', ({ request }) => {
url = new URL(request.url)
return HttpResponse.json({ places: [] })
}))
await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)
expect(url!.searchParams.has('lat')).toBe(false)
expect(url!.searchParams.has('lng')).toBe(false)
})
it('FE-REPO-PLUGINSEARCH-003: offline it answers empty without making the request', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
const asked = vi.fn()
server.use(http.get('/api/plugin-search/suggest', () => { asked(); return HttpResponse.json({ places: [hit] }) }))
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
expect(asked).not.toHaveBeenCalled()
})
it('FE-REPO-PLUGINSEARCH-004: an answer off the contract is dropped whole, not drawn', async () => {
server.use(http.get('/api/plugin-search/suggest', () =>
HttpResponse.json({ places: [{ ...hit, osm_id: 'node:1' }] })))
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
server.use(http.get('/api/plugin-search/suggest', () =>
HttpResponse.json({ places: [hit, hit, hit, hit] })))
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
})
it('FE-REPO-PLUGINSEARCH-005: a failing route or an aborted keystroke is an empty list, never a rejection', async () => {
server.use(http.get('/api/plugin-search/suggest', () => HttpResponse.json({ error: 'boom' }, { status: 500 })))
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
server.use(http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [hit] })))
const superseded = new AbortController()
superseded.abort()
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, superseded.signal)).toEqual([])
})
it('FE-REPO-PLUGINSEARCH-006: a plugin that misses the deadline costs its rows, not the list', async () => {
server.use(http.get('/api/plugin-search/suggest', async () => {
await delay(PLUGIN_SUGGEST_DEADLINE_MS + 500)
return HttpResponse.json({ places: [hit] })
}))
const started = Date.now()
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
expect(Date.now() - started).toBeLessThan(PLUGIN_SUGGEST_DEADLINE_MS + 400)
})
})