Merge pull request #2372 from liketrek/fix/dawarich-tester-feedback

fix(dawarich): three things a tester on dev1 ran into
This commit is contained in:
Maurice
2026-09-14 23:09:33 +02:00
committed by GitHub
19 changed files with 703 additions and 37 deletions
+55 -2
View File
@@ -1,5 +1,6 @@
/**
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
* for how long the browser waits.
*
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
* goes into the body or the query string. There is no logic in it to get wrong,
@@ -30,7 +31,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { dawarichApi } from './dawarich'
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
import apiClient from './client'
import type {
DawarichAtlasSuggestions,
DawarichBucketScan,
@@ -404,3 +406,54 @@ describe('dawarichApi track overlay', () => {
expect(query().get('offset')).toBe('0')
})
})
describe('dawarichApi: how long the browser waits', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
// The server gives each request to Dawarich 15 s on its own. At the shared
// 8 s the browser gave up first, the server cached the answer anyway, and the
// recorded route turned up only after a reload.
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
await dawarichApi.tripTrack(7)
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
await dawarichApi.test({ url: 'https://dawarich.example' })
await dawarichApi.syncNow()
await dawarichApi.scanBucketList()
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
const configs = [
...get.mock.calls.map(call => call[1]),
...post.mock.calls.map(call => call[2]),
...put.mock.calls.map(call => call[2]),
]
expect(configs).toHaveLength(6)
for (const config of configs) {
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
}
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
// The track calls still carry their offset alongside the longer wait.
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
})
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
// Reading suggestions and confirming countries touch TREK's own database
// only; a two-minute wait there would just hide a stuck server.
await dawarichApi.listSuggestions()
await dawarichApi.acceptAtlasCountries(['DE'])
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
expect(post.mock.calls[0][2]).toBeUndefined()
})
})
+31 -7
View File
@@ -18,6 +18,28 @@ import type {
* client has not caught up with is a typecheck failure rather than a runtime
* surprise.
*/
/**
* How long the browser waits for a call that makes the server go and ask
* Dawarich.
*
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
* these: the server allows each request to Dawarich 15 s by itself, walks pages
* of points for a route, and runs six probes one after another to test a
* connection. Cut off at 8 s, the browser gave up while the server carried on
* and cached the answer, so the recorded route showed "unavailable" and then
* appeared on the next reload.
*/
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
/**
* The Atlas asks for a year of visited cities a month at a time, one request
* after another, so it gets longer again. Still bounded, and under Node's own
* five-minute request limit on the server side.
*/
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
export const dawarichApi = {
// ── Connection ─────────────────────────────────────────────────────────────
getSettings: (): Promise<DawarichConnection> =>
@@ -31,13 +53,13 @@ export const dawarichApi = {
/** The address it resolved to, for the private-IP warning. */
warningIp?: string
}> =>
apiClient.put('/integrations/dawarich/settings', data).then(r => r.data),
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
disconnect: (): Promise<{ success: boolean }> =>
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
apiClient.post('/integrations/dawarich/test', data).then(r => r.data),
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
apiClient.post('/integrations/dawarich/sync').then(r => r.data),
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
// ── Suggestions ────────────────────────────────────────────────────────────
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
@@ -49,7 +71,7 @@ export const dawarichApi = {
// ── Bucket list ────────────────────────────────────────────────────────────
scanBucketList: (): Promise<DawarichBucketScan> =>
apiClient.post('/integrations/dawarich/bucket-list/scan').then(r => r.data),
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
@@ -57,7 +79,9 @@ export const dawarichApi = {
// ── Atlas ──────────────────────────────────────────────────────────────────
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
apiClient.get('/integrations/dawarich/atlas/suggestions', { params: { from, to } }).then(r => r.data),
apiClient
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
.then(r => r.data),
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
@@ -69,11 +93,11 @@ export const dawarichApi = {
// the wrong day of the trip.
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal })
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
apiClient
.get('/integrations/dawarich/track', { params: { from, to, offset: localOffsetMinutes() }, signal })
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
.then(r => r.data),
}
@@ -26,15 +26,18 @@ import {
export default function DawarichTrailLayer({
track,
selectedDate,
hiddenDates,
casingPane,
}: {
track: DawarichTrack | null
/** When set, only that local day is drawn. */
selectedDate?: string | null
/** Local dates whose day is folded away in the day plan. */
hiddenDates?: ReadonlySet<string> | null
/** The `trek-track-casing` pane, when the renderer supports panes. */
casingPane?: string
}) {
const segments = useMemo(() => trailSegments(track, selectedDate), [track, selectedDate])
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
if (segments.length === 0) return null
return (
@@ -1,5 +1,5 @@
/**
* FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-007: the recorded-route
* FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-009: the recorded-route
* toggle that sits with the compass and the layer switcher on the trip map.
*
* The pill is small but it carries the only explanation the user ever gets for
@@ -124,4 +124,26 @@ describe('DawarichTrailPill', () => {
// button to find out why the map is empty.
expect(screen.getByRole('tooltip')).toHaveTextContent('Nothing was recorded on these dates')
})
it('FE-DAWARICH-TRAILUI-008: shows a spinning ring and a busy state while the route loads', () => {
pill({ active: true, status: 'loading' })
// A first fetch from somebody's own server can take a while. With nothing on
// the button it read as "done, no line", and the tester reloaded the page.
expect(screen.getByTestId('dawarich-trail-loading')).toBeInTheDocument()
expect(button()).toHaveAttribute('aria-busy', 'true')
})
it('FE-DAWARICH-TRAILUI-009: drops the ring once there is an answer, and never shows it while the layer is off', () => {
const view = pill({ active: true, status: 'loading' })
view.rerender(<DawarichTrailPill active status="ready" onToggle={vi.fn()} />)
expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument()
expect(button()).toHaveAttribute('aria-busy', 'false')
// Switched off mid-load: the hook still reports its last status, the button
// must not keep spinning for a layer nobody asked for any more.
view.rerender(<DawarichTrailPill active={false} status="loading" onToggle={vi.fn()} />)
expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument()
})
})
@@ -40,10 +40,15 @@ export function DawarichTrailPill({
// A problem worth seeing without reading the tooltip: the layer is on and
// there is nothing to show.
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
// Loading has to be visible too. A first fetch from somebody's own server can
// take a while, and with nothing on the button it looked exactly like "done,
// but no line", so testers reloaded the page to find out.
const loading = active && status === 'loading'
return (
<div
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
padding: 4,
@@ -64,6 +69,7 @@ export function DawarichTrailPill({
onClick={onToggle}
aria-label={label}
aria-pressed={active}
aria-busy={loading}
data-testid="dawarich-trail-pill"
style={{
display: 'block',
@@ -88,6 +94,22 @@ export function DawarichTrailPill({
<DawarichIcon fill />
</button>
</Tooltip>
{loading && (
<span
aria-hidden="true"
data-testid="dawarich-trail-loading"
className="motion-safe:animate-spin"
style={{
position: 'absolute',
inset: 2,
borderRadius: 999,
border: '2px solid transparent',
borderTopColor: 'var(--accent)',
borderRightColor: 'var(--accent)',
pointerEvents: 'none',
}}
/>
)}
</div>
)
}
+2
View File
@@ -676,6 +676,7 @@ export const MapView = memo(function MapView({
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
dawarichHiddenDates = null,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -1201,6 +1202,7 @@ export const MapView = memo(function MapView({
<DawarichTrailLayer
track={dawarichTrack}
selectedDate={dawarichSelectedDate}
hiddenDates={dawarichHiddenDates}
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
/>
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
+4 -1
View File
@@ -197,6 +197,8 @@ interface Props {
dawarichTrack?: DawarichTrack | null
/** Draw only this local day of the recording. */
dawarichSelectedDate?: string | null
/** Local dates whose day is collapsed in the day plan; their recording is not drawn. */
dawarichHiddenDates?: ReadonlySet<string> | null
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -646,6 +648,7 @@ export function MapViewGL({
hazards,
dawarichTrack = null,
dawarichSelectedDate = null,
dawarichHiddenDates = null,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -724,7 +727,7 @@ export function MapViewGL({
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing')
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
@@ -1,5 +1,6 @@
/**
* dawarichTrail unit tests: FE-DAWARICH-TRAIL-001 to FE-DAWARICH-TRAIL-008.
* dawarichTrail unit tests: FE-DAWARICH-TRAIL-001 to FE-DAWARICH-TRAIL-008, and
* FE-DAWARICH-TRAIL-030 to FE-DAWARICH-TRAIL-032 for the days folded away.
*
* This module is the whole of what the two map renderers agree on about the
* recorded route: Leaflet turns `trailSegments` into `<Polyline>` elements, the
@@ -32,6 +33,7 @@ import {
DAWARICH_DAY_COLORS,
DAWARICH_TRAIL_CASING,
DAWARICH_TRAIL_COLOR,
collapsedDayDates,
dayColor,
trailDistanceKm,
trailGeoJson,
@@ -227,3 +229,40 @@ describe('trailDistanceKm', () => {
expect(trailDistanceKm([])).toBeNull()
})
})
describe('the days folded away in the day plan', () => {
it('FE-DAWARICH-TRAIL-030: a collapsed day takes its route off the map and leaves the colours of the rest alone', () => {
const all = trailSegments(THREE_DAYS)
const visible = trailSegments(THREE_DAYS, null, new Set(['2026-05-02']))
expect(visible.map(segment => segment.date)).toEqual(['2026-05-01', '2026-05-01', '2026-05-03'])
// Folding day two must not shift day three onto day two's colour.
expect(visible.find(segment => segment.date === '2026-05-03')?.color)
.toBe(all.find(segment => segment.date === '2026-05-03')?.color)
})
it('FE-DAWARICH-TRAIL-031: a picked day that is also folded away draws nothing', () => {
expect(trailSegments(THREE_DAYS, '2026-05-01', new Set(['2026-05-01']))).toEqual([])
expect(trailSegments(THREE_DAYS, '2026-05-01', new Set(['2026-05-03']))).toHaveLength(2)
})
it('FE-DAWARICH-TRAIL-032: follows both folds the places follow, and nothing before the day plan has spoken', () => {
const days = [
{ id: 1, date: '2026-05-03' },
{ id: 2, date: '2026-05-01' },
{ id: 3, date: null },
{ id: 4, date: '2026-05-02' },
]
// Null is "the day plan has not reported yet": the places show, so the
// route shows too.
expect(collapsedDayDates(days, null)).toEqual([])
// Sorted, deduplicated, and a day without a date matches no recording.
expect(collapsedDayDates(days, new Set([4]))).toEqual(['2026-05-01', '2026-05-03'])
// Road trip mode folds days in its own sidebar, on top of the day plan.
expect(collapsedDayDates(days, null, new Set([4]))).toEqual(['2026-05-02'])
expect(collapsedDayDates(days, new Set([1, 2, 3, 4]), new Set([1, 3]))).toEqual(['2026-05-03'])
})
})
+32 -7
View File
@@ -64,20 +64,21 @@ export interface DawarichTrailSegment {
}
/**
* Flatten a fetched track into drawable segments, optionally limited to one day.
* Flatten a fetched track into drawable segments, optionally limited to one day
* and without the days folded away in the day plan.
*
* The day filter is what the timeline scrubber drives: selecting a day on the
* planner should dim the rest of the trip rather than reload it, so the filter
* happens here over data that is already in memory.
* Both filters work on data that is already in memory: selecting or collapsing a
* day changes what is drawn, it does not reload the recording.
*/
export function trailSegments(
track: DawarichTrack | null,
onlyDate?: string | null,
hiddenDates?: ReadonlySet<string> | null,
): DawarichTrailSegment[] {
if (!track) return []
const days: DawarichTrackDay[] = onlyDate
? track.days.filter(day => day.date === onlyDate)
: track.days
const days: DawarichTrackDay[] = track.days.filter(
day => (!onlyDate || day.date === onlyDate) && !hiddenDates?.has(day.date),
)
const out: DawarichTrailSegment[] = []
for (const day of days) {
@@ -108,6 +109,30 @@ export function trailSegments(
return out
}
/**
* The dates whose recording stays off the map because their day is collapsed.
*
* The same rule the planner already applies to places: a collapsed day takes
* its markers off the map, so it takes its route with it. Two folds count, as
* they do for places: the day plan's (`expandedDayIds`, null until the day plan
* has reported its state, and then nothing is hidden by it) and, in road trip
* mode, the road trip sidebar's own. A day without a date has nothing to match a
* recording against. Sorted, so the caller can key a memo on the joined list.
*/
export function collapsedDayDates(
days: ReadonlyArray<{ id: number; date?: string | null }>,
expandedDayIds: ReadonlySet<number> | null,
collapsedDayIds?: ReadonlySet<number> | null,
): string[] {
const dates = new Set<string>()
for (const day of days) {
if (!day.date) continue
const foldedInPlan = !!expandedDayIds && !expandedDayIds.has(day.id)
if (foldedInPlan || collapsedDayIds?.has(day.id)) dates.add(day.date)
}
return [...dates].sort()
}
/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
export function trailGeoJson(segments: DawarichTrailSegment[]): {
type: 'FeatureCollection'
@@ -1,5 +1,5 @@
/**
* FE-DAWARICH-TRAILUI-020 to FE-DAWARICH-TRAILUI-029: the recorded-route
* FE-DAWARICH-TRAILUI-020 to FE-DAWARICH-TRAILUI-032: the recorded-route
* overlay on the GL renderer, the imperative twin of `DawarichTrailLayer`.
*
* A declarative overlay that gets its ordering wrong looks wrong. An imperative
@@ -28,6 +28,10 @@
* sources. Both paths are pinned, because which one a refresh takes is the
* difference between a line that blinks every two minutes and one that does
* not.
* - **the wait for `idle`.** `isStyleLoaded()` is false while any tile is
* still in flight, not only before the first style. A draw that gave up
* there and only listened for `style.load` left the line off the map until
* the page was reloaded, which is what a tester on dev1 ran into.
* - **one `style.load` subscription at a time.** The effect re-runs on every
* refresh, and a cleanup that forgot to unsubscribe would add one silent
* redraw per refresh for the life of the map.
@@ -61,6 +65,7 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
const layers = new Map<string, { spec: { id: string; paint?: Record<string, unknown> }; before: string | undefined }>()
for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined })
const listeners = new Map<string, Set<() => void>>()
const onceListeners = new Map<string, Set<() => void>>()
const state = { styleLoaded }
const map = {
@@ -84,7 +89,15 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
set.add(fn)
listeners.set(type, set)
}),
off: vi.fn((type: string, fn: () => void) => { listeners.get(type)?.delete(fn) }),
once: vi.fn((type: string, fn: () => void) => {
const set = onceListeners.get(type) ?? new Set<() => void>()
set.add(fn)
onceListeners.set(type, set)
}),
off: vi.fn((type: string, fn: () => void) => {
listeners.get(type)?.delete(fn)
onceListeners.get(type)?.delete(fn)
}),
}
return {
@@ -95,6 +108,13 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
state,
styleLoadListeners: () => listeners.get('style.load')?.size ?? 0,
emitStyleLoad: () => { listeners.get('style.load')?.forEach(fn => fn()) },
idleListeners: () => onceListeners.get('idle')?.size ?? 0,
/** A `once` listener is gone before it runs, the way GL's Evented does it. */
emitIdle: () => {
const pending = [...(onceListeners.get('idle') ?? [])]
onceListeners.delete('idle')
pending.forEach(fn => fn())
},
/** What a style rebuild does to the map before `style.load` fires. */
dropStyle: () => { sources.clear(); layers.clear() },
drawn: () => sources.get(SOURCE)?.data as
@@ -109,10 +129,11 @@ interface Props {
track?: DawarichTrack | null
date?: string | null
before?: string
hidden?: ReadonlySet<string> | null
}
const mount = (props: Props) =>
renderHook((p: Props) => useDawarichTrailGL(p.map, p.ready, p.track, p.date, p.before), {
renderHook((p: Props) => useDawarichTrailGL(p.map, p.ready, p.track, p.date, p.before, p.hidden), {
initialProps: props,
})
@@ -314,4 +335,53 @@ describe('useDawarichTrailGL', () => {
// change per refresh, for as long as the map lives.
expect(gl.styleLoadListeners()).toBe(1)
})
it('FE-DAWARICH-TRAILUI-030: a route that lands while tiles are still loading is drawn once they have arrived', () => {
// The tester's case: the overlay switched on, the map still filling in,
// and the line only appearing after a reload.
const gl = fakeGlMap({ styleLoaded: false })
mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
expect(gl.spies.addSource).not.toHaveBeenCalled()
expect(gl.idleListeners()).toBe(1)
gl.state.styleLoaded = true
gl.emitIdle()
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
expect(gl.spies.addLayer).toHaveBeenCalledTimes(2)
expect(gl.idleListeners()).toBe(0)
})
it('FE-DAWARICH-TRAILUI-031: waits for idle once however often it is asked, and keeps waiting while the map is busy', () => {
const gl = fakeGlMap({ styleLoaded: false })
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
// A style.load that fires while tiles are still in flight is a second draw
// attempt; it must not stack a second wait on the first.
gl.emitStyleLoad()
expect(gl.idleListeners()).toBe(1)
// An idle that arrives before the style is ready (a transition finished,
// another source started) is not the end of the wait.
gl.emitIdle()
expect(gl.spies.addSource).not.toHaveBeenCalled()
expect(gl.idleListeners()).toBe(1)
view.unmount()
expect(gl.idleListeners()).toBe(0)
expect(gl.spies.off).toHaveBeenCalledWith('idle', expect.any(Function))
})
it('FE-DAWARICH-TRAILUI-032: leaves out the days folded away in the day plan without repainting the others', () => {
const gl = fakeGlMap()
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) })
const secondColour = gl.drawn()?.features[1]?.properties.color
view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]), hidden: new Set([DAY_ONE.date]) })
expect(gl.drawn()?.features).toHaveLength(1)
expect(gl.drawn()?.features[0]?.properties.date).toBe(DAY_TWO.date)
expect(gl.drawn()?.features[0]?.properties.color).toBe(secondColour)
})
})
@@ -28,14 +28,29 @@ export function useDawarichTrailGL(
selectedDate?: string | null,
/** Insert beneath this layer, so the planned route stays on top. */
beforeId?: string,
/** Local dates whose day is folded away in the day plan. */
hiddenDates?: ReadonlySet<string> | null,
): void {
useEffect(() => {
if (!map || !ready) return
const data = trailGeoJson(trailSegments(track ?? null, selectedDate))
const data = trailGeoJson(trailSegments(track ?? null, selectedDate, hiddenDates))
// `isStyleLoaded()` does not mean "the style has loaded". It is false while
// any source still has tiles in flight, which is the usual state right after
// someone switches the overlay on and the map is still filling in. Giving up
// there left the line undrawn until something rebuilt the style, which in
// practice meant reloading the page. `idle` is the map saying everything it
// was fetching has arrived, so the draw waits for it once and tries again.
let waiting = false
const draw = () => {
if (!map.isStyleLoaded()) return
if (!map.isStyleLoaded()) {
if (!waiting) {
waiting = true
map.once('idle', retry)
}
return
}
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
if (existing) {
// setData rather than remove-and-add: a rebuilt source flickers, and a
@@ -77,14 +92,20 @@ export function useDawarichTrailGL(
)
}
const retry = () => {
waiting = false
draw()
}
draw()
map.on('style.load', draw)
return () => {
map.off('style.load', draw)
map.off('idle', retry)
for (const layer of [LINE_LAYER, CASING_LAYER]) {
if (map.getLayer(layer)) map.removeLayer(layer)
}
if (map.getSource(SOURCE)) map.removeSource(SOURCE)
}
}, [map, ready, track, selectedDate, beforeId])
}, [map, ready, track, selectedDate, beforeId, hiddenDates])
}
@@ -0,0 +1,107 @@
/**
* FE-ROADTRIP-HAZARDGL-001 to FE-ROADTRIP-HAZARDGL-003: when the hazard overlay
* on the GL renderer actually gets drawn.
*
* It shares a failure with the recorded Dawarich route: `isStyleLoaded()` is
* false while any source still has tiles in flight, not only before the first
* style. A draw that gave up there and waited for `style.load` alone stayed off
* the map until the style was rebuilt, which in practice meant a reload. These
* cases pin the wait for `idle` that replaced it, and that the wait does not
* outlive the component.
*/
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { Map as MapboxMap, Popup } from 'mapbox-gl'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
const SOURCE = 'roadtrip-hazards'
const HAZARD: RoadtripHazard = {
id: 'dwd-1',
source: 'DWD',
title: 'Sturmboeen',
description: 'Boeen bis 85 km/h',
updatedAt: '2026-09-14T10:00:00.000Z',
validUntil: null,
url: 'https://www.dwd.de/warnungen',
geometry: { type: 'Point', coordinates: [13.4, 52.5] },
}
function fakeGlMap(styleLoaded: boolean) {
const sources = new Set<string>()
const layers = new Set<string>()
const once = new Map<string, Set<() => void>>()
const state = { styleLoaded }
const map = {
isStyleLoaded: vi.fn(() => state.styleLoaded),
getSource: vi.fn((id: string) => (sources.has(id) ? {} : undefined)),
addSource: vi.fn((id: string) => { sources.add(id) }),
removeSource: vi.fn((id: string) => { sources.delete(id) }),
getLayer: vi.fn((id: string) => (layers.has(id) ? {} : undefined)),
addLayer: vi.fn((spec: { id: string }) => { layers.add(spec.id) }),
removeLayer: vi.fn((id: string) => { layers.delete(id) }),
on: vi.fn(),
once: vi.fn((type: string, fn: () => void) => {
const set = once.get(type) ?? new Set<() => void>()
set.add(fn)
once.set(type, set)
}),
off: vi.fn((type: string, fn: () => void) => { once.get(type)?.delete(fn) }),
}
return {
map: map as unknown as MapboxMap,
spies: map,
state,
sources,
idleListeners: () => once.get('idle')?.size ?? 0,
emitIdle: () => {
const pending = [...(once.get('idle') ?? [])]
once.delete('idle')
pending.forEach(fn => fn())
},
}
}
const popup = () => ({}) as Popup
describe('useHazardLayerGL', () => {
it('FE-ROADTRIP-HAZARDGL-001: draws straight away into a style that is ready', () => {
const gl = fakeGlMap(true)
renderHook(() => useHazardLayerGL(gl.map, true, [HAZARD], popup))
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }))
expect(gl.spies.addLayer).toHaveBeenCalledTimes(3)
expect(gl.idleListeners()).toBe(0)
})
it('FE-ROADTRIP-HAZARDGL-002: hazards that arrive while tiles are loading are drawn once the map is idle', () => {
const gl = fakeGlMap(false)
renderHook(() => useHazardLayerGL(gl.map, true, [HAZARD], popup))
expect(gl.spies.addSource).not.toHaveBeenCalled()
expect(gl.idleListeners()).toBe(1)
// Still busy at the first idle: wait again, and only once.
gl.emitIdle()
expect(gl.spies.addSource).not.toHaveBeenCalled()
expect(gl.idleListeners()).toBe(1)
gl.state.styleLoaded = true
gl.emitIdle()
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
expect(gl.idleListeners()).toBe(0)
})
it('FE-ROADTRIP-HAZARDGL-003: an unmount while waiting takes the wait with it', () => {
const gl = fakeGlMap(false)
const view = renderHook(() => useHazardLayerGL(gl.map, true, [HAZARD], popup))
view.unmount()
expect(gl.idleListeners()).toBe(0)
expect(gl.spies.off).toHaveBeenCalledWith('idle', expect.any(Function))
expect(gl.sources.size).toBe(0)
})
})
+17 -1
View File
@@ -11,8 +11,19 @@ export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: Roadt
const source = 'roadtrip-hazards'
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
let detail: Popup | undefined
// Waits for `idle` rather than giving up while tiles are still loading, for
// the reason spelled out in useDawarichTrailGL: `isStyleLoaded()` is false
// for as long as any source has tiles in flight.
let waiting = false
const draw = () => {
if (!map.isStyleLoaded() || map.getSource(source)) return
if (map.getSource(source)) return
if (!map.isStyleLoaded()) {
if (!waiting) {
waiting = true
map.once('idle', retry)
}
return
}
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
@@ -27,12 +38,17 @@ export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: Roadt
detail?.remove()
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
}
const retry = () => {
waiting = false
draw()
}
draw()
map.on('style.load', draw)
map.on('click', click)
return () => {
detail?.remove()
map.off('style.load', draw)
map.off('idle', retry)
map.off('click', click)
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
if (map.getSource(source)) map.removeSource(source)
+2 -1
View File
@@ -261,7 +261,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -415,6 +415,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<MapView
tripId={tripId}
dawarichTrack={dawarichTrail.track}
dawarichHiddenDates={dawarichHiddenDates}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
+15 -1
View File
@@ -32,6 +32,7 @@ import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
@@ -955,6 +956,19 @@ export function useTripPlanner() {
return plannedPlaces.filter(p => !hidden.has(p.id))
}, [mapPlaces, assignments, roadtripRoutes.days, collapsedRoadtripDays])
// The recorded route follows the same folds as the places above, so a
// collapsed day does not leave its line behind on the map. Keyed on the joined
// dates rather than on the Set, because `days` changes identity on every store
// update and the GL overlay rebuilds its source whenever this reference moves.
const dawarichHiddenKey = useMemo(
() => collapsedDayDates(days, expandedDayIds, roadtripActive ? collapsedRoadtripDays : null).join('|'),
[days, expandedDayIds, roadtripActive, collapsedRoadtripDays],
)
const dawarichHiddenDates = useMemo(
() => (dawarichHiddenKey ? new Set(dawarichHiddenKey.split('|')) : null),
[dawarichHiddenKey],
)
const roadtripLineColors = useMemo(
() => {
if (!roadtripSettings.roadtrip_day_colors) return undefined
@@ -2390,7 +2404,7 @@ export function useTripPlanner() {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichEnabled: !!enabledAddons.dawarich,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import type { DawarichErrorCode } from '@trek/shared';
import { safeFetch } from '../../utils/ssrfGuard';
import { readCappedJson } from '../../utils/cappedFetch';
import { mergeVisitedCountries, splitWindow, VISITED_CITIES_WINDOW_DAYS } from './dawarich.helpers';
/**
* Thin HTTP client for the Dawarich REST API (github.com/Freika/dawarich).
@@ -385,17 +386,31 @@ export class DawarichClient {
return { points, truncated };
}
/** Countries and cities the recordings put the user in, for the Atlas. */
/**
* Countries and cities the recordings put the user in, for the Atlas.
*
* Asked for a month at a time and folded back together: Dawarich computes this
* over every point in the window, so a year in one request outlasted the
* timeout for anyone who records densely (see VISITED_CITIES_WINDOW_DAYS).
* The windows go one after another rather than in parallel, because each one
* is a full computation on the user's own server.
*/
async listVisitedCities(
creds: DawarichCreds,
from: Date,
to: Date,
): Promise<DawarichVisitedCountry[]> {
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
start_at: unixSeconds(from),
end_at: unixSeconds(to),
});
return Array.isArray(data?.data) ? data.data : [];
const answers: DawarichVisitedCountry[][] = [];
for (const window of splitWindow(from, to, VISITED_CITIES_WINDOW_DAYS)) {
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
start_at: unixSeconds(window.from),
end_at: unixSeconds(window.to),
});
answers.push(Array.isArray(data?.data) ? data.data : []);
}
// A single window is passed through untouched, so the probe and every short
// range see exactly what Dawarich sent.
return answers.length === 1 ? answers[0] : mergeVisitedCountries(answers);
}
/**
@@ -1,6 +1,6 @@
import { createHash } from 'crypto';
import type { DawarichTrackDay, DawarichTrackSegment } from '@trek/shared';
import type { DawarichSlimPoint, DawarichTrackFeature, DawarichVisitRaw } from './dawarich.client';
import type { DawarichSlimPoint, DawarichTrackFeature, DawarichVisitRaw, DawarichVisitedCountry } from './dawarich.client';
/**
* Pure helpers for the Dawarich integration — no database, no HTTP, no
@@ -514,3 +514,88 @@ export function countPoints(days: DawarichTrackDay[]): number {
0,
);
}
// ── Visited cities ───────────────────────────────────────────────────────────
/**
* How much recording one `visited_cities` request may cover.
*
* Dawarich computes that endpoint on the spot: it loads every point in the
* window, sorts them in memory and walks them into stays, with nothing cached.
* A year in one request is fine for someone who records a point a minute and a
* certain timeout for someone who records one every few seconds, because the
* work grows with the points while the request budget stays fixed. A month is
* a twelfth of that work per request.
*/
export const VISITED_CITIES_WINDOW_DAYS = 30;
/**
* `[from, to]` cut into consecutive windows of at most `days`.
*
* Dawarich reads the bounds as an inclusive range (`start_at..end_at`), so each
* window after the first starts one second past the end of the one before. With
* touching bounds a point recorded on that exact second would be counted twice.
* Bounds are whole seconds for the same reason: that is what goes on the wire.
*/
export function splitWindow(from: Date, to: Date, days: number): Array<{ from: Date; to: Date }> {
const step = days * 86_400_000;
const end = Math.floor(to.getTime() / 1000) * 1000;
const windows: Array<{ from: Date; to: Date }> = [];
let start = Math.floor(from.getTime() / 1000) * 1000;
while (start <= end) {
const stop = Math.min(start + step, end);
windows.push({ from: new Date(start), to: new Date(stop) });
if (stop === end) break;
start = stop + 1000;
}
return windows;
}
/**
* Several `visited_cities` answers folded into one, as if the whole range had
* been asked for at once.
*
* Countries keep the order they were first seen in, cities within a country
* likewise. A city seen in more than one window adds up its minutes and points
* and keeps the latest timestamp. The payload arrives unvalidated, so an entry
* without a usable name is skipped here rather than guessed at.
*
* One thing a split cannot recover: Dawarich drops a stay shorter than the
* user's minimum per window, so a stay cut in half by a window boundary can fall
* under it on both sides. That costs a city at most, never a country the rest of
* the recording found.
*/
export function mergeVisitedCountries(answers: DawarichVisitedCountry[][]): DawarichVisitedCountry[] {
const countries = new Map<string, Map<string, DawarichVisitedCountry['cities'][number]>>();
for (const answer of answers) {
for (const entry of answer) {
if (!entry || typeof entry.country !== 'string') continue;
let cities = countries.get(entry.country);
if (!cities) {
cities = new Map();
countries.set(entry.country, cities);
}
for (const city of Array.isArray(entry.cities) ? entry.cities : []) {
if (!city || typeof city.city !== 'string') continue;
const seen = cities.get(city.city);
if (!seen) {
cities.set(city.city, { ...city });
continue;
}
seen.points = sumOf(seen.points, city.points);
seen.stayed_for = sumOf(seen.stayed_for, city.stayed_for);
const latest = Math.max(toNumber(seen.timestamp) ?? -Infinity, toNumber(city.timestamp) ?? -Infinity);
seen.timestamp = Number.isFinite(latest) ? latest : undefined;
}
}
}
return [...countries].map(([country, cities]) => ({ country, cities: [...cities.values()] }));
}
/** Two optional counts added up; absent when neither side had one. */
function sumOf(a: unknown, b: unknown): number | undefined {
const left = toNumber(a);
const right = toNumber(b);
if (left === null && right === null) return undefined;
return (left ?? 0) + (right ?? 0);
}
@@ -574,6 +574,66 @@ describe('DawarichClient — listVisitedCities', () => {
});
});
describe('DawarichClient, visited cities over a year', () => {
const YEAR_FROM = new Date('2025-09-14T20:00:00.000Z');
const YEAR_TO = new Date('2026-09-14T20:00:00.000Z');
it('DAWARICH-CLIENT-082: a year is asked for a month at a time, one request after another, and folded back', async () => {
// Dawarich computes this endpoint over every point in the window. A year in
// one request is what timed out on a tester's instance, so the client has to
// split it, and has to do so without firing thirteen computations at once.
let inFlight = 0;
let mostAtOnce = 0;
let served = 0;
safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => {
calls.push({ url, init });
inFlight++;
mostAtOnce = Math.max(mostAtOnce, inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight--;
served++;
const body =
served === 1
? { data: [{ country: 'Germany', cities: [{ city: 'Rostock', stayed_for: 90 }] }] }
: served === 7
? { data: [{ country: 'Germany', cities: [{ city: 'Rostock', stayed_for: 30 }] }, { country: 'Poland', cities: [] }] }
: { data: [] };
return reply({ body }) as unknown as Response;
});
const out = await client.listVisitedCities(CREDS, YEAR_FROM, YEAR_TO);
expect(calls).toHaveLength(13);
expect(mostAtOnce).toBe(1);
expect(requestedUrl(0).searchParams.get('start_at')).toBe(unixSeconds(YEAR_FROM));
expect(requestedUrl(12).searchParams.get('end_at')).toBe(unixSeconds(YEAR_TO));
for (let i = 1; i < calls.length; i++) {
const previousEnd = Number(requestedUrl(i - 1).searchParams.get('end_at'));
expect(Number(requestedUrl(i).searchParams.get('start_at'))).toBe(previousEnd + 1);
}
expect(out).toEqual([
{ country: 'Germany', cities: [{ city: 'Rostock', stayed_for: 120 }] },
{ country: 'Poland', cities: [] },
]);
});
it('DAWARICH-CLIENT-083: one month failing fails the whole year instead of passing off eleven as twelve', async () => {
let served = 0;
safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => {
calls.push({ url, init });
served++;
if (served === 4) throw new Error('The operation was aborted due to timeout');
return reply({ body: { data: [] } }) as unknown as Response;
});
const err = await failure(() => client.listVisitedCities(CREDS, YEAR_FROM, YEAR_TO));
expect(err.code).toBe('unreachable');
// Stops at the failure rather than spending the remaining nine on a result
// that is already going to be refused.
expect(calls).toHaveLength(4);
});
});
describe('DawarichClient — findVisitsNear', () => {
it('DAWARICH-CLIENT-090: pins the radius explicitly instead of letting Dawarich guess 500 m', async () => {
const visits = [{ timestamp: 1788000000, distance_meters: 40, points_count: 12 }];
@@ -1,6 +1,6 @@
/**
* The pure half of the Dawarich integration — DAWARICH-TIME-001 through
* DAWARICH-COUNT-003. Everything under test is a free function over plain
* DAWARICH-CITIES-006. Everything under test is a free function over plain
* fixtures: no DB, no container, and no client. `dawarich.client` appears here
* only as a type-only import (erased at compile time), so nothing in this file
* can reach the network even by accident — there is no DawarichClient instance
@@ -19,10 +19,12 @@ import {
distanceMeters,
geoJsonToLatLng,
localDateOf,
mergeVisitedCountries,
minutesBetween,
normalizeVisit,
offsetMinutesOf,
simplify,
splitWindow,
syncWindow,
toNumber,
visitHash,
@@ -927,3 +929,85 @@ describe('countPoints', () => {
expect(countPoints(days)).toBe(6);
});
});
describe('visited cities over a long range', () => {
const DAY = 86_400_000;
it('DAWARICH-CITIES-001: a range within one window stays one request, bounds untouched', () => {
const from = new Date('2026-09-01T00:00:00Z');
const to = new Date('2026-09-02T00:00:00Z');
expect(splitWindow(from, to, 30)).toEqual([{ from, to }]);
});
it('DAWARICH-CITIES-002: a year becomes months that cover it without a gap or an overlap', () => {
// Dawarich reads start_at..end_at as inclusive on both ends, so touching
// bounds would hand the point on the seam to two windows.
const from = new Date('2025-09-14T20:00:00Z');
const to = new Date(from.getTime() + 365 * DAY);
const windows = splitWindow(from, to, 30);
expect(windows).toHaveLength(13);
expect(windows[0].from).toEqual(from);
expect(windows[windows.length - 1].to).toEqual(to);
for (let i = 1; i < windows.length; i++) {
expect(windows[i].from.getTime() - windows[i - 1].to.getTime()).toBe(1000);
}
for (const window of windows) {
expect(window.to.getTime() - window.from.getTime()).toBeLessThanOrEqual(30 * DAY);
}
});
it('DAWARICH-CITIES-003: bounds are whole seconds, the unit that goes on the wire', () => {
const windows = splitWindow(new Date('2026-01-01T00:00:00.750Z'), new Date('2026-03-01T00:00:00.250Z'), 30);
for (const window of windows) {
expect(window.from.getMilliseconds()).toBe(0);
expect(window.to.getMilliseconds()).toBe(0);
}
});
it('DAWARICH-CITIES-004: a range that ends before it starts asks nothing', () => {
expect(splitWindow(new Date('2026-09-02T00:00:00Z'), new Date('2026-09-01T00:00:00Z'), 30)).toEqual([]);
});
it('DAWARICH-CITIES-005: a city seen in two months adds up its time and keeps the later visit', () => {
const merged = mergeVisitedCountries([
[{ country: 'Germany', cities: [{ city: 'Rostock', points: 10, stayed_for: 90, timestamp: 1_700_000_000 }] }],
[
{ country: 'Germany', cities: [{ city: 'Rostock', points: 5, stayed_for: 60, timestamp: 1_702_000_000 }, { city: 'Berlin', stayed_for: 120 }] },
{ country: 'Poland', cities: [{ city: 'Szczecin', stayed_for: 75, timestamp: 1_701_000_000 }] },
],
]);
expect(merged).toEqual([
{
country: 'Germany',
cities: [
{ city: 'Rostock', points: 15, stayed_for: 150, timestamp: 1_702_000_000 },
{ city: 'Berlin', stayed_for: 120 },
],
},
{ country: 'Poland', cities: [{ city: 'Szczecin', stayed_for: 75, timestamp: 1_701_000_000 }] },
]);
});
it('DAWARICH-CITIES-006: an unvalidated payload loses its unnamed entries, not its countries', () => {
const merged = mergeVisitedCountries([
[
null as never,
{ country: 42 as never, cities: [] },
{ country: 'Denmark', cities: null as never },
],
[{ country: 'Denmark', cities: [null as never, {} as never, { city: 'Gedser', points: '7' as never }] }],
]);
// A count that arrived as a string is still a count; a timestamp nobody sent
// stays absent instead of turning into -Infinity.
expect(merged).toEqual([{ country: 'Denmark', cities: [{ city: 'Gedser', points: '7' }] }]);
const again = mergeVisitedCountries([
[{ country: 'Denmark', cities: [{ city: 'Gedser', points: '7' as never }] }],
[{ country: 'Denmark', cities: [{ city: 'Gedser', points: 3 }] }],
]);
expect(again[0].cities[0]).toEqual({ city: 'Gedser', points: 10, stayed_for: undefined, timestamp: undefined });
});
});