Files
TREK/client/src/components/Roadtrip/useRefuelSearch.test.ts
T

300 lines
13 KiB
TypeScript

import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { haversineKm, pointAtMeters, type Bbox, type LatLng } from './corridor'
const { pois, offline, stored } = vi.hoisted(() => ({
pois: vi.fn(),
offline: vi.fn(),
stored: { settings: {} as { roadtrip_vehicle?: string } },
}))
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: offline }))
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (settings: typeof stored.settings) => unknown) => select(stored.settings),
}))
import { useRefuelSearch } from './useRefuelSearch'
/**
* A hundred kilometres due north. A straight north-south drive makes "before the dry
* point" a plain statement about latitude, so the direction the search looked in can be
* read off the box the request carried.
*/
const LINE: LatLng[] = [{ lat: 53, lng: 10 }, { lat: 53.9, lng: 10 }]
const DRY_KM = 60
const DRY = pointAtMeters(LINE, DRY_KM * 1000)!
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
osm_id: id,
name: id,
lat,
lng,
category,
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
})
/**
* A station `alongKm` into the day, `eastKm` to the side of the road.
*
* The side step is a rough degree conversion rather than the projection the hook uses:
* nothing here depends on the exact metre, only on a larger `eastKm` really sitting
* further from the line, which is what orders the offers.
*/
const station = (id: string, alongKm: number, eastKm = 0.5) => {
const on = pointAtMeters(LINE, alongKm * 1000)!
return hit(id, on.lat, on.lng + eastKm / 66)
}
const answer = (items: ReturnType<typeof hit>[], over: { truncated?: boolean; clamped?: boolean } = {}) =>
({ pois: items, source: 'openstreetmap', truncated: false, clamped: false, ...over })
const boxOf = (call: unknown[]) => call[1] as Bbox
const signalOf = (call: unknown[]) => call[3] as AbortSignal
/** A request left hanging, so a second one can overtake it. */
function held() {
let settle!: (value: ReturnType<typeof answer>) => void
const promise = new Promise<ReturnType<typeof answer>>(resolve => { settle = resolve })
return { promise, settle }
}
beforeEach(() => {
pois.mockReset().mockResolvedValue(answer([]))
offline.mockReset().mockReturnValue(false)
stored.settings = {}
})
describe('useRefuelSearch', () => {
it('FE-REFUELSEARCH-001: offline it says so rather than offering a stop that cannot be kept', async () => {
// Accepting an offer writes a place and assigns it to a day; only the first of those
// queues offline, so anything accepted here would end up an orphan. Saying "could not
// look" is the honest answer, and it must not cost a request either.
offline.mockReturnValue(true)
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois).not.toHaveBeenCalled()
expect(result.current.outcome).toBe('failed')
expect(result.current.loading).toBe(false)
// The band still opens on the point that was asked about, or there is nowhere to
// print the answer.
expect(result.current.openFor).toBe('4:1')
})
it('FE-REFUELSEARCH-002: somebody who drives a petrol car is not sent to a charger', async () => {
stored.settings = { roadtrip_vehicle: 'combustion' }
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[0][0]).toBe('fuel')
// The names come back in the traveller's language, so the rail is not half German.
expect(pois.mock.calls[0][2]).toBe('en-US')
})
it('FE-REFUELSEARCH-003: an electric car is not sent to a petrol pump', async () => {
stored.settings = { roadtrip_vehicle: 'electric' }
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[0][0]).toBe('charging')
})
it('FE-REFUELSEARCH-004: with nothing said about the car both kinds are looked for', async () => {
const { result, rerender } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[0][0]).toBe('fuel,charging')
// And a value this hook has never heard of falls back to both rather than narrowing
// the search to nothing: an empty category list would read on screen as "there is no
// filling station on this road", which is a statement about the road.
stored.settings = { roadtrip_vehicle: 'hovercraft' }
rerender()
await act(async () => { await result.current.ask('4:2', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[1][0]).toBe('fuel,charging')
})
it('FE-REFUELSEARCH-005: it looks at the road already driven, not around the dry point', async () => {
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
const box = boxOf(pois.mock.calls[0])
const centre = { lat: (box.south + box.north) / 2, lng: (box.west + box.east) / 2 }
// South is backwards along this drive. Centring on the dry point itself would spend
// half the circle on road that lies beyond an empty tank.
expect(centre.lat).toBeLessThan(DRY.lat)
expect(haversineKm(centre, DRY)).toBeGreaterThan(11)
expect(haversineKm(centre, DRY)).toBeLessThan(13)
// Pulled back, but not so far that the last kilometres before the tank empties fall
// outside the box: a pump two kilometres short of the dry point is the best answer
// there is.
expect(box.north).toBeGreaterThan(DRY.lat)
})
it('FE-REFUELSEARCH-006: a second question abandons the answer to the first', async () => {
const first = held()
pois.mockImplementationOnce(() => first.promise)
const { result } = renderHook(() => useRefuelSearch())
let pending!: Promise<void>
act(() => { pending = result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
pois.mockResolvedValueOnce(answer([station('second', 30)]))
await act(async () => { await result.current.ask('5:0', DRY, LINE, DRY_KM, []) })
// The abandoned request answers late, the way a slow one does. Landing it now would
// put day four's stations under day five's heading, with day five's accept button
// under them.
await act(async () => { first.settle(answer([station('first', 45)])); await pending })
expect(signalOf(pois.mock.calls[0]).aborted).toBe(true)
expect(result.current.results.map(p => p.osm_id)).toEqual(['second'])
expect(result.current.openFor).toBe('5:0')
expect(result.current.loading).toBe(false)
})
it('FE-REFUELSEARCH-007: asking again empties the rail before the new answer arrives', async () => {
pois.mockResolvedValueOnce(answer([station('old', 40)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.results).toHaveLength(1)
pois.mockImplementationOnce(() => held().promise)
act(() => { void result.current.ask('5:0', DRY, LINE, DRY_KM, []) })
// Left standing, the first search's stations sit under the new heading for as long as
// the second one takes, and accepting one would file it against the wrong dry point.
expect(result.current.results).toEqual([])
expect(result.current.offered).toEqual([])
expect(result.current.outcome).toBeNull()
expect(result.current.loading).toBe(true)
})
it('FE-REFUELSEARCH-008: a request that fails is a failure, not an empty road', async () => {
pois.mockRejectedValue(new Error('502'))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.outcome).toBe('failed')
// Left spinning, the band offers no way to ask again.
expect(result.current.loading).toBe(false)
})
it('FE-REFUELSEARCH-009: stations the car cannot reach are not a find', async () => {
// Both come back from the server and both are real filling stations; both sit past
// the point the tank empties, so neither answers the question that was asked.
pois.mockResolvedValue(answer([station('just-past', 58), station('well-past', 70)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.results).toEqual([])
expect(result.current.outcome).toBe('none')
})
it('FE-REFUELSEARCH-010: a search the server cut short is not reported as an empty road', async () => {
pois.mockResolvedValue(answer([], { truncated: true }))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
// "There is nothing here" and "we did not finish looking" send a traveller to two
// different places, and only the server knows which of them happened.
expect(result.current.outcome).toBe('incomplete')
})
it('FE-REFUELSEARCH-011: a pump already on the plan is not offered beside itself', async () => {
const planned = station('planned', 40)
pois.mockResolvedValue(answer([planned, station('new', 45, 1.5)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => {
await result.current.ask('4:1', DRY, LINE, DRY_KM, [{ lat: planned.lat, lng: planned.lng }])
})
expect(result.current.results.map(p => p.osm_id)).toEqual(['new'])
})
it('FE-REFUELSEARCH-012: three go on the map, and they are the three the rail lists', async () => {
// Ordered by the detour, so the map has to draw the first three of that same list
// rather than any three: a pin the rail does not name is a stop nobody can accept.
pois.mockResolvedValue(answer([
station('far', 30, 2.5),
station('nearest', 44, 0.2),
station('third', 36, 1.5),
station('fourth', 32, 2),
station('second', 40, 0.8),
]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.results).toHaveLength(5)
expect(result.current.offered.map(p => p.osm_id)).toEqual(['nearest', 'second', 'third'])
expect(result.current.offered).toEqual(result.current.results.slice(0, 3))
})
it('FE-REFUELSEARCH-013: with nothing open the offer list is the same array every time', async () => {
// The map rebuilds every pin when this reference changes, and the planner re-renders
// on anything from a drag to an incoming websocket event. A fresh [] per render is a
// map that flickers all day for a search nobody started.
const { result, rerender } = renderHook(() => useRefuelSearch())
const idle = result.current.offered
rerender()
expect(result.current.offered).toBe(idle)
pois.mockResolvedValue(answer([station('a', 40)]))
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
const open = result.current.offered
rerender()
expect(result.current.offered).toBe(open)
act(() => { result.current.close() })
expect(result.current.offered).toBe(idle)
})
it('FE-REFUELSEARCH-014: closing the band drops the answer and the request behind it', async () => {
pois.mockImplementationOnce(() => held().promise)
const { result } = renderHook(() => useRefuelSearch())
act(() => { void result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
act(() => { result.current.close() })
expect(signalOf(pois.mock.calls[0]).aborted).toBe(true)
expect(result.current.openFor).toBeNull()
expect(result.current.results).toEqual([])
expect(result.current.outcome).toBeNull()
expect(result.current.loading).toBe(false)
})
})
it('searches farther back when nearby chargers cannot be reached', async () => {
pois.mockResolvedValueOnce(answer([station('too-late', 58)]))
.mockResolvedValueOnce(answer([station('earlier', 25)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois).toHaveBeenCalledTimes(2)
expect(result.current.results.map(p => p.osm_id)).toEqual(['earlier'])
expect(result.current.outcome).toBe('found')
})
it('does not offer stations behind the last refuelling stop', async () => {
pois.mockResolvedValue(answer([station('behind', 20)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, [], 30) })
expect(result.current.results).toEqual([])
expect(pois).toHaveBeenCalledTimes(2)
})