forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMapView.test.tsx
More file actions
766 lines (637 loc) · 27.5 KB
/
Copy pathMapView.test.tsx
File metadata and controls
766 lines (637 loc) · 27.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { StrictMode } from 'react'
import { act, render, cleanup, screen, waitFor } from '@testing-library/react'
import { MockMap, resetMapLibreMock } from '../test/mocks/maplibre-gl'
import { MapView } from './MapView'
import { BACKDROP_LAYER_ID, MAP_BACKDROP, TRAILS_SOURCE_ID } from './style'
import { LIVE_TOPO_LAYER_IDS, TOPO_PALETTE_RED } from './liveTopo'
import { poiIconId } from './poiIcons'
import {
poiFeatureCollection,
poiFilter,
POI_ID_PROPERTY,
POI_DOT_LAYER_ID,
POI_LAYER_ID,
POI_SOURCE_ID,
} from './poiLayers'
import {
closureFeatureCollection,
CLOSURE_SOURCE_ID,
type ClosureBand,
} from './closureLayers'
import {
warningFeatureCollection,
WARNING_LAYER_ID,
WARNING_SOURCE_ID,
type WarningPoint,
} from './warningLayers'
import { WARNING_ICON_ID } from './warningPin'
import type { MapPoint } from '../lib/legendContents'
// Lifecycle is the whole risk surface here. A map that gets built twice means
// two WebGL contexts, two GPS watchers and doubled tile reads off a 314 MB
// on-device archive; a map that never gets torn down leaks all of the same.
// React StrictMode deliberately mounts -> unmounts -> remounts in development
// precisely to expose that class of bug, so these tests run under it.
const { registrationOrder, basemapOrder, workerOrder } = vi.hoisted(() => ({
registrationOrder: [] as number[],
basemapOrder: [] as number[],
workerOrder: [] as number[],
}))
vi.mock('maplibre-gl', () => import('../test/mocks/maplibre-gl'))
// Records how many maps existed at the moment the protocol was registered.
// A 0 proves registration happened BEFORE any map was constructed - which it
// must, or the map cannot resolve its own pmtiles:// style URL.
vi.mock('./protocol', async () => {
const { MockMap: Recorded } = await import('../test/mocks/maplibre-gl')
return {
PMTILES_SCHEME: 'pmtiles',
registerPMTilesProtocol: vi.fn(() => {
registrationOrder.push(Recorded.instances.length)
}),
}
})
// The basemap:// scheme has the same before-any-map requirement: the live
// style's osm source declares a basemap:// tiles template, and a map built
// first would ask for tiles through a scheme nothing answers.
vi.mock('./basemap', async () => {
const { MockMap: Recorded } = await import('../test/mocks/maplibre-gl')
return {
registerBasemapProtocol: vi.fn(() => {
basemapOrder.push(Recorded.instances.length)
}),
}
})
// The same recording for the worker, which has the same before-any-map
// requirement and a much quieter failure: MapLibre keeps ONE worker pool per
// page and builds it for the first map, so a URL set after that is a URL the
// pool never reads. The map then parses no tiles at all and draws nothing but
// its background colour, with no error anywhere - see mapWorker.ts.
vi.mock('./mapWorker', async () => {
const { MockMap: Recorded } = await import('../test/mocks/maplibre-gl')
return {
registerMapWorker: vi.fn(() => {
workerOrder.push(Recorded.instances.length)
return '/assets/maplibre-gl-worker-test.js'
}),
}
})
const PROPS = {
topoArchiveUrl: 'pmtiles://ourhike-corridor',
trailsUrl: '/data/trails.geojson',
}
// Already in map coordinates, which is the contract: turning a mile marker
// into a line needs the centerline index, and that is the shell's job.
const CLOSURES: readonly ClosureBand[] = [
{
id: 'c1',
lines: [
[
[-77.1, 39.3],
[-77.1, 39.32],
],
],
},
]
const WARNINGS: readonly WarningPoint[] = [{ id: 'r1', lon: -77.2, lat: 39.4 }]
beforeEach(() => {
resetMapLibreMock()
registrationOrder.length = 0
basemapOrder.length = 0
workerOrder.length = 0
})
afterEach(() => {
cleanup()
})
describe('MapView', () => {
it('frames the opening box against the room the caller says it has', () => {
// First run draws the steps OVER this map, so the box has to be fitted to
// the strip they leave rather than to the whole canvas. Without this the
// corridor was fitted to a full-height map and then three quarters of it
// was covered, leaving a fragment of Maine in the corner above the card
// while the sentence beside it said "the whole trail".
const padding = { top: 24, bottom: 658, left: 24, right: 24 }
const corridor: [[number, number], [number, number]] = [
[-84.73, 34.2],
[-68.3, 46.34],
]
render(<MapView {...PROPS} bounds={corridor} boundsPadding={padding} />)
expect(MockMap.instances.at(-1)?.options.fitBoundsOptions).toEqual({ padding })
})
it('leaves exactly one LIVE map after StrictMode’s deliberate double-invoke', () => {
render(
<StrictMode>
<MapView {...PROPS} />
</StrictMode>,
)
// React mounts, tears down, and remounts on purpose here, so more than one
// map may have been CONSTRUCTED over the render's lifetime. What must never
// happen is two of them being alive at once - that is the actual leak.
expect(MockMap.live).toHaveLength(1)
})
it('tears the map down on unmount, leaving nothing live', () => {
const { unmount } = render(
<StrictMode>
<MapView {...PROPS} />
</StrictMode>,
)
unmount()
expect(MockMap.live).toHaveLength(0)
expect(MockMap.instances.every((m) => m.removed)).toBe(true)
})
it('does not rebuild the map when re-rendered with a fresh center array identity', () => {
// A parent passing center={[x, y]} inline hands over a new array every
// render. If that landed in the effect's dependencies the map would be
// destroyed and rebuilt on every parent render - catastrophic, and easy to
// do by accident.
const { rerender } = render(<MapView {...PROPS} center={[-77.1, 39.3]} zoom={12} />)
const afterFirstRender = MockMap.instances.length
rerender(<MapView {...PROPS} center={[-77.1, 39.3]} zoom={12} />)
rerender(<MapView {...PROPS} center={[-77.1, 39.3]} zoom={12} />)
expect(MockMap.instances).toHaveLength(afterFirstRender)
expect(MockMap.live).toHaveLength(1)
})
it('registers the pmtiles protocol before constructing any map', () => {
render(<MapView {...PROPS} />)
expect(registrationOrder.length).toBeGreaterThan(0)
expect(registrationOrder[0]).toBe(0)
})
it('registers the basemap protocol before constructing any map', () => {
render(<MapView {...PROPS} />)
expect(basemapOrder.length).toBeGreaterThan(0)
expect(basemapOrder[0]).toBe(0)
})
it('points MapLibre at its bundled worker before constructing any map', () => {
// Not a detail of setup order: with no worker MapLibre parses no tiles of
// any kind, so the basemap, the contours, the trail line and the pins all
// draw nothing and the map is a blank sheet of paper. It shipped that way.
render(<MapView {...PROPS} />)
expect(workerOrder.length).toBeGreaterThan(0)
expect(workerOrder[0]).toBe(0)
})
it('builds the map against the container it rendered, using the style URLs it was given', () => {
render(<MapView {...PROPS} />)
const [map] = MockMap.live
expect(map.options.container).toBeInstanceOf(HTMLElement)
expect(map.options.style).toBeTypeOf('object')
})
it('exposes the map canvas as a labelled region rather than an unnamed div', () => {
render(<MapView {...PROPS} />)
expect(screen.getByRole('region', { name: /trail map/i })).toBeInTheDocument()
})
it('attaches the map chrome once the map exists', () => {
render(<MapView {...PROPS} />)
const [map] = MockMap.live
expect(map.controls.length).toBeGreaterThan(0)
})
it('re-attaches chrome for a units change without rebuilding the map underneath the hiker', () => {
// Switching the scale bar to metric is a display preference. Rebuilding the
// whole map for it would drop the WebGL context and re-read tiles - a
// visible flash mid-walk for what should be a three-control swap.
const { rerender } = render(<MapView {...PROPS} units="imperial" />)
const builtInitially = MockMap.instances.length
rerender(<MapView {...PROPS} units="metric" />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(MockMap.live).toHaveLength(1)
})
it('repaints for a theme change without rebuilding the map underneath the hiker', () => {
// The theme is the preference most likely to change mid-walk - a phone on
// 'auto' flips to dark at sunset, which is when this app is most likely
// to be out. A rebuild there would take the map from a hiker reading it
// in fading light. The construction effect omits `theme` on purpose
// (MapView.tsx); this is that omission's regression test.
const { rerender } = render(<MapView {...PROPS} theme="light" />)
const builtInitially = MockMap.instances.length
const [map] = MockMap.live
act(() => map.emit('load'))
rerender(<MapView {...PROPS} theme="dark" />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(MockMap.live).toHaveLength(1)
// And the repaint really happened - the backdrop took the dark colour in
// place rather than waiting for some future rebuild to apply it.
expect(map.paintProperties.get(`${BACKDROP_LAYER_ID}/background-color`)).toBe(
MAP_BACKDROP.dark,
)
})
it('repaints for a map-style or red-light change without rebuilding the map', () => {
// MAP_STYLE_SPEC.md spells this as a requirement rather than a nicety:
// appearance preferences are display-only and never a map rebuild. The
// construction effect omits `mapStyle` and `redLight` exactly as it omits
// `theme`; this is that omission's regression test.
const { rerender } = render(<MapView {...PROPS} mapStyle="field" />)
const builtInitially = MockMap.instances.length
const [map] = MockMap.live
act(() => map.emit('load'))
rerender(<MapView {...PROPS} mapStyle="night_hike" redLight />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(MockMap.live).toHaveLength(1)
// And the repaint landed: red light's own ink on the backdrop, in place.
expect(map.paintProperties.get(`${BACKDROP_LAYER_ID}/background-color`)).toBe(
TOPO_PALETTE_RED.labelHalo,
)
})
it('rewires visibility for a detail change without rebuilding the map', () => {
const { rerender } = render(<MapView {...PROPS} detail="standard" />)
const builtInitially = MockMap.instances.length
const [map] = MockMap.live
act(() => map.emit('load'))
rerender(<MapView {...PROPS} detail="minimal" />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(MockMap.live).toHaveLength(1)
expect(map.layoutProperties.get(`${LIVE_TOPO_LAYER_IDS.track}/visibility`)).toBe(
'none',
)
})
it('re-points the trail source for new lines without rebuilding the map', () => {
// The lines come out of IndexedDB a beat after the map is built, so this
// omission is the one a cold start actually pays for: depending on the URL
// meant every launch tore the map down and built a second one a second
// after the first appeared. They are a GeoJSON source, and a
// GeoJSON source takes a new URL in place - the same treatment the POIs,
// closures and warnings have always had.
const { rerender } = render(<MapView {...PROPS} trailsUrl="/data/empty.geojson" />)
const builtInitially = MockMap.instances.length
const [map] = MockMap.live
act(() => map.emit('load'))
rerender(<MapView {...PROPS} trailsUrl="/data/trails.geojson" />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(MockMap.live).toHaveLength(1)
// And the lines really landed, rather than waiting on a rebuild that no
// longer comes.
expect(map.sourceData.get(TRAILS_SOURCE_ID)).toBe('/data/trails.geojson')
})
it('does not re-push lines the style was built holding', () => {
// Seeding the style is what puts the trail on the very first frame when
// the lines are already known. Writing them in again straight afterwards
// would re-fetch and re-tile twelve megabytes of coordinates for a source
// that already holds them.
render(<MapView {...PROPS} trailsUrl="/data/trails.geojson" />)
const [map] = MockMap.live
act(() => map.emit('load'))
expect(map.options.style).toMatchObject({
sources: { [TRAILS_SOURCE_ID]: { data: '/data/trails.geojson' } },
})
expect(map.sourceData.has(TRAILS_SOURCE_ID)).toBe(false)
})
it('leaves no load listener behind after unmount', () => {
const { unmount } = render(<MapView {...PROPS} />)
const [map] = MockMap.live
unmount()
expect(map.listenerCount('load')).toBe(0)
})
it('leaves no controls attached after unmount', () => {
const { unmount } = render(<MapView {...PROPS} />)
const [map] = MockMap.live
unmount()
expect(map.controls).toHaveLength(0)
})
// Unmounting runs the effect cleanups in the order the effects were declared,
// and the map-building one is first - so `map.remove()` happens, and every
// cleanup after it is handed a map that no longer exists. Anything that
// throws there escapes React's commit phase, and with no error boundary in
// the tree that unmounts the whole root: the screen the hiker was switching
// TO never renders at all.
it('unmounts without throwing, even though its own cleanup removes the map first', () => {
const { unmount } = render(<MapView {...PROPS} />)
expect(() => unmount()).not.toThrow()
})
// The same teardown, on the path that does not end the screen. `background`
// is a dependency of the map-building effect, so switching to the offline
// archive removes one map and builds another - and the chrome cleanup that
// follows still holds the map that was just removed.
it('switches background without throwing on the map it just tore down', () => {
const { rerender } = render(<MapView {...PROPS} background="hiking_topo_live" />)
expect(() =>
rerender(<MapView {...PROPS} background="usgs_topo_offline" />),
).not.toThrow()
expect(MockMap.live).toHaveLength(1)
})
})
describe('POI pins', () => {
const POIS: MapPoint[] = [
{ id: 'w1', type: 'water', lat: 39.3, lon: -77.1, confidence: 'high' },
{ id: 's1', type: 'shelter', lat: 40.1, lon: -76.4, confidence: 'low' },
]
/** Real MapLibre has its layers and sources by the time `load` fires. */
function loadStyle(map: MockMap): void {
// Both waypoint ranks (#597): attachPoiFilter waits for the dot layer as
// well as the pin one, so a stub holding only pins never filters at all.
map.layerIds = [POI_DOT_LAYER_ID, POI_LAYER_ID, WARNING_LAYER_ID]
map.sourceIds = [POI_SOURCE_ID, CLOSURE_SOURCE_ID, WARNING_SOURCE_ID]
map.emit('load')
}
it('registers the pin images once the style is up', () => {
render(<MapView {...PROPS} pois={POIS} />)
const [map] = MockMap.live
loadStyle(map)
expect(map.images.has(poiIconId('water', 'high'))).toBe(true)
})
it('pushes the POIs it was given into the source', () => {
render(<MapView {...PROPS} pois={POIS} />)
const [map] = MockMap.live
loadStyle(map)
expect(map.sourceData.get(POI_SOURCE_ID)).toEqual(poiFeatureCollection(POIS))
})
it('filters out the categories the hiker hid from the legend', () => {
render(<MapView {...PROPS} pois={POIS} hiddenTypes={new Set(['water'])} />)
const [map] = MockMap.live
loadStyle(map)
expect(map.filters.get(POI_LAYER_ID)).toEqual(poiFilter(new Set(['water'])))
})
it('filters out unverified pins when the legend asks for verified only', () => {
render(<MapView {...PROPS} pois={POIS} verifiedOnly />)
const [map] = MockMap.live
loadStyle(map)
expect(map.filters.get(POI_LAYER_ID)).toEqual(poiFilter(new Set(), true))
})
it('turns the "Verified?" filter back off without rebuilding the map', () => {
// Same argument as the category toggle below: it is a filter change, and
// rebuilding for it would drop the WebGL context mid-walk.
const { rerender } = render(<MapView {...PROPS} pois={POIS} verifiedOnly />)
const [map] = MockMap.live
loadStyle(map)
const builtInitially = MockMap.instances.length
rerender(<MapView {...PROPS} pois={POIS} verifiedOnly={false} />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(map.filters.get(POI_LAYER_ID)).toEqual(poiFilter(new Set(), false))
})
it('hides a category without rebuilding the map underneath the hiker', () => {
// Tapping a legend row is a filter change. Rebuilding the map for it would
// drop the WebGL context and re-read tiles off a 1.18 GB archive - a
// visible stall mid-walk for what should be one paint.
const { rerender } = render(
<MapView {...PROPS} pois={POIS} hiddenTypes={new Set()} />,
)
const [map] = MockMap.live
loadStyle(map)
const builtInitially = MockMap.instances.length
rerender(<MapView {...PROPS} pois={POIS} hiddenTypes={new Set(['water'])} />)
expect(MockMap.instances).toHaveLength(builtInitially)
expect(MockMap.live).toHaveLength(1)
expect(map.filters.get(POI_LAYER_ID)).toEqual(poiFilter(new Set(['water'])))
})
it('rebuilds the source when a legend tap hides a site’s anchor', () => {
// THE WIRING HALF OF #607, and a failure the other two files cannot see.
// composeSites can be perfectly right about which member carries the pin
// and the map still draws nothing, because the source is only pushed when
// `pois` changes - and tapping a legend row does not change `pois`. What
// this catches is exactly that missing effect dependency: a shelter hidden,
// and its privy never offered a pin to be filtered.
const site: MapPoint[] = [
{
id: 'shelter',
type: 'shelter',
lat: 39,
lon: -77,
confidence: 'high',
siteId: 'site_1',
siteRole: 'anchor',
},
{
id: 'privy',
type: 'privy',
lat: 39.0004,
lon: -77,
confidence: 'high',
siteId: 'site_1',
siteRole: 'member',
},
]
const pinnedIds = (map: MockMap): unknown[] => {
const data = map.sourceData.get(POI_SOURCE_ID) as {
features: Array<{ id: unknown }>
}
return data.features.map((feature) => feature.id)
}
const { rerender } = render(
<MapView {...PROPS} pois={site} hiddenTypes={new Set()} />,
)
const [map] = MockMap.live
loadStyle(map)
// The precondition, asserted rather than assumed: the privy is folded away
// and the shelter's pin stands for both. That is #524 working.
expect(pinnedIds(map)).toEqual(['shelter'])
rerender(<MapView {...PROPS} pois={site} hiddenTypes={new Set(['shelter'])} />)
expect(pinnedIds(map)).toEqual(['privy'])
})
it('takes POIs arriving after the map was built, which is the normal case', () => {
// The map screen renders before the download finishes and before
// IndexedDB has been read, so an empty first render is the rule rather
// than the exception.
const { rerender } = render(<MapView {...PROPS} />)
const [map] = MockMap.live
loadStyle(map)
rerender(<MapView {...PROPS} pois={POIS} />)
expect(MockMap.instances).toHaveLength(1)
expect(map.sourceData.get(POI_SOURCE_ID)).toEqual(poiFeatureCollection(POIS))
})
it('draws the closures it was given as bands along the trail', () => {
render(<MapView {...PROPS} closures={CLOSURES} />)
const [map] = MockMap.live
loadStyle(map)
expect(map.sourceData.get(CLOSURE_SOURCE_ID)).toEqual(
closureFeatureCollection(CLOSURES),
)
})
it('draws the serious warnings it was given as pins', () => {
render(<MapView {...PROPS} warnings={WARNINGS} />)
const [map] = MockMap.live
loadStyle(map)
expect(map.images.has(WARNING_ICON_ID)).toBe(true)
expect(map.sourceData.get(WARNING_SOURCE_ID)).toEqual(
warningFeatureCollection(WARNINGS),
)
})
it('takes closures and warnings arriving long after the map was built', () => {
// The normal case, and more so than for the POIs: these come over the
// network from a backend that is unreachable on most of the trail, so the
// first render is empty and the data lands whenever signal does.
const { rerender } = render(<MapView {...PROPS} />)
const [map] = MockMap.live
loadStyle(map)
rerender(<MapView {...PROPS} closures={CLOSURES} warnings={WARNINGS} />)
expect(MockMap.instances).toHaveLength(1)
expect(map.sourceData.get(CLOSURE_SOURCE_ID)).toEqual(
closureFeatureCollection(CLOSURES),
)
expect(map.sourceData.get(WARNING_SOURCE_ID)).toEqual(
warningFeatureCollection(WARNINGS),
)
})
it('does not rebuild the map when a closure clears', () => {
// A closure being lifted is a data change like any other. Rebuilding for
// it would drop the WebGL context and re-read tiles off a 1.18 GB archive.
const { rerender } = render(<MapView {...PROPS} closures={CLOSURES} />)
const [map] = MockMap.live
loadStyle(map)
rerender(<MapView {...PROPS} closures={[]} />)
expect(MockMap.instances).toHaveLength(1)
expect(map.sourceData.get(CLOSURE_SOURCE_ID)).toEqual(closureFeatureCollection([]))
})
it('leaves no load listeners behind after unmount', () => {
const { unmount } = render(<MapView {...PROPS} pois={POIS} />)
const [map] = MockMap.live
unmount()
expect(map.listenerCount('load')).toBe(0)
})
it('reports which pin was tapped, so the shell can describe it', () => {
const onSelectPoi = vi.fn()
render(<MapView {...PROPS} pois={POIS} onSelectPoi={onSelectPoi} />)
const [map] = MockMap.live
loadStyle(map)
map.renderedFeatures.set(POI_LAYER_ID, [
{ properties: { [POI_ID_PROPERTY]: 's1', poi_type: 'shelter' } },
])
map.emit('click', { point: { x: 120, y: 240 } })
expect(onSelectPoi).toHaveBeenCalledWith('s1')
})
it('re-binds taps for a new handler without rebuilding the map', () => {
// The shell's handler identity changes for reasons that have nothing to do
// with the pins. Folding this into the POI-data effect would re-serialise
// every pin on the trail whenever it did.
const { rerender } = render(<MapView {...PROPS} pois={POIS} onSelectPoi={vi.fn()} />)
const [map] = MockMap.live
loadStyle(map)
map.renderedFeatures.set(POI_LAYER_ID, [
{ properties: { [POI_ID_PROPERTY]: 'w1', poi_type: 'water' } },
])
const second = vi.fn()
rerender(<MapView {...PROPS} pois={POIS} onSelectPoi={second} />)
map.emit('click', { point: { x: 10, y: 10 } })
expect(MockMap.instances).toHaveLength(1)
expect(second).toHaveBeenCalledWith('w1')
expect(map.listenerCount('click')).toBe(1)
})
it('leaves no tap listeners behind after unmount', () => {
const { unmount } = render(<MapView {...PROPS} pois={POIS} onSelectPoi={vi.fn()} />)
const [map] = MockMap.live
loadStyle(map)
unmount()
expect(map.listenerCount('click')).toBe(0)
expect(map.listenerCount('mousemove')).toBe(0)
})
})
describe('opening view', () => {
it('fits the whole corridor when given bounds, letting MapLibre pick the zoom', () => {
// A zoom number cannot express "show all of this" - what fits depends on
// the screen, so the same number frames it differently on every phone.
render(
<MapView
topoArchiveUrl="pmtiles://archive"
trailsUrl="/trails.geojson"
bounds={[
[-84.73, 34.2],
[-68.3, 46.34],
]}
/>,
)
const options = MockMap.instances[0].options
expect(options.bounds).toEqual([
[-84.73, 34.2],
[-68.3, 46.34],
])
// center/zoom must not also be set - MapLibre would have to reconcile two
// conflicting instructions about the same camera.
expect(options.center).toBeUndefined()
expect(options.zoom).toBeUndefined()
})
it('still honours center and zoom when no bounds are given', () => {
render(
<MapView
topoArchiveUrl="pmtiles://archive"
trailsUrl="/trails.geojson"
center={[-77.1, 39.3]}
zoom={12}
/>,
)
const options = MockMap.instances[0].options
expect(options.center).toEqual([-77.1, 39.3])
expect(options.zoom).toBe(12)
expect(options.bounds).toBeUndefined()
})
describe('the opening view, without bounds', () => {
it('falls back to its own default camera when given neither bounds nor center', () => {
render(<MapView {...PROPS} />)
const options = MockMap.instances[0].options
expect(options.center).toBeDefined()
expect(options.zoom).toBeDefined()
expect(options.bounds).toBeUndefined()
})
})
})
// #216: the archive is a range of scales, not a map of everywhere. The app
// opens on the whole trail (~z3.8 on a phone) and every archive built before
// 2026-08-05 starts at z6, so the opening view had nothing to draw and a
// complete 314 MB download rendered as flat paper on every launch.
//
// MockMap does not implement fitBounds, so a map constructed with `bounds`
// sits at its initial zoom of 0 - which is below any floor worth testing and
// is exactly the state the clamp exists for.
describe('keeping the opening camera inside what the download covers', () => {
const CORRIDOR: [[number, number], [number, number]] = [
[-84.73, 34.2],
[-68.3, 46.34],
]
it('lifts the opening view to the archive floor when it falls under it', async () => {
render(
<MapView
{...PROPS}
background="usgs_topo_offline"
bounds={CORRIDOR}
archiveZooms={{ minZoom: 6, maxZoom: 12 }}
/>,
)
// 5, not the header's 6: the @2x tileSize declaration (#191) means
// camera z5 already draws the archive's z6 tiles.
await waitFor(() => expect(MockMap.live[0]?.getZoom()).toBe(5))
})
it('leaves it alone once the archive reaches every zoom', async () => {
// What the pipeline builds from now on. The whole-trail opening view is a
// deliberate decision (App.tsx) and must survive untouched.
render(
<MapView
{...PROPS}
background="usgs_topo_offline"
bounds={CORRIDOR}
archiveZooms={{ minZoom: 0, maxZoom: 12 }}
/>,
)
await waitFor(() => expect(MockMap.live.length).toBeGreaterThan(0))
expect(MockMap.live[0].cameraMoves).toHaveLength(0)
})
it('leaves the live background alone, which covers every zoom itself', async () => {
render(
<MapView
{...PROPS}
background="hiking_topo_live"
bounds={CORRIDOR}
archiveZooms={{ minZoom: 6, maxZoom: 12 }}
/>,
)
await waitFor(() => expect(MockMap.live.length).toBeGreaterThan(0))
expect(MockMap.live[0].cameraMoves).toHaveLength(0)
})
it('claims nothing while the archive coverage is still unknown', async () => {
// Not-looked-yet must never be acted on as though the download were known
// to fall short - that conflation is what made #216 invisible.
render(<MapView {...PROPS} background="usgs_topo_offline" bounds={CORRIDOR} />)
await waitFor(() => expect(MockMap.live.length).toBeGreaterThan(0))
expect(MockMap.live[0].cameraMoves).toHaveLength(0)
})
it('does not touch a camera the hiker has already moved', async () => {
// The shell passes `bounds` only for the very first view; once there is a
// remembered camera it sends centre and zoom instead. So a hiker who
// deliberately zoomed out to look at the whole trail is never yanked back.
render(
<MapView
{...PROPS}
background="usgs_topo_offline"
center={[-77, 39]}
zoom={4}
archiveZooms={{ minZoom: 6, maxZoom: 12 }}
/>,
)
await waitFor(() => expect(MockMap.live.length).toBeGreaterThan(0))
expect(MockMap.live[0].getZoom()).toBe(4)
expect(MockMap.live[0].cameraMoves).toHaveLength(0)
})
})