mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user