test(dawarich): the integration that shipped without a suite

Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on
SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are
Dawarich's own. 946 cases across 30 files take the surface to 100% in shared,
99.7 in server and 99.6 in client, and the gate to roughly 85%.

What stays uncovered is thirteen type-narrowing guards and defensive fallbacks
the only caller cannot reach, each argued in the file beside it rather than
forced with a cast. No source, config or coverage threshold is touched.
This commit is contained in:
Maurice
2026-09-14 10:39:49 +02:00
parent b2384a7523
commit a6e8c66609
30 changed files with 10985 additions and 9 deletions
@@ -0,0 +1,169 @@
/**
* FE-DAWARICH-TRAILUI-010 to FE-DAWARICH-TRAILUI-015: the recorded-route
* overlay on the Leaflet renderer.
*
* Everything this component decides is a decision about legibility on top of
* somebody else's map, and every one of them is the kind that quietly rots:
*
* - **two lines per segment, casing first.** The white casing is what keeps
* the route visible over a satellite tile. It only works while it is drawn
* *under* the coloured line, so the order of the two `map()` blocks is
* load-bearing and not cosmetic.
* - **`interactive: false` on both.** A Leaflet path without it grows an
* invisible hit area that lies over the places and the planned route and
* eats the clicks meant for them. Losing this flag breaks selection on a
* surface that has nothing to do with Dawarich, which is why it is pinned
* here rather than left to a reviewer's eye.
* - **colour through `pathOptions`**, because react-leaflet only calls
* `setStyle` when that object's identity changes; a bare `color` prop sticks
* at its mount-time value and a day recolour never arrives.
* - **the day filter keeps the day's colour.** The colour index comes from the
* day's place in the whole track, so scrubbing to a single day dims the rest
* of the trip instead of repainting the day the user is looking at.
*
* The empty case matters too: returning `null` rather than an empty fragment is
* what stops Leaflet from being handed zero-length polylines on every trip that
* has no recording.
*/
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
import { DAWARICH_DAY_COLORS, DAWARICH_TRAIL_CASING } from './dawarichTrail'
// Leaflet itself is irrelevant here. What is under test is which polylines are
// asked for and with what options, so the mock reflects the props back as data
// attributes and the assertions read them in render order.
vi.mock('react-leaflet', () => ({
Polyline: ({
positions,
pathOptions,
pane,
interactive,
}: {
positions: Array<[number, number]>
pathOptions?: Record<string, unknown>
pane?: string
interactive?: boolean
}) => (
<div
data-testid="polyline"
data-points={JSON.stringify(positions)}
data-color={String(pathOptions?.color ?? '')}
data-weight={String(pathOptions?.weight ?? '')}
data-opacity={String(pathOptions?.opacity ?? '')}
data-dash={String(pathOptions?.dashArray ?? '')}
data-pane={pane ?? ''}
data-interactive={String(interactive)}
/>
),
}))
import DawarichTrailLayer from './DawarichTrailLayer'
function day(date: string, ...segments: Array<Array<[number, number]>>): DawarichTrackDay {
return {
date,
segments: segments.map((points, i) => ({
points,
mode: 'walking',
startedAt: date + 'T08:0' + i + ':00.000Z',
endedAt: date + 'T09:0' + i + ':00.000Z',
distanceMeters: 1200,
})),
}
}
function track(days: DawarichTrackDay[]): DawarichTrack {
return {
days,
source: 'tracks',
fetchedAt: '2026-05-03T10:00:00.000Z',
pointCount: days.reduce((sum, d) => sum + d.segments.reduce((n, s) => n + s.points.length, 0), 0),
truncated: false,
}
}
const DAY_ONE = day('2026-05-01', [[48.2, 16.37], [48.21, 16.38]])
const DAY_TWO = day('2026-05-02', [[45.46, 9.18], [45.47, 9.19]], [[45.5, 9.2], [45.51, 9.21]])
const lines = () => screen.queryAllByTestId('polyline')
describe('DawarichTrailLayer', () => {
it('FE-DAWARICH-TRAILUI-010: draws nothing at all when there is no recording', () => {
const { container } = render(<DawarichTrailLayer track={null} />)
expect(container.firstChild).toBeNull()
})
it('FE-DAWARICH-TRAILUI-011: ignores a segment that is a single fix rather than a line', () => {
// Dawarich reports a lone point for a day someone only opened the app on.
// Leaflet would accept the one-point polyline and draw nothing visible, but
// it would still be a layer, and every such day would add two of them.
const { container } = render(<DawarichTrailLayer track={track([day('2026-05-01', [[48.2, 16.37]])])} />)
expect(container.firstChild).toBeNull()
})
it('FE-DAWARICH-TRAILUI-012: draws every casing first, then every coloured line on top', () => {
render(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} casingPane="trek-track-casing" />)
const drawn = lines()
expect(drawn).toHaveLength(6) // three segments, casing + line each
// The first half is the casing pass. Reversing these two blocks would put
// the white line over the coloured one and the overlay would go white.
const casings = drawn.slice(0, 3)
const coloured = drawn.slice(3)
expect(casings.every(el => el.getAttribute('data-color') === DAWARICH_TRAIL_CASING)).toBe(true)
expect(coloured.some(el => el.getAttribute('data-color') === DAWARICH_TRAIL_CASING)).toBe(false)
})
it('FE-DAWARICH-TRAILUI-013: gives the casing the pane and the weight that make it a casing, and nothing catches clicks', () => {
render(<DawarichTrailLayer track={track([DAY_ONE])} casingPane="trek-track-casing" />)
const [casing, line] = lines()
expect(casing.getAttribute('data-pane')).toBe('trek-track-casing')
expect(casing.getAttribute('data-weight')).toBe('6')
expect(casing.getAttribute('data-opacity')).toBe('0.55')
// Only the casing takes the pane; the coloured line is left in Leaflet's
// default overlay pane. Pinned as the fact it is rather than as a rule:
// that pane also holds the planned route, and the trail is mounted after
// it, so the recorded line currently draws over the plan while the GL twin
// puts both of its layers under it.
expect(line.getAttribute('data-pane')).toBe('')
expect(line.getAttribute('data-weight')).toBe('3')
expect(line.getAttribute('data-dash')).toBe('6 5')
expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]]))
expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true)
})
it('FE-DAWARICH-TRAILUI-014: works without a casing pane on a renderer that has none', () => {
render(<DawarichTrailLayer track={track([DAY_ONE])} />)
// An older Leaflet build, or the GL renderer's Leaflet fallback, has no
// pane API and MapView passes undefined. Everything then lands in the
// default overlay pane in insertion order, which still keeps the casing
// under its own line, the one piece of the stacking that has to hold.
expect(lines().every(el => el.getAttribute('data-pane') === '')).toBe(true)
expect(lines()).toHaveLength(2)
})
it('FE-DAWARICH-TRAILUI-015: scrubbing to one day keeps that day its own colour', () => {
const { rerender } = render(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-02" />)
// Two segments on the second day, so two casings and two lines, and the hue
// is the SECOND of the palette even though it is now the only day drawn.
expect(lines()).toHaveLength(4)
expect(lines().slice(2).map(el => el.getAttribute('data-color')))
.toEqual([DAWARICH_DAY_COLORS[1], DAWARICH_DAY_COLORS[1]])
rerender(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-01" />)
expect(lines().slice(1).map(el => el.getAttribute('data-color'))).toEqual([DAWARICH_DAY_COLORS[0]])
// A day nobody recorded on falls back to the empty case rather than to the
// whole trip, which would be the worst possible answer to "show me Sunday".
rerender(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-09" />)
expect(lines()).toHaveLength(0)
})
})
@@ -0,0 +1,127 @@
/**
* FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-007: 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
* an empty overlay. Turning the layer on and seeing no line has four different
* causes (it is still loading, nothing was recorded on these dates, the
* Dawarich instance did not answer, the device is offline), and all four are
* answered by the label on this one control. If the label chain collapses to
* "hide recorded route" the map silently lies: the user sees a pressed button,
* no line, and no reason.
*
* So what is pinned here is the mapping from `status` to the label, and the
* second, wordless channel next to it: a problem state drains the mark to 0.35
* so it is visible without hovering, while loading (which is not a problem)
* stays at full strength. The off state has to win over all of it, because a
* dimmed logo on a switched-off control would read as broken rather than as
* off.
*/
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
import { DawarichTrailPill } from './DawarichTrailPill'
import type { DawarichTrailStatus } from './useDawarichTrail'
function pill(props: Partial<React.ComponentProps<typeof DawarichTrailPill>> = {}) {
return render(
<DawarichTrailPill active={false} status="idle" onToggle={vi.fn()} {...props} />,
)
}
const button = () => screen.getByTestId('dawarich-trail-pill')
const label = () => button().getAttribute('aria-label')
afterEach(() => {
vi.useRealTimers()
})
describe('DawarichTrailPill', () => {
it('FE-DAWARICH-TRAILUI-001: offers to show the route while the layer is off, whatever the last status was', () => {
const { rerender } = pill()
expect(label()).toBe('Show recorded route')
expect(button()).toHaveAttribute('aria-pressed', 'false')
expect(button().style.opacity).toBe('0.6')
// A stale failure from the last time the layer was on must not dim a
// switched-off control: off already has its own resting opacity, and a
// second, dimmer step would read as "this button is broken".
rerender(<DawarichTrailPill active={false} status="unavailable" onToggle={vi.fn()} />)
expect(label()).toBe('Show recorded route')
expect(button().style.opacity).toBe('0.6')
})
it('FE-DAWARICH-TRAILUI-002: offers to hide the route once a line is on the map', () => {
const { rerender } = pill({ active: true, status: 'ready' })
expect(label()).toBe('Hide recorded route')
expect(button()).toHaveAttribute('aria-pressed', 'true')
expect(button().style.opacity).toBe('1')
// `idle` is the state between the toggle flipping and the first fetch
// starting. It falls through the same arm as `ready` on purpose: the button
// has just been pressed and "hide" is the only honest thing it can offer.
rerender(<DawarichTrailPill active status="idle" onToggle={vi.fn()} />)
expect(label()).toBe('Hide recorded route')
expect(button().style.opacity).toBe('1')
})
it('FE-DAWARICH-TRAILUI-003: says it is loading without dimming the mark', () => {
pill({ active: true, status: 'loading' })
expect(label()).toMatch(/^Loading the recorded route/)
// Loading is not a problem, so the wordless "something is wrong" channel
// stays off. Otherwise every refresh would blink the control.
expect(button().style.opacity).toBe('1')
})
it('FE-DAWARICH-TRAILUI-004: names each reason there is no line and dims the mark for it', () => {
const reasons: Array<[DawarichTrailStatus, string]> = [
['empty', 'Nothing was recorded on these dates'],
['offline', 'The recorded route needs a connection'],
['unavailable', 'The recorded route could not be loaded'],
]
for (const [status, expected] of reasons) {
const { unmount } = pill({ active: true, status })
expect(label()).toBe(expected)
expect(button().style.opacity).toBe('0.35')
// The pressed state survives the problem: the layer really is on, and
// un-pressing it here would make the toggle disagree with the map.
expect(button()).toHaveAttribute('aria-pressed', 'true')
unmount()
}
})
it('FE-DAWARICH-TRAILUI-005: reports a press once', () => {
const onToggle = vi.fn()
pill({ onToggle })
fireEvent.click(button())
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-TRAILUI-006: lifts the mark under the pointer and puts it back', () => {
pill({ active: true, status: 'ready' })
fireEvent.mouseEnter(button())
expect(button().style.transform).toBe('scale(1.06)')
fireEvent.mouseLeave(button())
expect(button().style.transform).toBe('none')
})
it('FE-DAWARICH-TRAILUI-007: repeats the status in a readable tooltip rather than the native one', () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
pill({ active: true, status: 'empty' })
fireEvent.mouseEnter(button())
act(() => { vi.advanceTimersByTime(300) })
// The whole reason this control carries TREK's own tooltip: the native one
// never appears on a touch device, which is exactly where someone taps the
// button to find out why the map is empty.
expect(screen.getByRole('tooltip')).toHaveTextContent('Nothing was recorded on these dates')
})
})
@@ -0,0 +1,229 @@
/**
* dawarichTrail unit tests: FE-DAWARICH-TRAIL-001 to FE-DAWARICH-TRAIL-008.
*
* This module is the whole of what the two map renderers agree on about the
* recorded route: Leaflet turns `trailSegments` into `<Polyline>` elements, the
* GL map turns `trailGeoJson` into one source, and the pill reports
* `trailDistanceKm`. Nothing here touches a map, so every decision it makes can
* be pinned directly, and every one of them breaks silently, because a wrong
* line still looks exactly like a line.
*
* Four claims are worth holding:
*
* - **the day colour comes from the day's place in the whole track, never in
* the filtered list.** Picking a day on the timeline has to dim the rest of
* the trip while leaving the picked day the colour it already had; an index
* read off the filtered array would repaint every selection blue, and the
* map would quietly stop meaning "we were here on Tuesday".
* - **a segment that cannot be drawn is dropped, not drawn badly.** The pairs
* are narrowed at runtime rather than cast, so a ragged answer from someone
* else's Dawarich instance costs a point instead of putting a vertex at NaN
* and taking the rest of the overlay down with it.
* - **`[lat, lng]` becomes `[lng, lat]` exactly once.** This is the only
* coordinate flip in the overlay. A second flip, or none, drops the whole
* recording in the Gulf of Guinea, and nothing else in the repo watches
* that boundary.
* - **an unknown distance is not zero.** `null` means "Dawarich did not say",
* which the panel has to render as nothing rather than as "0 km travelled".
*/
import { describe, it, expect } from 'vitest'
import type { DawarichTrack, DawarichTrackDay, DawarichTrackSegment } from '@trek/shared'
import {
DAWARICH_DAY_COLORS,
DAWARICH_TRAIL_CASING,
DAWARICH_TRAIL_COLOR,
dayColor,
trailDistanceKm,
trailGeoJson,
trailSegments,
type DawarichTrailSegment,
} from './dawarichTrail'
const seg = (over: Partial<DawarichTrackSegment> = {}): DawarichTrackSegment => ({
points: over.points ?? [
[52.52, 13.405],
[52.51, 13.41],
],
mode: over.mode === undefined ? 'walking' : over.mode,
startedAt: over.startedAt ?? '2026-05-01T08:00:00Z',
endedAt: over.endedAt ?? '2026-05-01T09:00:00Z',
distanceMeters: over.distanceMeters === undefined ? 1200 : over.distanceMeters,
})
const track = (days: DawarichTrackDay[]): DawarichTrack => ({
days,
source: 'tracks',
fetchedAt: '2026-05-03T10:00:00Z',
pointCount: 42,
truncated: false,
})
/** Three calendar days, so "the second day" has a colour that is not the first day's. */
const THREE_DAYS = track([
{ date: '2026-05-01', segments: [seg(), seg({ mode: 'car', distanceMeters: 40_000 })] },
{ date: '2026-05-02', segments: [seg({ mode: null, distanceMeters: null })] },
{ date: '2026-05-03', segments: [seg()] },
])
/**
* The wire shapes a Dawarich instance can actually produce but the Zod tuple
* type cannot express: a pair that lost its longitude, and a pair whose
* latitude came back as text. The casts reproduce the bad answer rather than
* pretending the schema makes it impossible, which is exactly the assumption
* the runtime narrowing in `trailSegments` refuses to make.
*/
const RAGGED = [
[52.52, 13.405],
[52.51],
[52.5, 13.4],
] as unknown as Array<[number, number]>
const UNDRAWABLE = [
['north', 13.405],
[52.51, 13.41],
] as unknown as Array<[number, number]>
describe('dayColor', () => {
it('FE-DAWARICH-TRAIL-001: cycles the six day hues and wraps instead of running off the end', () => {
expect(DAWARICH_DAY_COLORS).toHaveLength(6)
expect(dayColor(0)).toBe(DAWARICH_DAY_COLORS[0])
expect(dayColor(5)).toBe(DAWARICH_DAY_COLORS[5])
// A fortnight-long trip must not hand back undefined on day seven.
expect(dayColor(6)).toBe(DAWARICH_DAY_COLORS[0])
expect(dayColor(13)).toBe(DAWARICH_DAY_COLORS[1])
// These two are what the Leaflet path options and the GL paint expressions
// both read. Changing either is a review decision, not a drive-by diff.
expect(DAWARICH_TRAIL_COLOR).toBe('#2563EB')
expect(DAWARICH_TRAIL_CASING).toBe('#FFFFFF')
})
})
describe('trailSegments', () => {
it('FE-DAWARICH-TRAIL-002: has nothing to draw before a track has been fetched', () => {
expect(trailSegments(null)).toEqual([])
// The timeline passes its selected day even while the first fetch is out.
expect(trailSegments(null, '2026-05-01')).toEqual([])
})
it('FE-DAWARICH-TRAIL-003: flattens every day into keyed segments carrying the day colour', () => {
const out = trailSegments(THREE_DAYS)
expect(out.map(s => s.id)).toEqual([
'2026-05-01-0',
'2026-05-01-1',
'2026-05-02-0',
'2026-05-03-0',
])
expect(out.map(s => s.color)).toEqual([
DAWARICH_DAY_COLORS[0],
DAWARICH_DAY_COLORS[0],
DAWARICH_DAY_COLORS[1],
DAWARICH_DAY_COLORS[2],
])
expect(out[1].mode).toBe('car')
expect(out[1].distanceMeters).toBe(40_000)
// An instance too old to classify the movement reports null, and null is
// what reaches the renderer: no invented transport mode.
expect(out[2].mode).toBeNull()
expect(out[2].distanceMeters).toBeNull()
expect(out[0].startedAt).toBe('2026-05-01T08:00:00Z')
expect(out[0].endedAt).toBe('2026-05-01T09:00:00Z')
expect(out[0].points).toEqual([
[52.52, 13.405],
[52.51, 13.41],
])
})
it('FE-DAWARICH-TRAIL-004: filtering to one day keeps that day its own colour', () => {
const only = trailSegments(THREE_DAYS, '2026-05-03')
expect(only.map(s => s.id)).toEqual(['2026-05-03-0'])
// The regression this test exists for: an index taken from the filtered
// array would be 0 here and repaint the third day in the first day's blue.
expect(only[0].color).toBe(DAWARICH_DAY_COLORS[2])
// An explicit null is the "no day picked" case the scrubber sends on reset.
expect(trailSegments(THREE_DAYS, null)).toHaveLength(4)
// A day with no recording is an empty overlay, not the whole trip.
expect(trailSegments(THREE_DAYS, '2026-04-30')).toEqual([])
})
it('FE-DAWARICH-TRAIL-005: drops what cannot be drawn and keeps the rest of the day numbered', () => {
const out = trailSegments(
track([
{
date: '2026-05-01',
segments: [
// A single recorded fix is a dot, not a line: Leaflet would draw
// nothing and the GL source would reject the LineString.
seg({ points: [[52.52, 13.405]] }),
// Two pairs, one of them without a usable latitude, so only one
// point survives the narrowing and this segment goes with it.
seg({ points: UNDRAWABLE }),
seg({ points: RAGGED }),
],
},
]),
)
expect(out).toHaveLength(1)
// The id keeps the segment's index inside the day rather than its index in
// the output: a renderer keys on it, and renumbering would remount every
// surviving line as soon as an earlier segment went missing.
expect(out[0].id).toBe('2026-05-01-2')
expect(out[0].points).toEqual([
[52.52, 13.405],
[52.5, 13.4],
])
})
it('FE-DAWARICH-TRAIL-006: a day that recorded nothing contributes nothing', () => {
expect(trailSegments(track([{ date: '2026-05-01', segments: [] }]))).toEqual([])
expect(trailSegments(track([]))).toEqual([])
})
})
describe('trailGeoJson', () => {
it('FE-DAWARICH-TRAIL-007: flips lat/lng once and carries the paint properties across', () => {
const collection = trailGeoJson(trailSegments(THREE_DAYS, '2026-05-02'))
expect(collection.type).toBe('FeatureCollection')
expect(collection.features).toHaveLength(1)
const [feature] = collection.features
expect(feature.type).toBe('Feature')
expect(feature.properties).toEqual({
id: '2026-05-02-0',
color: DAWARICH_DAY_COLORS[1],
date: '2026-05-02',
mode: null,
})
expect(feature.geometry.type).toBe('LineString')
// TREK stores [lat, lng]; GeoJSON wants [lng, lat]. Berlin sits at 52 N
// 13 E, so a missed flip lands the recording off the coast of Ghana.
expect(feature.geometry.coordinates).toEqual([
[13.405, 52.52],
[13.41, 52.51],
])
// Nothing recorded is an empty collection, which a GeoJSON source accepts,
// not the null it would choke on.
expect(trailGeoJson([])).toEqual({ type: 'FeatureCollection', features: [] })
})
})
describe('trailDistanceKm', () => {
it('FE-DAWARICH-TRAIL-008: sums the known metres into kilometres and says nothing when none are known', () => {
const withDistances: DawarichTrailSegment[] = trailSegments(THREE_DAYS)
// 1200 + 40000 + 1200 metres; the day Dawarich gave no distance for is
// skipped rather than counted as a zero-kilometre leg.
expect(trailDistanceKm(withDistances)).toBeCloseTo(42.4, 6)
const unknownOnly = trailSegments(
track([{ date: '2026-05-02', segments: [seg({ distanceMeters: null })] }]),
)
// null, not 0: "Dawarich did not say" and "you went nowhere" are different
// sentences and the pill is allowed to render only one of them.
expect(trailDistanceKm(unknownOnly)).toBeNull()
expect(trailDistanceKm([])).toBeNull()
})
})
@@ -0,0 +1,430 @@
/**
* useDawarichTrail unit tests: FE-DAWARICH-TRAIL-009 to FE-DAWARICH-TRAIL-024
* (the numbering continues the one in `dawarichTrail.test.ts`, so a grep for
* FE-DAWARICH-TRAIL finds the whole overlay in one list).
*
* This hook is the only fetcher for the recorded route. It is called once in
* `MapViewAuto` and the answer is handed to both renderers as a prop, so a bug
* here is not a rendering glitch. It is either a request storm against a
* server the user pays for and runs themselves, or a line on the map that is
* telling them about a different moment than the one they are looking at.
*
* What is pinned, and why each one matters:
*
* - **the gate.** `enabled && tripId && the addon is on`: three separate ways
* to be switched off, and the hook must reach the network for none of them.
* The addon check is the one a reviewer forgets, and forgetting it means an
* instance with Dawarich turned off still hammers `/api/dawarich/...` every
* two minutes.
* - **the offline contract.** `isEffectivelyOffline()` and not
* `navigator.onLine`, so the manual switch someone flipped before a flight
* is honoured; a request fired anyway blocks on a timeout instead of
* failing, and that is the case the user cannot see.
* - **staleness is said, never faked.** A refresh keeps the previous line on
* screen and only moves the status, because blanking the map makes a routine
* two-minute poll look like the recording was deleted. A failure keeps the
* line too, and says `unavailable`.
* - **nothing that arrives late is allowed to paint.** Three ways a response
* can be obsolete by the time it lands (the layer was switched off, the
* device went offline, a newer request superseded it), and all three have to
* end in the answer being dropped rather than drawn over a newer truth.
* - **the timer is torn down with the hook.** An interval that outlives the
* map is an unattributable request every two minutes, for the rest of the
* session.
*/
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { DawarichTrack } from '@trek/shared'
const { tripTrack } = vi.hoisted(() => ({ tripTrack: vi.fn() }))
vi.mock('../../repo/dawarichRepo', () => ({ dawarichRepo: { tripTrack } }))
import { seedStore } from '../../../tests/helpers/store'
import { useAddonStore } from '../../store/addonStore'
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode'
import { useDawarichTrail } from './useDawarichTrail'
const REFRESH_MS = 120_000
const trackWith = (dates: string[], fetchedAt = '2026-05-03T10:00:00Z'): DawarichTrack => ({
days: dates.map(date => ({
date,
segments: [
{
points: [
[52.52, 13.405],
[52.51, 13.41],
],
mode: 'walking',
startedAt: `${date}T08:00:00Z`,
endedAt: `${date}T09:00:00Z`,
distanceMeters: 1200,
},
],
})),
source: 'tracks',
fetchedAt,
pointCount: 2 * dates.length,
truncated: false,
})
const TRACK = trackWith(['2026-05-01'])
const NEWER = trackWith(['2026-05-01', '2026-05-02'], '2026-05-03T10:02:00Z')
const EMPTY: DawarichTrack = { ...trackWith([]), pointCount: 0 }
const addon = (enabled: boolean) => [
{ id: 'dawarich', name: 'Dawarich', description: '', type: 'integration', icon: 'map', enabled },
]
/**
* A promise the test settles by hand. "In flight" is a state this hook has
* opinions about (it keeps the old line, it aborts on the next load), and none
* of them are observable against a promise that has already resolved.
*/
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/** Let the repo promise and the state updates it schedules settle inside act. */
const flush = () =>
act(async () => {
await Promise.resolve()
await Promise.resolve()
})
/** jsdom's onLine is a prototype getter; an own property shadows it for one test. */
const setBrowserOnline = (value: boolean) =>
Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true })
const signalOf = (call: number): AbortSignal => tripTrack.mock.calls[call][2] as AbortSignal
beforeEach(() => {
tripTrack.mockReset()
tripTrack.mockResolvedValue(TRACK)
// The `isEnabled` selector is a stable function reference, so a store change
// after mount does not re-render the hook. Every test seeds before it renders.
seedStore(useAddonStore, { addons: addon(true), loaded: true })
_resetNetworkMode()
setBrowserOnline(true)
})
afterEach(() => {
vi.useRealTimers()
_resetNetworkMode()
setBrowserOnline(true)
})
describe('useDawarichTrail: the gate', () => {
it('FE-DAWARICH-TRAIL-009: stays idle and reaches for nothing while the layer is off', () => {
const { result } = renderHook(() => useDawarichTrail(7, false))
expect(result.current.status).toBe('idle')
expect(result.current.track).toBeNull()
expect(result.current.fetchedAt).toBeNull()
expect(tripTrack).not.toHaveBeenCalled()
})
it('FE-DAWARICH-TRAIL-010: a trip that has not loaded its id yet is not a trip to fetch for', () => {
const { result } = renderHook(() => useDawarichTrail(undefined, true))
expect(result.current.status).toBe('idle')
expect(tripTrack).not.toHaveBeenCalled()
})
it('FE-DAWARICH-TRAIL-011: an instance with the addon switched off never calls the integration', () => {
seedStore(useAddonStore, { addons: addon(false), loaded: true })
const { result } = renderHook(() => useDawarichTrail(7, true))
expect(result.current.status).toBe('idle')
expect(tripTrack).not.toHaveBeenCalled()
})
})
describe('useDawarichTrail: fetching', () => {
it('FE-DAWARICH-TRAIL-012: fetches the trip window with an abort signal and reports it ready', async () => {
const { result } = renderHook(() =>
useDawarichTrail(7, true, { from: '2026-05-01', to: '2026-05-03' }),
)
await waitFor(() => expect(result.current.status).toBe('ready'))
expect(result.current.track).toEqual(TRACK)
// fetchedAt comes off the answer, not off the clock: it is the server's
// statement about how fresh the recording is, and the pill quotes it.
expect(result.current.fetchedAt).toBe('2026-05-03T10:00:00Z')
expect(tripTrack).toHaveBeenCalledTimes(1)
expect(tripTrack.mock.calls[0][0]).toBe(7)
expect(tripTrack.mock.calls[0][1]).toEqual({ from: '2026-05-01', to: '2026-05-03' })
expect(signalOf(0)).toBeInstanceOf(AbortSignal)
expect(signalOf(0).aborted).toBe(false)
})
it('FE-DAWARICH-TRAIL-013: a window with no recording is empty, which is not the same as ready', async () => {
tripTrack.mockResolvedValue(EMPTY)
const { result } = renderHook(() => useDawarichTrail(7, true))
await waitFor(() => expect(result.current.status).toBe('empty'))
expect(result.current.track).toEqual(EMPTY)
expect(result.current.fetchedAt).toBe(EMPTY.fetchedAt)
})
it('FE-DAWARICH-TRAIL-014: a failed refresh keeps the line that is already drawn', async () => {
tripTrack.mockResolvedValueOnce(TRACK).mockRejectedValueOnce(new Error('502 from Dawarich'))
const { result } = renderHook(() => useDawarichTrail(7, true))
await waitFor(() => expect(result.current.status).toBe('ready'))
act(() => {
result.current.reload()
})
await waitFor(() => expect(result.current.status).toBe('unavailable'))
// A line from two minutes ago beats an empty map; the status is the part
// that admits it may be stale.
expect(result.current.track).toEqual(TRACK)
expect(result.current.fetchedAt).toBe(TRACK.fetchedAt)
expect(tripTrack).toHaveBeenCalledTimes(2)
})
it('FE-DAWARICH-TRAIL-015: a refresh does not blank the map while the next answer is in flight', async () => {
const second = deferred<DawarichTrack>()
tripTrack.mockResolvedValueOnce(TRACK).mockReturnValueOnce(second.promise)
const { result } = renderHook(() => useDawarichTrail(7, true))
await waitFor(() => expect(result.current.status).toBe('ready'))
act(() => {
result.current.reload()
})
// Not 'loading': flipping back to the spinner every two minutes would make
// a routine poll look like the recording had gone away.
expect(result.current.status).toBe('ready')
expect(result.current.track).toEqual(TRACK)
expect(tripTrack).toHaveBeenCalledTimes(2)
await act(async () => {
second.resolve(NEWER)
await Promise.resolve()
})
await waitFor(() => expect(result.current.track).toEqual(NEWER))
expect(result.current.fetchedAt).toBe(NEWER.fetchedAt)
})
it('FE-DAWARICH-TRAIL-016: changing the window refetches, re-rendering with the same window does not', async () => {
type Range = { from?: string; to?: string }
const { result, rerender } = renderHook(
({ range }: { range: Range }) => useDawarichTrail(7, true, range),
{ initialProps: { range: { from: '2026-05-01' } as Range } },
)
await waitFor(() => expect(result.current.status).toBe('ready'))
expect(tripTrack).toHaveBeenCalledTimes(1)
// A fresh object literal with the same window: the parent produces one on
// every render, and re-fetching on each of them is the bug the range key
// exists to prevent.
rerender({ range: { from: '2026-05-01' } })
await flush()
expect(tripTrack).toHaveBeenCalledTimes(1)
rerender({ range: { from: '2026-05-01', to: '2026-05-04' } })
await waitFor(() => expect(tripTrack).toHaveBeenCalledTimes(2))
expect(tripTrack.mock.calls[1][1]).toEqual({ from: '2026-05-01', to: '2026-05-04' })
})
})
describe('useDawarichTrail: offline', () => {
it('FE-DAWARICH-TRAIL-017: a device that is offline by choice is never asked to reach the network', () => {
setForcedOffline(true)
const { result } = renderHook(() => useDawarichTrail(7, true))
expect(result.current.status).toBe('offline')
expect(tripTrack).not.toHaveBeenCalled()
})
it('FE-DAWARICH-TRAIL-018: going offline mid-flight aborts the request and drops the answer', async () => {
const first = deferred<DawarichTrack>()
tripTrack.mockReturnValueOnce(first.promise)
const { result } = renderHook(() => useDawarichTrail(7, true))
expect(result.current.status).toBe('loading')
act(() => {
setForcedOffline(true)
})
// The hook subscribes to network-mode changes, so the switch re-enters the
// load immediately rather than waiting out the two-minute timer.
expect(signalOf(0).aborted).toBe(true)
expect(result.current.status).toBe('offline')
expect(tripTrack).toHaveBeenCalledTimes(1)
await act(async () => {
first.resolve(TRACK)
await Promise.resolve()
})
// The answer belonged to a request the hook has already given up on.
// Painting it now would contradict the status the user is reading.
expect(result.current.status).toBe('offline')
expect(result.current.track).toBeNull()
})
it('FE-DAWARICH-TRAIL-019: a request that fails after the connection dropped says offline, not unavailable', async () => {
tripTrack.mockImplementationOnce(async () => {
// The browser drops the connection after the request left: the pre-flight
// check passed, and only the failure knows the device is gone.
setBrowserOnline(false)
throw new Error('network error')
})
const { result } = renderHook(() => useDawarichTrail(7, true))
await waitFor(() => expect(result.current.status).toBe('offline'))
expect(result.current.track).toBeNull()
})
})
describe('useDawarichTrail: lifecycle', () => {
it('FE-DAWARICH-TRAIL-020: switching the layer off mid-flight discards the answer instead of drawing it', async () => {
const first = deferred<DawarichTrack>()
tripTrack.mockReturnValueOnce(first.promise)
const { result, rerender } = renderHook(({ on }: { on: boolean }) => useDawarichTrail(7, on), {
initialProps: { on: true },
})
expect(result.current.status).toBe('loading')
rerender({ on: false })
expect(result.current.status).toBe('idle')
expect(signalOf(0).aborted).toBe(true)
await act(async () => {
first.resolve(TRACK)
await Promise.resolve()
})
// Nobody asked for this line any more; drawing it would put the overlay
// back on a map the user just cleared.
expect(result.current.status).toBe('idle')
expect(result.current.track).toBeNull()
})
it('FE-DAWARICH-TRAIL-021: a failure that lands after the layer was switched off stays quiet', async () => {
const first = deferred<DawarichTrack>()
tripTrack.mockReturnValueOnce(first.promise)
const { result, rerender } = renderHook(({ on }: { on: boolean }) => useDawarichTrail(7, on), {
initialProps: { on: true },
})
rerender({ on: false })
await act(async () => {
first.reject(new Error('too late'))
await Promise.resolve()
})
// 'unavailable' here would light up a warning pill about a layer that is
// not on screen.
expect(result.current.status).toBe('idle')
})
it('FE-DAWARICH-TRAIL-022: switching off clears the drawn track and switching back on refetches', async () => {
const { result, rerender } = renderHook(({ on }: { on: boolean }) => useDawarichTrail(7, on), {
initialProps: { on: true },
})
await waitFor(() => expect(result.current.status).toBe('ready'))
expect(result.current.track).toEqual(TRACK)
rerender({ on: false })
expect(result.current.track).toBeNull()
expect(result.current.status).toBe('idle')
// fetchedAt is deliberately not cleared: it describes the last answer the
// hook actually received, and the pill reads it again on the way back.
expect(result.current.fetchedAt).toBe(TRACK.fetchedAt)
rerender({ on: true })
await waitFor(() => expect(result.current.status).toBe('ready'))
expect(tripTrack).toHaveBeenCalledTimes(2)
expect(result.current.track).toEqual(TRACK)
})
it('FE-DAWARICH-TRAIL-023: refreshes on the two-minute timer and stops the timer with the hook', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const { result, unmount } = renderHook(() => useDawarichTrail(7, true))
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(result.current.status).toBe('ready')
expect(tripTrack).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(REFRESH_MS)
})
expect(tripTrack).toHaveBeenCalledTimes(2)
// Each poll supersedes the one before it, so a slow answer can never land
// on top of a newer one.
expect(signalOf(0).aborted).toBe(true)
unmount()
expect(signalOf(1).aborted).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(REFRESH_MS * 3)
})
// An interval that outlived the map would be a request every two minutes
// for the rest of the session, against somebody's own hardware.
expect(tripTrack).toHaveBeenCalledTimes(2)
})
it('FE-DAWARICH-TRAIL-024: a superseded request that fails afterwards leaves the newer one alone', async () => {
const superseded = deferred<DawarichTrack>()
const current = deferred<DawarichTrack>()
tripTrack.mockReturnValueOnce(superseded.promise).mockReturnValueOnce(current.promise)
const { result } = renderHook(() => useDawarichTrail(7, true))
expect(result.current.status).toBe('loading')
// Offline and straight back: two network-mode notifications inside the same
// effect, so the first request is abandoned without the hook being torn down
// or reloaded. That is the only way to get an aborted request whose failure
// still arrives at a live listener, which is what the abort check in the
// failure path is for. A timer tick does the same thing two minutes later.
act(() => {
setForcedOffline(true)
})
expect(result.current.status).toBe('offline')
act(() => {
setForcedOffline(false)
})
expect(tripTrack).toHaveBeenCalledTimes(2)
expect(signalOf(0).aborted).toBe(true)
expect(signalOf(1).aborted).toBe(false)
expect(result.current.status).toBe('loading')
await act(async () => {
superseded.reject(new Error('aborted while the next request was already out'))
// The failure travels through the then() before it reaches the catch(),
// so one tick is not enough to see it land.
await Promise.resolve()
await Promise.resolve()
})
// 'unavailable' here would put a warning on the map about a request the hook
// threw away itself, while the request it is actually waiting for is fine.
expect(result.current.status).toBe('loading')
await act(async () => {
current.resolve(TRACK)
await Promise.resolve()
})
await waitFor(() => expect(result.current.status).toBe('ready'))
expect(result.current.track).toEqual(TRACK)
})
})
@@ -0,0 +1,317 @@
/**
* FE-DAWARICH-TRAILUI-020 to FE-DAWARICH-TRAILUI-029: 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
* one that gets its lifecycle wrong leaks: sources and layers survive the React
* tree that created them, and a GL map carrying a layer nobody owns any more
* throws on the next style change instead of at the point of the mistake. So
* the things pinned here are the ones whose failure is invisible at the call
* site:
*
* - **the guard.** `mapRef.current` is null on the first render and the style
* is not loaded for several frames after that. Adding a layer in either
* window throws inside an effect, which React reports as a render error on a
* component that has nothing to do with Dawarich.
* - **the anchor.** `beforeId` is only usable once that layer actually exists
* on the current style; passing an unknown id to `addLayer` is a throw, not
* a no-op, and the layer it names belongs to a different overlay that may
* not have mounted yet.
* - **the `trek-` prefix on every id**, which is what `applySatellite` looks
* for when it decides where to slot the satellite tile. A layer called
* `dawarich-trail` would end up underneath the satellite image and simply
* not be there for the people most likely to want it.
* - **`setData` over remove-and-add** when the source is still there, because
* a rebuilt source flickers. The two-minute refresh does not reach that arm:
* a fresh track object re-runs the effect and the teardown takes the source
* with it, so what setData actually covers is a style change that kept its
* 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.
* - **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.
* - **the teardown**, which has to take both layers and the source away, and
* has to survive a style rebuild that already took them.
*/
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { Map as MapboxMap } from 'mapbox-gl'
import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
import { DAWARICH_DAY_COLORS, DAWARICH_TRAIL_CASING } from './dawarichTrail'
import { useDawarichTrailGL } from './useDawarichTrailGL'
const SOURCE = 'trek-dawarich-trail'
const CASING_LAYER = 'trek-dawarich-trail-casing'
const LINE_LAYER = 'trek-dawarich-trail-line'
interface FakeSource {
data: unknown
setData: ReturnType<typeof vi.fn>
}
/**
* A GL map double that keeps real registries rather than returning fixed
* values: the hook asks "is this already there?" before every decision it
* makes, so a double that always answers the same way cannot exercise the
* decisions at all.
*/
function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
const sources = new Map<string, FakeSource>()
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 state = { styleLoaded }
const map = {
isStyleLoaded: vi.fn(() => state.styleLoaded),
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string, spec: { type: string; data: unknown }) => {
const entry: FakeSource = {
data: spec.data,
setData: vi.fn((next: unknown) => { entry.data = next }),
}
sources.set(id, entry)
}),
removeSource: vi.fn((id: string) => { sources.delete(id) }),
getLayer: vi.fn((id: string) => layers.get(id)),
addLayer: vi.fn((spec: { id: string; paint?: Record<string, unknown> }, before?: string) => {
layers.set(spec.id, { spec, before })
}),
removeLayer: vi.fn((id: string) => { layers.delete(id) }),
on: vi.fn((type: string, fn: () => void) => {
const set = listeners.get(type) ?? new Set<() => void>()
set.add(fn)
listeners.set(type, set)
}),
off: vi.fn((type: string, fn: () => void) => { listeners.get(type)?.delete(fn) }),
}
return {
map: map as unknown as MapboxMap,
spies: map,
sources,
layers,
state,
styleLoadListeners: () => listeners.get('style.load')?.size ?? 0,
emitStyleLoad: () => { listeners.get('style.load')?.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
| { features: Array<{ properties: { color: string; date: string }; geometry: { coordinates: Array<[number, number]> } }> }
| undefined,
}
}
interface Props {
map: MapboxMap | null
ready: boolean
track?: DawarichTrack | null
date?: string | null
before?: string
}
const mount = (props: Props) =>
renderHook((p: Props) => useDawarichTrailGL(p.map, p.ready, p.track, p.date, p.before), {
initialProps: props,
})
function day(date: string, ...segments: Array<Array<[number, number]>>): DawarichTrackDay {
return {
date,
segments: segments.map(points => ({
points,
mode: 'driving',
startedAt: date + 'T08:00:00.000Z',
endedAt: date + 'T09:00:00.000Z',
distanceMeters: 4200,
})),
}
}
function track(days: DawarichTrackDay[]): DawarichTrack {
return { days, source: 'tracks', fetchedAt: '2026-05-03T10:00:00.000Z', pointCount: 4, truncated: false }
}
const DAY_ONE = day('2026-05-01', [[48.2, 16.37], [48.21, 16.38]])
const DAY_TWO = day('2026-05-02', [[45.46, 9.18], [45.47, 9.19]])
describe('useDawarichTrailGL', () => {
it('FE-DAWARICH-TRAILUI-020: touches nothing before there is a map and a ready style', () => {
const gl = fakeGlMap()
const view = mount({ map: null, ready: true, track: track([DAY_ONE]) })
expect(gl.spies.addSource).not.toHaveBeenCalled()
// A map that exists but is not ready is the more dangerous half: the object
// answers every call and throws from inside GL rather than from here.
view.rerender({ map: gl.map, ready: false, track: track([DAY_ONE]) })
expect(gl.spies.addSource).not.toHaveBeenCalled()
expect(gl.styleLoadListeners()).toBe(0)
view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE]) })
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-TRAILUI-021: builds the source and both layers under TREK-prefixed ids, anchored below the planned route', () => {
const gl = fakeGlMap({ styleLayers: ['trip-route-casing'] })
mount({ map: gl.map, ready: true, track: track([DAY_ONE]), before: 'trip-route-casing' })
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }))
const [casingCall, lineCall] = gl.spies.addLayer.mock.calls
expect(casingCall[0].id).toBe(CASING_LAYER)
expect(casingCall[0].paint['line-color']).toBe(DAWARICH_TRAIL_CASING)
expect(casingCall[0].paint['line-width']).toBe(6)
expect(lineCall[0].id).toBe(LINE_LAYER)
// Per-feature colour, with the casing white as the fallback a feature
// without one falls back to. The GPX layer uses the same shape.
expect(lineCall[0].paint['line-color']).toEqual(['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING])
// Dashed, so the recording is distinguishable from the planned route for
// someone who cannot tell the two hues apart.
expect(lineCall[0].paint['line-dasharray']).toEqual([2, 1.6])
// Both go beneath the planned route's casing. The line is inserted second
// and therefore ends up above its own casing and still below the plan.
expect(casingCall[1]).toBe('trip-route-casing')
expect(lineCall[1]).toBe('trip-route-casing')
})
it('FE-DAWARICH-TRAILUI-022: appends on top when the anchor layer is not on the style', () => {
// The trip-route overlay may not have mounted yet, or may be switched off
// entirely. Passing its id to addLayer then throws, so the hook has to look
// before it leaps and simply append instead.
const absent = fakeGlMap()
mount({ map: absent.map, ready: true, track: track([DAY_ONE]), before: 'trip-route-casing' })
expect(absent.spies.addLayer.mock.calls.map(call => call[1])).toEqual([undefined, undefined])
const none = fakeGlMap()
mount({ map: none.map, ready: true, track: track([DAY_ONE]) })
expect(none.spies.addLayer.mock.calls.map(call => call[1])).toEqual([undefined, undefined])
})
it('FE-DAWARICH-TRAILUI-023: waits for style.load instead of drawing into a half-loaded style', () => {
const gl = fakeGlMap({ styleLoaded: false })
mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
expect(gl.spies.addSource).not.toHaveBeenCalled()
expect(gl.styleLoadListeners()).toBe(1)
// The whole GL map is torn down and reassembled when the provider, the
// style or the token changes; this subscription is what puts the overlay
// back afterwards.
gl.state.styleLoaded = true
gl.emitStyleLoad()
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
expect(gl.spies.addLayer).toHaveBeenCalledTimes(2)
})
it('FE-DAWARICH-TRAILUI-024: updates a surviving source in place rather than rebuilding it', () => {
const gl = fakeGlMap()
mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
// A style change that kept its sources, which is what setStyle does when it
// can diff the two. Rebuilding the source here would flicker the line for
// nothing, so the hook writes into the one that is already on the map.
gl.emitStyleLoad()
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
expect(gl.spies.addLayer).toHaveBeenCalledTimes(2)
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenCalledTimes(1)
})
it('FE-DAWARICH-TRAILUI-025: hands GL the recording in GeoJSON order, and an empty collection when there is none', () => {
const empty = fakeGlMap()
mount({ map: empty.map, ready: true, track: null })
// An empty collection rather than a skipped source: the layers still have
// to be on the style, so a style change that keeps its sources finds
// something to write into instead of leaving the map bare.
expect(empty.drawn()?.features).toEqual([])
// The track parameter is optional, so the overlay also has to build before
// anyone has handed the hook a track at all. Same empty collection, reached
// through the `?? null` rather than through an explicit null.
const unfetched = fakeGlMap()
mount({ map: unfetched.map, ready: true })
expect(unfetched.drawn()?.features).toEqual([])
const gl = fakeGlMap()
mount({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) })
const features = gl.drawn()?.features ?? []
expect(features).toHaveLength(2)
// TREK stores [lat, lng] everywhere; GeoJSON wants the other order, and the
// overlay is the single place that flips it. Getting this backwards puts
// Vienna in the Indian Ocean.
expect(features[0].geometry.coordinates).toEqual([[16.37, 48.2], [16.38, 48.21]])
expect(features[0].properties.color).toBe(DAWARICH_DAY_COLORS[0])
expect(features[1].properties.color).toBe(DAWARICH_DAY_COLORS[1])
})
it('FE-DAWARICH-TRAILUI-026: redraws for a picked day without repainting it', () => {
const gl = fakeGlMap()
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) })
view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]), date: '2026-05-02' })
const features = gl.drawn()?.features ?? []
expect(features).toHaveLength(1)
expect(features[0].properties.date).toBe('2026-05-02')
// Still the second hue, because the index comes from the day's place in the
// whole track. Picking a day must dim the others, not recolour the one.
expect(features[0].properties.color).toBe(DAWARICH_DAY_COLORS[1])
})
it('FE-DAWARICH-TRAILUI-027: takes both layers and the source away with it, and stops listening', () => {
const gl = fakeGlMap()
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
view.unmount()
// Layers before the source, in that order: a GL map refuses to remove a
// source that still carries a layer, so a teardown that started with the
// source would throw and leave both layers behind.
expect(gl.spies.removeLayer.mock.calls.map(call => call[0])).toEqual([LINE_LAYER, CASING_LAYER])
expect(gl.spies.removeSource).toHaveBeenCalledWith(SOURCE)
expect(gl.layers.size).toBe(0)
expect(gl.sources.size).toBe(0)
expect(gl.styleLoadListeners()).toBe(0)
})
it('FE-DAWARICH-TRAILUI-028: does not try to remove what a style rebuild already took', () => {
const gl = fakeGlMap()
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
// setStyle drops every source and layer the previous style carried. The
// teardown then runs against a map that has never heard of them, and a
// blind removeLayer there throws out of a cleanup function.
gl.dropStyle()
view.unmount()
expect(gl.spies.removeLayer).not.toHaveBeenCalled()
expect(gl.spies.removeSource).not.toHaveBeenCalled()
expect(gl.spies.off).toHaveBeenCalledWith('style.load', expect.any(Function))
})
it('FE-DAWARICH-TRAILUI-029: rebuilds the overlay when the refresh hands it a new track, without leaving a listener behind', () => {
const gl = fakeGlMap()
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
const firstSource = gl.sources.get(SOURCE)
expect(firstSource?.setData).toBeTypeOf('function')
// `useDawarichTrail` refetches every two minutes and returns a fresh object
// every time, so the effect's dependency changes and React tears the whole
// overlay down before rebuilding it. The setData path above belongs to a
// style change that kept its sources, not to a refresh. Pinned as the
// behaviour it is, so that making the refresh cheap later is a visible
// change to this file rather than a silent one on the map.
view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) })
expect(gl.spies.removeSource).toHaveBeenCalledWith(SOURCE)
expect(gl.spies.addSource).toHaveBeenCalledTimes(2)
expect(firstSource?.setData).not.toHaveBeenCalled()
expect(gl.drawn()?.features).toHaveLength(2)
// One subscription, not two. The cleanup unsubscribes before the next
// effect subscribes; without that the overlay would redraw once per style
// change per refresh, for as long as the map lives.
expect(gl.styleLoadListeners()).toBe(1)
})
})