diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index 41a798387..df5afdddd 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -8,7 +8,7 @@ import {
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
import type { RefuelSearch } from './useRefuelSearch'
-import type { RefuelCandidate } from './refuelSuggestion'
+import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from './refuelSuggestion'
import type { DryPoint } from './roadtripModel'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
@@ -445,9 +445,10 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
const { vehicleKind } = useVehicleRange()
const electric = vehicleKind === 'electric'
const DryIcon = electric ? Zap : Fuel
- const key = `${dayId}:${dry.legIndex}`
- const open = refuel.openFor === key
- const settled = open && !refuel.loading && refuel.outcome
+ const words = REFUEL_WORDS[electric ? 'electric' : 'fuel']
+ // Which control, which sentence and which offers: decided in refuelSuggestion so the
+ // phone band reads the same answer instead of keeping its own copy of the conditions.
+ const band = refuelBandState(refuel, dayId, dry.legIndex)
return (
@@ -490,7 +491,7 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
className="truncate font-geist font-semibold uppercase tracking-[0.16em] text-danger"
style={{ fontSize: FS.label }}
>
- {t(electric ? 'roadtrip.refuel.dryElectric' : 'roadtrip.refuel.dry')}
+ {t(words.dry)}
{/* How far INTO this leg, where a drive band keeps its figures. Not the
range that was crossed: that is the traveller's own setting, says
@@ -513,7 +514,7 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
found nothing leaves a button rather than a dead end — the place search
is a shared public service that does time out, and "it did not answer"
with no way to retry reads as broken rather than as busy. */}
- {open && (refuel.loading || refuel.results.length) ? (
+ {band.control === 'close' ? (
) : (
-
+
- {settled && refuel.outcome !== 'found'
+ {band.control === 'again'
?
: (
/* The glow stops under the pointer: a lamp that keeps blinking
@@ -553,12 +550,11 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
)}
- {open && refuel.loading ? (
+ {band.loading ? (
{t('roadtrip.refuel.looking')}
) : null}
- {settled ? (
- refuel.results.length ? (
+ {band.offers.length ? (
{/* Three at most. This is an offer beside a plan, not a list to browse;
the corridor panel is where somebody goes to see all of them.
@@ -569,7 +565,7 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
not enough to tell them apart — measured on a real day the top three
came back as "Vattenfall InCharge" three times over, identical but for
a number nobody could see the meaning of. */}
- {refuel.results.slice(0, 3).map(poi => {
+ {band.offers.map(poi => {
const kind = STOP_KIND_BY_KEY[poi.category]
const KindIcon = kind?.Icon ?? Fuel
return (
@@ -632,11 +628,11 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
{onAccept ? (
-
+
onAccept(poi)}
- aria-label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}
+ aria-label={t(words.add, { name: poi.name })}
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-lg bg-surface-secondary text-content-secondary transition-colors hover:bg-accent hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
@@ -648,18 +644,15 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
)
})}
- ) : (
- /* Three different sentences for three different facts. "Nothing on this
- stretch" after a request that failed or was cut short states something
- that was never checked, which is worse than saying nothing. */
-
- {refuel.outcome === 'none'
- ? t('roadtrip.refuel.none')
- : refuel.outcome === 'incomplete'
- ? t('roadtrip.refuel.incomplete')
- : t('roadtrip.refuel.failed')}
-
- )
+ ) : null}
+
+ {/* Three different sentences for three different facts. "Nothing on this
+ stretch" after a request that failed or was cut short states something
+ that was never checked, which is worse than saying nothing. */}
+ {band.empty ? (
+
+ {t(REFUEL_EMPTY_KEY[band.empty])}
+
) : null}
diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts
index 886518769..d4a7769d8 100644
--- a/client/src/components/Roadtrip/refuelSuggestion.test.ts
+++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest'
-import { reachableRefuels, outcomeOf } from './refuelSuggestion'
+import {
+ reachableRefuels, outcomeOf, refuelBandState, refuelKey, REFUEL_EMPTY_KEY, REFUEL_OFFER_LIMIT, REFUEL_WORDS,
+ type RefuelCandidate, type RefuelOutcome, type RefuelSearchView,
+} from './refuelSuggestion'
import type { LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
@@ -122,3 +125,67 @@ describe('reachableRefuels — one entry per station', () => {
expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
})
})
+
+describe('refuelBandState', () => {
+ const candidate = (name: string): RefuelCandidate =>
+ ({ ...poi(name, 52, 13.5), alongKm: 40, offRouteKm: 0.4, spareKm: 30 })
+ const search = (over: Partial = {}): RefuelSearchView =>
+ ({ openFor: null, loading: false, outcome: null, results: [], ...over })
+
+ it('FE-ROADTRIP-REFUEL-013: a search filed under another leg leaves this band alone', () => {
+ // One search is open at a time and it names the dry point it belongs to, so a band
+ // three legs down the chain must not light up for a question asked about this one.
+ expect(refuelKey(7, 1)).toBe('7:1')
+ const band = refuelBandState(search({ openFor: '7:4', outcome: 'found', results: [candidate('a')] }), 7, 1)
+ expect(band).toEqual({ open: false, loading: false, offers: [], empty: null, control: 'find' })
+ })
+
+ it('FE-ROADTRIP-REFUEL-014: while the request runs the lamp steps aside for the way out', () => {
+ const band = refuelBandState(search({ openFor: '7:1', loading: true }), 7, 1)
+ expect(band).toEqual({ open: true, loading: true, offers: [], empty: null, control: 'close' })
+ })
+
+ it('FE-ROADTRIP-REFUEL-015: an answer lists three at most, in the order the search ranked them', () => {
+ const results = ['a', 'b', 'c', 'd', 'e'].map(candidate)
+ const band = refuelBandState(search({ openFor: '7:1', outcome: 'found', results }), 7, 1)
+ expect(REFUEL_OFFER_LIMIT).toBe(3)
+ expect(band.offers.map(o => o.name)).toEqual(['a', 'b', 'c'])
+ expect(band.control).toBe('close')
+ expect(band.empty).toBeNull()
+ })
+
+ it.each>(['none', 'incomplete', 'failed'])(
+ 'FE-ROADTRIP-REFUEL-016: an empty answer (%s) says which nothing it was and leaves a retry',
+ outcome => {
+ // A retry rather than a dead end: the place search is a shared service that does
+ // time out, and no answer with no way to ask again reads as broken.
+ const band = refuelBandState(search({ openFor: '7:1', outcome }), 7, 1)
+ expect(band).toEqual({ open: true, loading: false, offers: [], empty: outcome, control: 'again' })
+ expect(REFUEL_EMPTY_KEY[outcome]).toBe(`roadtrip.refuel.${outcome}`)
+ },
+ )
+
+ it('FE-ROADTRIP-REFUEL-017: asked but nothing back yet keeps the lamp and says nothing', () => {
+ const band = refuelBandState(search({ openFor: '7:1' }), 7, 1)
+ expect(band).toEqual({ open: true, loading: false, offers: [], empty: null, control: 'find' })
+ })
+
+ it('FE-ROADTRIP-REFUEL-018: the battery never borrows the tank\'s words', () => {
+ expect(REFUEL_WORDS.fuel).toEqual({
+ dry: 'roadtrip.refuel.dry', find: 'roadtrip.refuel.find', add: 'roadtrip.refuel.add',
+ })
+ expect(REFUEL_WORDS.electric).toEqual({
+ dry: 'roadtrip.refuel.dryElectric', find: 'roadtrip.refuel.findElectric', add: 'roadtrip.refuel.addElectric',
+ })
+ })
+
+ it('FE-ROADTRIP-REFUEL-019: results that arrive before their outcome already offer the way out', () => {
+ // The close control follows the results rather than the outcome, which is how the
+ // desktop rail always decided it: once there is something on the map to dismiss,
+ // the band offers to dismiss it.
+ const band = refuelBandState(search({ openFor: '7:1', results: [candidate('a')] }), 7, 1)
+ expect(band.control).toBe('close')
+ expect(band.offers).toEqual([])
+ expect(band.empty).toBeNull()
+ })
+})
diff --git a/client/src/components/Roadtrip/refuelSuggestion.ts b/client/src/components/Roadtrip/refuelSuggestion.ts
index 9daf24b80..0a9d548e6 100644
--- a/client/src/components/Roadtrip/refuelSuggestion.ts
+++ b/client/src/components/Roadtrip/refuelSuggestion.ts
@@ -121,3 +121,103 @@ function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
}
return kept
}
+
+/**
+ * How many answers a band lists, on the desktop rail and on the phone alike.
+ *
+ * Three at most. This is an offer beside a plan, not a list to browse; the corridor
+ * search is where somebody goes to see all of them. Named once so the two bands can never
+ * disagree about what "the offers" are.
+ */
+export const REFUEL_OFFER_LIMIT = 3
+
+/**
+ * The name a search is filed under: the card day the band is drawn on and the leg.
+ *
+ * One search is open at a time and it names the dry point it belongs to, so a band three
+ * legs down the chain does not light up for a question asked about this one. The card
+ * day rather than the day a borrowed stop is stored on, because the answer has to arrive
+ * under a key the band that asked is watching.
+ */
+export function refuelKey(dayId: number, legIndex: number): string {
+ return `${dayId}:${legIndex}`
+}
+
+/** What sits in the band's trailing slot: the lamp that goes looking, a retry, or a close. */
+export type RefuelControl = 'find' | 'again' | 'close'
+
+/**
+ * The part of the search a band reads.
+ *
+ * Structural rather than the hook's own type, because useRefuelSearch.ts imports this
+ * module and the pure half should not have to know about the half with the network in it.
+ */
+export interface RefuelSearchView {
+ openFor: string | null
+ loading: boolean
+ outcome: RefuelOutcome | null
+ results: readonly RefuelCandidate[]
+}
+
+/** Everything a band shows that is not markup, decided once for both shells. */
+export interface RefuelBandState {
+ /** The open search belongs to this band. */
+ open: boolean
+ /** This band's question is out and has not come back yet. */
+ loading: boolean
+ /** The answers to list, already capped at REFUEL_OFFER_LIMIT; empty until there are some. */
+ offers: RefuelCandidate[]
+ /** Which kind of nothing came back, or null while there is no empty answer to report. */
+ empty: Exclude | null
+ /** What fills the trailing slot; `refuelBandState` says why each one wins when it does. */
+ control: RefuelControl
+}
+
+/**
+ * The state of one dry band, given the one search that may be open.
+ *
+ * The lamp steps aside while a request runs or offers are showing, for the way to close
+ * the answer: every press is a real request against a shared service, and a lamp still
+ * standing there invites a second one nobody asked for. An answer that found nothing
+ * leaves a retry rather than a dead end, because the place search is a public service
+ * that does time out, and "it did not answer" with no way to ask again reads as broken
+ * rather than as busy.
+ *
+ * The empty answer keeps three different sentences for three different facts. "Nothing
+ * on this stretch" after a request that failed or was cut short states something that was
+ * never checked, which is worse than saying nothing, so anything that is not a plain
+ * `none` or `incomplete` reads as `failed`.
+ */
+export function refuelBandState(search: RefuelSearchView, dayId: number, legIndex: number): RefuelBandState {
+ const open = search.openFor === refuelKey(dayId, legIndex)
+ const loading = open && search.loading
+ const settled = open && !search.loading && search.outcome != null
+ const offers = settled && search.results.length > 0 ? search.results.slice(0, REFUEL_OFFER_LIMIT) : []
+ let empty: RefuelBandState['empty'] = null
+ if (settled && offers.length === 0) {
+ empty = search.outcome === 'none' || search.outcome === 'incomplete' ? search.outcome : 'failed'
+ }
+ let control: RefuelControl = 'find'
+ if (loading || (open && search.results.length > 0)) control = 'close'
+ else if (settled && search.outcome !== 'found') control = 'again'
+ return { open, loading, offers, empty, control }
+}
+
+/** Which sentence each kind of empty answer gets. */
+export const REFUEL_EMPTY_KEY: Record, string> = {
+ none: 'roadtrip.refuel.none',
+ incomplete: 'roadtrip.refuel.incomplete',
+ failed: 'roadtrip.refuel.failed',
+}
+
+/**
+ * The band's words for a tank and for a battery.
+ *
+ * An electric car does not run out of TANK, and a battery band that talks about fuel is
+ * the kind of detail that makes the rest look careless. Kept as one table so the title,
+ * the lamp's label and the offer's label never mix the two vocabularies.
+ */
+export const REFUEL_WORDS = {
+ fuel: { dry: 'roadtrip.refuel.dry', find: 'roadtrip.refuel.find', add: 'roadtrip.refuel.add' },
+ electric: { dry: 'roadtrip.refuel.dryElectric', find: 'roadtrip.refuel.findElectric', add: 'roadtrip.refuel.addElectric' },
+} as const
diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
index acb75f371..177a1b266 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
@@ -1,11 +1,13 @@
-import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, Search, Sunrise, Zap } from 'lucide-react'
-import type { ReactNode } from 'react'
+import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Sunrise, X, Zap } from 'lucide-react'
+import type { CSSProperties, ReactNode } from 'react'
+import MDancingTrek from '../../../components/MDancingTrek'
+import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units'
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
-import type { RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
+import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../types'
@@ -169,11 +171,51 @@ export function RtLegRow({ seg, mode, chrome }: {
}
/**
- * The one loud row of the chain: the tank runs out here.
+ * The ground the dry band stands on, where the mascot is.
*
- * Full width and a 44px button, where the desktop has a 22px reserve light. This is
- * the single situation on a drive that forces an action, so it gets the only shout
- * on the screen.
+ * Opaque on purpose: the mascot cuts its eyes and wheel hubs out in `--m-bg`, and
+ * `--m-card` is 55% alpha in both themes, so on the card's own ground the red body would
+ * show through the eyes. `--m-sheetop` is the phone's opaque surface. Mixed against that
+ * rather than against `--m-bg`, because the mascot's span renames `--m-bg` to this value
+ * and a variable defined in terms of itself is invalid, which would drop the cutouts.
+ */
+const DRY_GROUND = 'color-mix(in srgb, var(--m-st-danger) 11%, var(--m-sheetop))'
+
+/**
+ * The lamp's circle: a soft wash of the band's own colour rather than the black action
+ * fill. The lamp is a warning light that happens to be pressable, and the 44px circle is
+ * what tells a thumb so without turning it into the loudest thing on the screen.
+ */
+const DANGER_SOFT: CSSProperties = {
+ background: 'color-mix(in srgb, var(--m-st-danger) 14%, transparent)',
+ color: 'var(--m-st-danger)',
+}
+
+/**
+ * Where the tank runs out on this leg, and the reserve lamp that goes looking.
+ *
+ * The desktop band's design, sized for a thumb: the mascot on its skateboard and not
+ * enjoying it, a small uppercase warning, and the lamp itself as the button. Not a pill
+ * with the words on it, because a labelled pill beside a mascot and a title leaves the
+ * title almost no room: the band's content box is only about
+ * 299px wide at 375px and 284px at 360px, and a label like "Cerca un punt de recàrrega"
+ * is a long way into that. So the lamp is the button, as on the desktop, and its words
+ * live in the label a screen reader announces.
+ *
+ * 44px where the desktop lamp is 22px, because it is aimed with a thumb. The title clamps
+ * to two lines instead of truncating, because a warning that loses its end says nothing
+ * ("Qui il serbatoio si esaurisce" does not fit on one). Offline the lamp stops glowing,
+ * because a lamp that glows invites a press that cannot work. Somebody who may not edit
+ * the day still sees the lamp, as a plain mark: the empty tank is information, not an
+ * action.
+ *
+ * While a search runs or its offers show, the lamp steps aside for a close, and an empty
+ * answer leaves a retry in its place. The close matters because the map draws the offers,
+ * and without it nothing in the band could clear those pins short of accepting one. It
+ * stays while offline too, and the offline sentence stands down with it: closing is local,
+ * so there is no dead control left to explain. Which control shows, which offers and which
+ * empty sentence is decided in `refuelBandState`, the same place the desktop band reads
+ * it from.
*/
export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refuel, dayId, legIndex, onAccept }: {
intoLegKm: number
@@ -188,56 +230,79 @@ export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refue
onAccept?: (poi: RefuelCandidate) => void
}) {
const { t, unit } = chrome
- // One search is open at a time and it names the dry point it belongs to, so a band
- // three legs down the chain does not light up for a question asked about this one.
- const open = refuel.openFor === `${dayId}:${legIndex}`
- const settled = open && !refuel.loading && refuel.outcome
+ const band = refuelBandState(refuel, dayId, legIndex)
+ const words = REFUEL_WORDS[electric ? 'electric' : 'fuel']
+ const Lamp = electric ? Zap : Fuel
return (
-
- {electric ? : }
-
- {electric ? t('roadtrip.refuel.dryElectric') : t('roadtrip.refuel.dry')}
+
+ {/* Renamed on this span only, never on the band: the offers below are drawn in
+ --m-ink too, and they have to stay ink. Not a tap target either, because
+ nothing in the chain but a destination row reacts to a tap. */}
+
+ {/* Out of fuel is a thing that happens to the drive, so it is the mascot with
+ the vehicle, and it is not enjoying it. */}
+
-
-
- {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(intoLegKm), unit) })}
-
- {onSearch && (
- <>
+
+
+ {t(words.dry)}
+
+
+ {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(intoLegKm), unit) })}
+
+
+ {band.control === 'close' ? (
+
+
+
+ ) : onSearch ? (
-
- {electric ? t('roadtrip.refuel.findElectric') : t('roadtrip.refuel.find')}
+ {band.control === 'again'
+ ?
+ : }
- {/* Blunt rather than greyed out: the button keeps its shape and says why. */}
- {offline && (
- {t('mobileTrip.rtSearchOffline')}
- )}
- >
+ ) : (
+
+
+
+ )}
+
+
+ {/* Blunt rather than silent: the lamp keeps its place and this says why it is out. */}
+ {onSearch && offline && band.control !== 'close' && (
+ {t('mobileTrip.rtSearchOffline')}
)}
- {open && refuel.loading && (
+ {band.loading && (
{t('roadtrip.refuel.looking')}
)}
{/* The answer, in the band that asked. The desktop puts it in the rail row for the
same reason: a station offered somewhere other than where the tank runs out is
- an offer somebody has to go and find. Three at most: this is an offer beside a
- plan, and the search sheet above is where all of them live. */}
- {settled && (refuel.results.length ? (
+ an offer somebody has to go and find. */}
+ {band.offers.length > 0 && (
- {refuel.results.slice(0, 3).map(poi => (
+ {band.offers.map(poi => (
))}
- ) : (
- /* Three sentences for three different facts. "Nothing on this stretch" after a
- request that failed states something that was never checked. */
-
- {refuel.outcome === 'none'
- ? t('roadtrip.refuel.none')
- : refuel.outcome === 'incomplete'
- ? t('roadtrip.refuel.incomplete')
- : t('roadtrip.refuel.failed')}
-
- ))}
+ )}
+
+ {/* Three sentences for three different facts. "Nothing on this stretch" after a
+ request that failed states something that was never checked. */}
+ {band.empty && (
+ {t(REFUEL_EMPTY_KEY[band.empty])}
+ )}
)
}
@@ -303,7 +364,7 @@ function RefuelOffer({ poi, chrome, electric, onAccept }: {
diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
index c211d91e3..08cbd1197 100644
--- a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
+++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
@@ -9,7 +9,7 @@ import type { TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
-// FE-MOB-RTROW-001 to FE-MOB-RTROW-030
+// FE-MOB-RTROW-001 to FE-MOB-RTROW-037
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
@@ -222,11 +222,12 @@ describe('RtDryRow', () => {
const props = { intoLegKm: 82, chrome, electric: false, offline: false, refuel: idle, dayId: 7, legIndex: 1 }
it('FE-MOB-RTROW-018: names the fuel wording and the distance into the leg for a combustion car', () => {
- render( )
+ const { container } = render( )
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
expect(screen.getByText('roadtrip.refuel.after:82 km')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'roadtrip.refuel.find' })).toBeInTheDocument()
+ expect(container.querySelector('svg.trek--transport')).not.toBeNull()
})
it('FE-MOB-RTROW-019: swaps to the charging wording for an electric car', () => {
@@ -252,18 +253,23 @@ describe('RtDryRow', () => {
const button = screen.getByRole('button', { name: 'roadtrip.refuel.find' })
expect(button).toBeDisabled()
expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
+ // The lamp stops glowing: a lamp that glows invites a press that cannot work.
+ expect(button.querySelector('svg.lucide-fuel')).not.toBeNull()
+ expect(button.querySelector('.trek-lowfuel')).toBeNull()
fireEvent.click(button)
expect(onSearch).not.toHaveBeenCalled()
})
it('FE-MOB-RTROW-022: drops the button entirely without write permission, offline line included', () => {
- render( )
+ const { container } = render( )
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByText('mobileTrip.rtSearchOffline')).toBeNull()
- // The warning itself stays: it is information, not an action.
+ // The warning itself stays: it is information, not an action. The reserve lamp too,
+ // as a mark rather than a control.
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
+ expect(container.querySelector('.trek-lowfuel')).not.toBeNull()
})
const offer = (over: Record = {}) => ({
osm_id: 'n1', name: 'Shell Ebina', lat: 35.44, lng: 139.39, category: 'fuel',
@@ -339,6 +345,110 @@ describe('RtDryRow', () => {
expect(screen.getByText('roadtrip.refuel.failed')).toBeInTheDocument()
expect(screen.queryByText('roadtrip.refuel.none')).toBeNull()
})
+
+ it('FE-MOB-RTROW-032: the mascot rides the skateboard, sad, in the band colour and on an opaque ground', () => {
+ const { container } = render( )
+
+ const mascot = container.querySelector('svg.trek--transport') as SVGElement
+ // Expression eyes, not the default open ones, bent down rather than up.
+ expect(mascot.querySelectorAll('.trek-eye')).toHaveLength(0)
+ const eyes = mascot.querySelectorAll('.trek-body g[stroke] path')
+ expect(eyes).toHaveLength(2)
+ const [, startY, controlY] = /^M[\d.]+ ([\d.]+) Q[\d.]+ ([\d.]+)/.exec(eyes[0].getAttribute('d') ?? '') ?? []
+ expect(Number(controlY)).toBeGreaterThan(Number(startY))
+
+ // Renamed on the mascot's own span, against an opaque surface so the cut out eyes
+ // do not show the body through them.
+ const wrapper = mascot.parentElement as HTMLElement
+ expect(wrapper.style.getPropertyValue('--m-ink')).toBe('var(--m-st-danger)')
+ expect(wrapper.style.getPropertyValue('--m-bg')).toContain('--m-sheetop')
+ // And never on the band, where the offers read --m-ink and have to stay ink.
+ expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-ink')).toBe('')
+ })
+
+ it('FE-MOB-RTROW-033: the reserve lamp is the button, with its words in the label only', () => {
+ const fuel = render( )
+
+ const lamp = screen.getByRole('button', { name: 'roadtrip.refuel.find' })
+ expect(lamp.querySelector('svg.lucide-fuel.trek-lowfuel')).not.toBeNull()
+ // Not the black pill it replaced, and no visible label beside the mascot and title.
+ expect(lamp.className).not.toContain('bg-m-act')
+ expect(screen.queryByText('roadtrip.refuel.find')).toBeNull()
+ fuel.unmount()
+
+ render( )
+ expect(screen.getByRole('button', { name: 'roadtrip.refuel.findElectric' }).querySelector('svg.lucide-zap')).not.toBeNull()
+ })
+
+ it('FE-MOB-RTROW-034: while the search runs or offers show, the lamp steps aside for a close', () => {
+ // The map draws the offers, and the band is where somebody lets those pins go.
+ const close = vi.fn()
+ const running = render( )
+
+ expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
+ expect(close).toHaveBeenCalledTimes(1)
+ running.unmount()
+
+ const found = render( )
+ expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
+ expect(close).toHaveBeenCalledTimes(2)
+ found.unmount()
+
+ // Offline the close still works, because closing is local, and with no dead control
+ // in the slot the sentence explaining one stands down.
+ render( )
+ const closeOffline = screen.getByRole('button', { name: 'common.close' })
+ expect(closeOffline).toBeEnabled()
+ expect(screen.queryByText('mobileTrip.rtSearchOffline')).toBeNull()
+ fireEvent.click(closeOffline)
+ expect(close).toHaveBeenCalledTimes(3)
+ })
+
+ it('FE-MOB-RTROW-035: an empty answer leaves a retry rather than a dead end, and offline it waits', () => {
+ const onSearch = vi.fn()
+ const failed = render( )
+
+ expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.again' }))
+ expect(onSearch).toHaveBeenCalledTimes(1)
+ failed.unmount()
+
+ const none = render( )
+ expect(screen.getByRole('button', { name: 'roadtrip.refuel.again' })).toBeInTheDocument()
+ none.unmount()
+
+ render( )
+ const again = screen.getByRole('button', { name: 'roadtrip.refuel.again' })
+ expect(again).toBeDisabled()
+ fireEvent.click(again)
+ expect(onSearch).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTROW-036: lists three offers at most, however many came back', () => {
+ const results = ['n1', 'n2', 'n3', 'n4'].map((id, i) => offer({ osm_id: id, name: `Station ${i + 1}` }))
+ const { container } = render( )
+
+ expect(container.querySelectorAll('li')).toHaveLength(3)
+ expect(screen.queryByText('Station 4')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-037: the battery wording carries through to taking an offer', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'roadtrip.refuel.addElectric:Shell Ebina' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' })).toBeNull()
+ })
})
describe('RtAutoRow', () => {