mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked for it while still deciding. The availability and the published tariffs were reachable only for a saved place: the route takes a places id, and a hit found along the route has no row yet. ChargingService.load already needed nothing but a coordinate and a name, so it now has a public method that says so, behind a trip-scoped route with the same guard stack, a Zod contract in shared, and the parallel MCP tool in this same change. Same service, same caches, so a station looked at in the dialog and then added keys the entry already fetched. Trip-scoped even though the trip is not part of the question: without :tripId the trip access guard goes too, and what is left is an open proxy in front of somebody else's public API. It renders in the add dialog, and only while the selected kind is charging, so a fuel or food stop costs nothing and one opened dialog is one lookup. Deliberately not per result row: the corridor search publishes box by box, every mounted panel owns a refresh timer, a cache miss can cost seven upstream requests, and a station in a dense charging park is the case the upstream matcher refuses as ambiguous. Power and socket counts are not in this answer and were not asked of it. They are OSM tag data and already ride along on the corridor row for free.
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { chargingRepo } from '../../repo/chargingRepo'
|
||||
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn() } }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } }))
|
||||
const info = { checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Station', source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, tariffs: [], pricesUnavailable: false }
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
describe('Charging information', () => {
|
||||
it('shows availability, attribution and missing prices without inventing a price', async () => {
|
||||
vi.mocked(chargingRepo.read).mockResolvedValue(info)
|
||||
@@ -23,4 +24,49 @@ describe('Charging information', () => {
|
||||
expect(await screen.findByText('Status outdated')).toBeInTheDocument()
|
||||
expect(screen.queryByText('2/4')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// A station found along the route has no place row yet, and the availability and the
|
||||
// price are what somebody wants before deciding to add it.
|
||||
it('FE-ROADTRIP-CHARGING-010: a station that is not a stop yet is asked for by coordinate', async () => {
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(info)
|
||||
render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
|
||||
|
||||
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
|
||||
expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord')
|
||||
expect(chargingRepo.read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGING-011: pointed at another station, it asks again', async () => {
|
||||
// The coordinate is the identity here. Left out of the effect's dependencies the
|
||||
// panel would keep showing the first charger's availability for the second one.
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(info)
|
||||
const { rerender } = render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
|
||||
await screen.findByText('2/4 available')
|
||||
|
||||
rerender(<TranslationProvider><ChargingInfo lat={49.013} lng={12.101} name="Autohof Sued" /></TranslationProvider>)
|
||||
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenLastCalledWith(1, 49.013, 12.101, 'Autohof Sued'))
|
||||
expect(chargingRepo.readAt).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGING-012: a saved stop is still asked for by place id', async () => {
|
||||
vi.mocked(chargingRepo.read).mockResolvedValue(info)
|
||||
render(<TranslationProvider><ChargingInfo placeId={7} compact /></TranslationProvider>)
|
||||
|
||||
expect(await screen.findByText('2/4')).toBeInTheDocument()
|
||||
expect(chargingRepo.read).toHaveBeenCalledWith(1, 7)
|
||||
expect(chargingRepo.readAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGING-013: an unidentified station is never shown as free', async () => {
|
||||
// `available` is null whenever the answer is not both fresh and countable, and an
|
||||
// unknown status is not spare capacity. Outside the covered regions this is the
|
||||
// usual answer, so it is the one the dialog has to get right.
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue({ ...info, status: 'ambiguous', available: null, total: 0, station: null })
|
||||
render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
|
||||
|
||||
// Said twice over: once where the free count would be, once where the price would.
|
||||
expect(await screen.findAllByText('No reliable data available')).toHaveLength(2)
|
||||
expect(screen.queryByText(/\d+\/\d+/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,20 @@ import { chargingRepo } from '../../repo/chargingRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
export default function ChargingInfo({ placeId, compact = false }: { placeId: number; compact?: boolean }) {
|
||||
/**
|
||||
* Which station the panel is asking about, one way or the other.
|
||||
*
|
||||
* A stop on the trip is named by its `places` row. A station found along the route has
|
||||
* no row yet, and the availability and the price are exactly what somebody wants before
|
||||
* deciding to add it, so that one is named by where it is instead. The two are mutually
|
||||
* exclusive: the optional `undefined` members are what stop a caller passing both and
|
||||
* leaving it to the component to guess which it meant.
|
||||
*/
|
||||
type ChargingTarget =
|
||||
| { placeId: number; lat?: undefined; lng?: undefined; name?: undefined }
|
||||
| { placeId?: undefined; lat: number; lng: number; name: string }
|
||||
|
||||
export default function ChargingInfo({ placeId, lat, lng, name, compact = false }: ChargingTarget & { compact?: boolean }) {
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const { t } = useTranslation()
|
||||
const [info, setInfo] = useState<Info | null>(null)
|
||||
@@ -17,7 +30,12 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu
|
||||
setInfo(null); setLoading(true)
|
||||
const refresh = async () => {
|
||||
if (!tripId || document.hidden) return
|
||||
try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) }
|
||||
try {
|
||||
const value = placeId != null
|
||||
? await chargingRepo.read(tripId, placeId)
|
||||
: await chargingRepo.readAt(tripId, lat!, lng!, name!)
|
||||
if (active) setInfo(value)
|
||||
}
|
||||
catch { if (active) setInfo(null) }
|
||||
finally { if (active) setLoading(false) }
|
||||
}
|
||||
@@ -28,7 +46,9 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu
|
||||
window.addEventListener('online', refresh)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) }
|
||||
}, [tripId, placeId])
|
||||
// The identity of the station, whichever way it was given: a changed coordinate is a
|
||||
// different charger and has to re-ask, exactly as a changed place id does.
|
||||
}, [tripId, placeId, lat, lng, name])
|
||||
const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000
|
||||
const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown')
|
||||
const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? []
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
|
||||
import { chargingRepo } from '../../repo/chargingRepo'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
|
||||
// The dialog carries the charging panel, which reads the open availability data for a
|
||||
// station that is not on the trip yet. Both of its collaborators are stubbed: the trip
|
||||
// is whichever one is open, and the data comes off the network.
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } }))
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const charging = {
|
||||
checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord',
|
||||
source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0',
|
||||
updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0,
|
||||
tariffs: [], pricesUnavailable: false,
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
|
||||
return {
|
||||
osm_id: 'node:9',
|
||||
@@ -116,15 +132,24 @@ describe('RoadtripStopPopup', () => {
|
||||
['sights', 'Sights'],
|
||||
]
|
||||
|
||||
/**
|
||||
* One kind pill, by the name on it.
|
||||
*
|
||||
* Filtered on `aria-pressed` rather than taken as the only match: for a charging hit
|
||||
* the dialog also carries the charging panel, whose `<summary>` reads "Charging" too.
|
||||
*/
|
||||
const kindPill = (label: string): HTMLElement =>
|
||||
screen.getAllByRole('button', { name: new RegExp(label) }).filter(el => el.hasAttribute('aria-pressed'))[0]
|
||||
|
||||
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(kindPill(label)).toHaveAttribute('aria-pressed', 'true')
|
||||
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
|
||||
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
|
||||
for (const [, other] of CORRIDOR_KINDS) {
|
||||
if (other === label) continue
|
||||
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(kindPill(other)).toHaveAttribute('aria-pressed', 'false')
|
||||
}
|
||||
unmount()
|
||||
})
|
||||
@@ -270,6 +295,56 @@ describe('RoadtripStopPopup', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Charging, before the add ───────────────────────────────────────────────
|
||||
//
|
||||
// The data was only reachable once the charger was on the trip, which is the wrong
|
||||
// way round: how busy the place is and what it charges is what decides whether to
|
||||
// add it at all.
|
||||
|
||||
const chargingDraft = () => draft({ poi: poi({ category: 'charging', name: 'Ladepark Nord', lat: 48.137, lng: 11.575 }) })
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-030: a charging hit is asked about by where it is', async () => {
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
|
||||
wrap(<RoadtripStopPopup draft={chargingDraft()} {...noop} />)
|
||||
|
||||
// By coordinate and name: there is no place row to name it by yet.
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord'))
|
||||
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
|
||||
// The licence line travels with the numbers wherever they are shown.
|
||||
expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-031: a fuel stop costs nothing', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
|
||||
expect(chargingRepo.readAt).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Published tariffs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-032: the question follows the kind that is picked', async () => {
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
expect(chargingRepo.readAt).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(kindPill('Charging'))
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.click(kindPill('Fuel'))
|
||||
expect(screen.queryByText('2/4 available')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-033: one station, one question, however much else is changed', async () => {
|
||||
// A cache miss upstream costs several requests against a shared public registry, so
|
||||
// the dialog asks once and re-rendering it for an unrelated answer must not re-ask.
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
|
||||
wrap(<RoadtripStopPopup draft={chargingDraft()} {...noop} />)
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
|
||||
|
||||
expect(chargingRepo.readAt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
|
||||
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
|
||||
// leads nowhere.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import StayPortals from './StayPortals'
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
@@ -266,6 +267,26 @@ export default function RoadtripStopPopup({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How busy the charging area is and what it charges, while the stop is still
|
||||
being considered rather than once it is on the trip. Only for the kind that
|
||||
has an answer: a petrol station or a bakery asks nothing, so picking either
|
||||
of those costs no request at all.
|
||||
|
||||
One panel for one station, for as long as the dialog is open. The corridor
|
||||
list deliberately does not do this per row: it publishes results box by
|
||||
box, each row would own its own refresh timer, and a station in a dense
|
||||
row of chargers is the case the upstream match refuses as ambiguous. */}
|
||||
{stopType === 'charging' ? (
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.charging.availability')}
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
<ChargingInfo lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user