forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.flows.test.tsx
More file actions
1401 lines (1204 loc) · 56 KB
/
Copy pathApp.flows.test.tsx
File metadata and controls
1401 lines (1204 loc) · 56 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
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { describe, it, expect, vi } from 'vitest'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App'
import { appHarness, centerlineGeoJSON, latOfMile } from './test/appHarness'
import { PREFERENCES_KEY } from './lib/preferences'
import { ELEVATION_STORE_KEY, POIS_KEY, TRAILS_BLOB_KEY } from './lib/trailData'
import { CORRIDOR_ARCHIVE_KEY } from './map/pmtilesSource'
import { readArchive, segmentKeyFor } from './lib/archiveStore'
import { POI_ID_PROPERTY, POI_LAYER_ID } from './map/poiLayers'
import { archiveUrl } from './lib/config'
import { MockMap } from './test/mocks/maplibre-gl'
import { liveMap } from './test/liveMap'
import { THEME_ATTRIBUTE } from './lib/theme'
import { BACKDROP_LAYER_ID, MAP_BACKDROP } from './map/style'
import { SHEET_VARIANTS } from './map/liveTopo'
/** The colour the backdrop layer was BUILT with, off the style the mock map
* was constructed from - which is the only half of the theme a screen that
* unmounts the canvas can be asked about. */
function backdropOf(map: MockMap): unknown {
const style = map.options.style as { layers: Array<Record<string, never>> }
const backdrop = style.layers.find((l) => l.id === (BACKDROP_LAYER_ID as never))
return (backdrop?.paint as Record<string, unknown> | undefined)?.['background-color']
}
// App.test.tsx covers the shell: which screen you land on and what it says
// before any data exists. This covers what happens once data and a GPS fix DO
// exist - the paths a hiker is actually on for the length of a hike, and the
// ones that only run after a download succeeds.
vi.mock('maplibre-gl', () => import('./test/mocks/maplibre-gl'))
vi.mock('idb-keyval', () => ({ get: vi.fn(), set: vi.fn(), del: vi.fn() }))
const SHELTER = {
id: 'atc_shelters:abc',
type: 'shelter',
name: 'Chairback Gap Lean-to',
lat: latOfMile(5),
lon: -77,
confidence: 'high' as const,
source: 'atc_shelters',
}
// No `onLine`, deliberately - App.safety.test.tsx is where the reads that need
// a connection are asserted, and these flows are the ones a hiker walks with
// no signal at all.
const app = appHarness({ navigator: { geolocation: true }, objectUrls: true })
const store = app.store
/** Onboarded, location allowed, trail data already on the phone. */
function hikerOnTrail(overrides: Record<string, unknown> = {}) {
app.onboard({ location_permission_requested: true, ...overrides })
app.putTrailData({ pois: [SHELTER] })
}
/** Report a GPS fix at a mile of the synthetic centerline. */
const reportFix = (mile = 5) => app.reportFixAtMile(mile)
/**
* The download window, opened the way a hiker reaches it.
*
* There is no Downloads tab (chrome/tabs.ts): the door is the link at the foot
* of the legend, which is where the map screen keeps it.
*/
async function openDownloads(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: /legend/i }))
await user.click(await screen.findByRole('button', { name: /download/i }))
return screen.findByRole('dialog', { name: /offline map/i })
}
/**
* The USGS sheet's card, behind its own tab in the download window (#298).
*
* The sheets are tabs rather than a stack, so the card a test wants is not on
* screen until its tab is chosen - which is exactly what a hiker does.
*/
async function usgsSheetCard(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('tab', { name: /usgs sheet/i }))
return screen.findByRole('region', { name: /usgs sheet/i })
}
describe('once there is a GPS fix', () => {
it('shows the mile instead of still looking for GPS', async () => {
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await reportFix()
expect(await screen.findByText(/mi 5\./)).toBeInTheDocument()
})
// The mile assertions are what make this test mean anything: they prove each
// fix arrived and was used, so a run with an unmoved camera cannot be a run
// where no fix ever showed up.
it('leaves the camera on the whole corridor, since the view belongs to the hiker', async () => {
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
// Wait for the map itself, not just its container div: findByRole resolves
// a commit before MapView's effect constructs the map, so reading
// instances[0] straight after it races the build and can find nothing at
// all. That would fail as a TypeError rather than as the assertion.
await waitFor(() => expect(MockMap.instances.length).toBeGreaterThan(0))
await reportFix()
expect(await screen.findByText(/mi 5\./)).toBeInTheDocument()
await reportFix(6)
expect(await screen.findByText(/mi 6\./)).toBeInTheDocument()
expect(MockMap.instances[0].cameraMoves).toHaveLength(0)
})
it('starts tracking a direction of travel once it has two fixes', async () => {
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await reportFix(5)
await reportFix(8)
expect(await screen.findByText(/mi 8\./)).toBeInTheDocument()
})
})
describe('search, with a real index behind it', () => {
it('jumps the map to the result that was picked', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('button', { name: /search/i }))
await user.type(
await screen.findByRole('searchbox', { name: /search the downloaded map/i }),
'chairback',
)
await user.click(await screen.findByText('Chairback Gap Lean-to'))
// Centre AND zoom: from the opening view of the whole corridor, centring
// alone moves the map a few pixels and looks like nothing happened.
await waitFor(() =>
expect(MockMap.instances[0].cameraMoves).toContainEqual(
expect.objectContaining({ center: [SHELTER.lon, SHELTER.lat] }),
),
)
const jump = MockMap.instances[0].cameraMoves.find(
(move) => Array.isArray(move.center) && move.center[0] === SHELTER.lon,
)
expect(jump?.zoom).toBeGreaterThanOrEqual(14)
})
it('shows the mile alongside a result once the index exists', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('button', { name: /search/i }))
await user.type(
await screen.findByRole('searchbox', { name: /search the downloaded map/i }),
'chairback',
)
expect(await screen.findByText(/Shelter · mi 5\./)).toBeInTheDocument()
})
it('closes without moving the map when the search is cancelled', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('button', { name: /search/i }))
await user.click(await screen.findByRole('button', { name: /cancel/i }))
expect(
screen.queryByRole('searchbox', { name: /search the downloaded map/i }),
).not.toBeInTheDocument()
})
})
describe('the legend', () => {
it('opens and closes', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('button', { name: /legend/i }))
const legend = await screen.findByRole('dialog', { name: /legend/i })
await user.click(within(legend).getByRole('button', { name: /close|done/i }))
expect(screen.queryByRole('dialog', { name: /legend/i })).not.toBeInTheDocument()
})
it('hides a type when it is toggled off, and shows it again when toggled back', async () => {
// Found by pressed state rather than by name. The control used to be a dot
// labelled "Hide Water"; since #572 the row itself is the button, and a
// category is on or off according to whether it reports itself pressed.
//
// The version this replaces looked the button up by /hide|show/, read an
// `aria-label` that control never had, and then asserted no button was
// named `''` - which was true before the click as well. It could not fail.
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('button', { name: /legend/i }))
const legend = await screen.findByRole('dialog', { name: /legend/i })
// queryAll, not getAll: this fixture puts one category in the viewport, so
// the count after the click is legitimately zero and getAll would throw
// rather than report it.
const shown = () => within(legend).queryAllByRole('button', { pressed: true })
const before = shown().length
expect(before).toBeGreaterThan(0)
await user.click(shown()[0])
expect(shown()).toHaveLength(before - 1)
// Still on screen, still counted - hiding a category takes its pins off
// the map without taking the row out of the legend, which is the only
// thing left to turn it back on with.
expect(within(legend).getAllByRole('button', { pressed: false })).toHaveLength(1)
// Toggling back is the half that a Set-based toggle gets wrong if it only
// ever adds.
await user.click(within(legend).getByRole('button', { pressed: false }))
expect(shown()).toHaveLength(before)
})
})
describe('tapping a pin on the map', () => {
/**
* Touch the canvas where MapLibre would report a pin.
*
* The map is real code with a mock MapLibre under it, so the pin has to be
* put where a rendered-feature query will find it - which is what the live
* map's own tile rendering does for a real one.
*/
async function tapPin(properties: Record<string, unknown>) {
// The LIVE map, and only once it is listening. The map screen builds a new
// map when the trail lines land - a different object URL is a different
// style - so the first map constructed is routinely one that has already
// been torn down, and touching it would be touching nothing.
await waitFor(() => {
expect(MockMap.live).toHaveLength(1)
expect(MockMap.live[0].listenerCount('click')).toBeGreaterThan(0)
})
const map = MockMap.live[0]
map.renderedFeatures.set(POI_LAYER_ID, [{ properties }])
await act(async () => {
map.emit('click', { point: { x: 160, y: 300 } })
})
}
it('opens the waypoint’s details, which used to do nothing at all', async () => {
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
const sheet = await screen.findByRole('dialog', { name: /waypoint/i })
expect(
within(sheet).getByRole('heading', { name: 'Chairback Gap Lean-to' }),
).toBeInTheDocument()
expect(within(sheet).getByText('Shelter')).toBeInTheDocument()
})
it('reaches the shelter’s privy, which has no pin of its own to tap', async () => {
// The whole of #526 end to end, and the reason it is a shell test rather
// than a card one: the card is handed a single waypoint, and the shell is
// the only layer holding the others. Since #524 gave the site one pin there
// is nothing on the canvas to aim at for the privy, so if this row is not
// wired through, no gesture in the app reaches it at all.
const user = userEvent.setup()
hikerOnTrail()
store.set(POIS_KEY, [
{ ...SHELTER, siteId: 'site_abc', siteRole: 'anchor' },
{
id: 'atc_privies:xyz',
type: 'privy',
name: 'Chairback Gap Privy',
// 0.00036 degrees of latitude is 40 m by the pipeline's own constant,
// which the chip says as 131 ft - the units this hiker has, since
// DEFAULT_PREFERENCES starts at the ones the trail is signed in.
lat: SHELTER.lat + 0.00036,
lon: SHELTER.lon,
confidence: 'low' as const,
source: 'atc_privies',
siteId: 'site_abc',
siteRole: 'member',
},
])
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
const card = await screen.findByRole('dialog', { name: /waypoint/i })
// Both parts of the place, named as the site they belong to.
const strip = within(card).getByRole('group', {
name: 'Parts of Chairback Gap Lean-to',
})
expect(within(strip).getAllByRole('button')).toHaveLength(2)
await user.click(within(strip).getByRole('button', { name: 'Privy 131 ft' }))
expect(
within(card).getByRole('heading', { name: 'Chairback Gap Privy' }),
).toBeInTheDocument()
expect(within(card).getByText(/privy data/)).toBeInTheDocument()
// And its mile, which is the card's headline fact and the only line saying
// where along the A.T. this thing is. The privy arrives from IndexedDB as a
// StoredPoi with no mile on it - the number comes from the same
// locateOnTrail() call search paid for, through App's `cardDetail` - so the
// natural wrong wiring is to hand the card the raw roster, and the privy then
// silently loses its position on the trail. This is the only place that
// wiring exists to be tested.
expect(within(card).getByText(/^mi 5\.0$/)).toBeInTheDocument()
})
it('says how far the parts are in the units they chose in Settings (#625)', async () => {
// The bug as it was reported: "I've selected ft, but everything still
// shows in meters". The chips were the app's one exempt line and the
// sentence above them was published prose, so this card answered in metres
// whatever the hiker had chosen - and it is the only card that did.
//
// Both halves, in one assertion pass, because the reason neither could move
// alone was that they print the same distances: `Privy · 131 ft` over a
// sentence saying 40 m would have been worse than either unit alone.
const metricHiker = { ...SHELTER, siteId: 'site_abc', siteRole: 'anchor' }
hikerOnTrail({ unit_system: 'metric' })
store.set(POIS_KEY, [
{
...metricHiker,
// What export_poi.py publishes for a privy 0.00036° of latitude away.
nearby: [{ phrase: 'a multi-seat moldering privy', distance_ft: 131.48 }],
},
{
id: 'atc_privies:xyz',
type: 'privy',
name: 'Chairback Gap Privy',
lat: SHELTER.lat + 0.00036,
lon: SHELTER.lon,
confidence: 'low' as const,
siteId: 'site_abc',
siteRole: 'member',
},
])
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
const card = await screen.findByRole('dialog', { name: /waypoint/i })
expect(within(card).getByRole('button', { name: 'Privy 40 m' })).toBeInTheDocument()
expect(
within(card).getByText('Nearby: a multi-seat moldering privy 40 m away.'),
).toBeInTheDocument()
})
it('places the waypoint on the trail, at the mile search would give it', async () => {
// One number from one computation. A second way of working out the mile
// could disagree with search about the same shelter, which is exactly the
// kind of quiet contradiction OurHikeValues.md #4 is about.
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
const sheet = await screen.findByRole('dialog', { name: /waypoint/i })
expect(within(sheet).getByText(/^mi 5\./)).toBeInTheDocument()
})
it('says which source listed it', async () => {
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
const sheet = await screen.findByRole('dialog', { name: /waypoint/i })
expect(within(sheet).getByText(/Appalachian Trail Conservancy/)).toBeInTheDocument()
})
it('closes again', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
const sheet = await screen.findByRole('dialog', { name: /waypoint/i })
await user.click(within(sheet).getByRole('button', { name: /close/i }))
expect(screen.queryByRole('dialog', { name: /waypoint/i })).not.toBeInTheDocument()
})
it('goes away on a tap on bare map, without hunting for the close button', async () => {
// The card floats beside its pin, so it dismisses the way every floating
// map card does: tap anywhere else. Only a TAP - MapLibre withholds the
// click event when the gesture was a pan, so riding the map around with
// the card open keeps it.
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
await screen.findByRole('dialog', { name: /waypoint/i })
const map = await liveMap()
map.renderedFeatures.set(POI_LAYER_ID, [])
await act(async () => {
map.emit('click', { point: { x: 40, y: 60 } })
})
expect(screen.queryByRole('dialog', { name: /waypoint/i })).not.toBeInTheDocument()
})
it('ignores a tap on a pin the app no longer holds', async () => {
// A stale tile can name a POI that a re-download has since dropped. An
// empty sheet would be worse than no sheet.
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: 'atc_shelters:gone', poi_type: 'shelter' })
expect(screen.queryByRole('dialog', { name: /waypoint/i })).not.toBeInTheDocument()
})
it('replaces the legend rather than stacking on it', async () => {
// Both sit at the bottom of the map. Two at once leaves the lower one
// unreadable and its close button unreachable.
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('button', { name: /legend/i }))
await screen.findByRole('dialog', { name: /legend/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
expect(await screen.findByRole('dialog', { name: /waypoint/i })).toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: /legend/i })).not.toBeInTheDocument()
})
it('gets out of the way when the legend is opened over it', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await tapPin({ [POI_ID_PROPERTY]: SHELTER.id, poi_type: 'shelter' })
await screen.findByRole('dialog', { name: /waypoint/i })
await user.click(screen.getByRole('button', { name: /legend/i }))
expect(await screen.findByRole('dialog', { name: /legend/i })).toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: /waypoint/i })).not.toBeInTheDocument()
})
})
describe('downloading everything', () => {
function servesEverything() {
vi.mocked(fetch).mockImplementation((url) =>
Promise.resolve({
ok: true,
status: 200,
statusText: 'OK',
blob: () => Promise.resolve(new Blob([centerlineGeoJSON()])),
// The vector artifacts are hashed before they are stored (#197), so
// the double has to answer with bytes as well as with text.
arrayBuffer: () =>
Promise.resolve(
new TextEncoder().encode(
String(url).includes('poi_shelter')
? JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
id: SHELTER.id,
name: SHELTER.name,
confidence: 'high',
},
geometry: {
type: 'Point',
coordinates: [SHELTER.lon, SHELTER.lat],
},
},
],
})
: String(url).includes('trails')
? centerlineGeoJSON()
: JSON.stringify({ type: 'FeatureCollection', features: [] }),
).buffer,
),
text: () =>
Promise.resolve(
String(url).includes('poi_shelter')
? JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
id: SHELTER.id,
name: SHELTER.name,
confidence: 'high',
},
geometry: {
type: 'Point',
coordinates: [SHELTER.lon, SHELTER.lat],
},
},
],
})
: JSON.stringify({ type: 'FeatureCollection', features: [] }),
),
headers: new Headers({ 'content-length': '3' }),
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]))
controller.close()
},
}),
} as unknown as Response),
)
}
it('fetches the trail data and then the archive, in that order', async () => {
const user = userEvent.setup()
app.onboard()
servesEverything()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await openDownloads(user)
// The USGS card, named: two sheets each have a download button now (#237).
const usgsCard = await usgsSheetCard(user)
await user.click(within(usgsCard).getByRole('button', { name: /download the map/i }))
await waitFor(() => expect(store.get(TRAILS_BLOB_KEY)).toBeInstanceOf(Blob))
// Read through the accessor the map uses: since #553 a finished archive is a
// run of segment records named by a completion marker, not one record.
await waitFor(async () =>
expect(await readArchive(CORRIDOR_ARCHIVE_KEY)).toBeInstanceOf(Blob),
)
})
it('deletes the background and keeps the trail (#192)', async () => {
// Someone reclaiming space is reclaiming the BACKGROUND - that is what
// the hundreds of megabytes are, and what they chose. The centerline and
// the POIs are a rounding error beside it, they are what makes this an
// app rather than a map viewer, and they are downloaded by default
// wherever they are missing - so taking them would blank the trail line
// until the next launch with signal fetched them straight back.
const user = userEvent.setup()
hikerOnTrail()
store.set(CORRIDOR_ARCHIVE_KEY, new Blob(['archive']))
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await openDownloads(user)
const usgsCard = await usgsSheetCard(user)
await user.click(within(usgsCard).getByRole('button', { name: /delete the map/i }))
await user.click(within(usgsCard).getByRole('button', { name: /yes, delete it/i }))
await waitFor(() => expect(store.has(CORRIDOR_ARCHIVE_KEY)).toBe(false))
expect(store.has(TRAILS_BLOB_KEY)).toBe(true)
expect(store.has(POIS_KEY)).toBe(true)
})
it('records a new detail level as a max background zoom', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await openDownloads(user)
// The levels live on the sheet that has them - the USGS raster. One
// stored level all the same: max_background_zoom is what the next
// download is fetched at.
await usgsSheetCard(user)
await user.click(await screen.findByRole('radio', { name: /light/i }))
await waitFor(() => {
const saved = store.get(PREFERENCES_KEY) as { max_background_zoom: number }
expect(saved.max_background_zoom).toBe(11)
})
})
})
describe('reporting, with a fix to attach', () => {
it('files the report at the position the hiker is actually standing', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await reportFix()
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /report a problem/i }))
await user.click(await screen.findByRole('button', { name: /blow down/i }))
await user.click(await screen.findByRole('button', { name: /send|save to outbox/i }))
await waitFor(() => {
const queued = store.get('ourhike:outbox') as Array<{
payload: { lat?: number; lon?: number }
}>
expect(queued).toHaveLength(1)
expect(queued[0].payload.lat).toBeCloseTo(SHELTER.lat, 4)
expect(queued[0].payload.lon).toBeCloseTo(SHELTER.lon, 4)
})
})
it('drops the draft and returns to the tab it came from when cancelled', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /report a problem/i }))
await user.click(await screen.findByRole('button', { name: /blow down/i }))
await user.click(await screen.findByRole('button', { name: /^cancel$/i }))
expect(await screen.findByRole('heading', { name: 'You' })).toBeInTheDocument()
expect(store.get('ourhike:outbox')).toBeUndefined()
})
it('counts what is waiting in the outbox', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /report a problem/i }))
await user.click(await screen.findByRole('button', { name: /blow down/i }))
await user.click(await screen.findByRole('button', { name: /send|save to outbox/i }))
// Saving now hands over to the sign-in step (lib/contributionFlow.ts's
// stepAfterSaving). Declining it is the path this test cares about: the
// count has to be the same either way, because the report was already
// written before anyone was asked to authenticate.
await user.click(await screen.findByRole('button', { name: /not now/i }))
expect(await screen.findByText(/1 .*(waiting|queued|outbox)/i)).toBeInTheDocument()
})
it('saves the report even when sign-in is declined', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /report a problem/i }))
await user.click(await screen.findByRole('button', { name: /blow down/i }))
await user.click(await screen.findByRole('button', { name: /send|save to outbox/i }))
await user.click(await screen.findByRole('button', { name: /not now/i }))
// The promise the whole flow exists to keep: someone who cannot or will
// not authenticate still has what they wrote.
await waitFor(() => {
expect(store.get('ourhike:outbox')).toBeDefined()
})
})
it('asks to sign in only after the report is already saved', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /report a problem/i }))
await user.click(await screen.findByRole('button', { name: /blow down/i }))
await user.click(await screen.findByRole('button', { name: /send|save to outbox/i }))
// Ordering is the design, not a detail - the screen says the report is
// already saved, and it has to be true when it says it.
expect(await screen.findByText(/already saved/i)).toBeInTheDocument()
expect(store.get('ourhike:outbox')).toBeDefined()
})
})
describe('preferences from the More screen', () => {
it('saves a changed setting straight away, with no explicit save step', async () => {
// The theme is the vehicle here because it is a live control. This test
// used to flip the wrong-way alert toggle, which is now marked Later and
// disabled like its neighbours - nothing implements the alert yet, and a
// live-looking safety switch that armed nothing was worse than none.
const user = userEvent.setup()
hikerOnTrail({ theme: 'light' })
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('radio', { name: /dark/i }))
await waitFor(() => {
const saved = store.get(PREFERENCES_KEY) as { theme: string }
expect(saved.theme).toBe('dark')
})
})
it('takes the whole app dark from the theme control, map included', async () => {
// The end-to-end shape of the feature: one tap writes the preference, the
// chrome follows the attribute the design tokens key their dark block off,
// and the canvas - which is WebGL and cannot read a CSS variable - is
// built in the same theme rather than staying paper-white inside a dark
// app.
//
// The map is unmounted while More is showing (it is a different screen,
// not a hidden one), so what this can observe on the way back is the style
// the canvas was built with. That a theme change on a LIVE map repaints in
// place instead of rebuilding is map/style.test.ts's attachMapAppearance block.
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('light')
// Waited on, exactly like the dark read at the end of this test. The map
// is built in an effect that runs a commit AFTER the container div lands,
// so `MockMap.live[0]` here was a race: one of the two reads in this test
// was wrapped and the other was not, and this is the one that failed
// under a full-suite run (#331).
expect(backdropOf(await liveMap())).toBe(MAP_BACKDROP.light)
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('radio', { name: /dark/i }))
await waitFor(() => {
const saved = store.get(PREFERENCES_KEY) as { theme: string }
expect(saved.theme).toBe('dark')
})
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark')
await user.click(screen.getByRole('tab', { name: 'Trail' }))
await screen.findByRole('region', { name: /trail map/i })
// Waited on the built style rather than on a tick: the map is constructed
// inside an effect, so what proves the sequence completed is a live map
// carrying the dark backdrop, not time passing. Field/night's backdrop
// specifically: dark CHOSEN from the control reaches field's own night
// sheet, where an auto theme resolving dark would land on night_hike -
// liveTopo.ts's sheetVariant owns that distinction.
await waitFor(() => {
expect(MockMap.live.length).toBeGreaterThan(0)
expect(backdropOf(MockMap.live[0])).toBe(SHEET_VARIANTS.field.night.backdrop)
})
})
})
describe('the placeholder actions on More', () => {
// Sync and export are wired to no-ops today: the backend they need is Phase
// 2 (ROADMAP.md). Rendered and clickable anyway so the shape of the screen
// is real, and asserted here so a wiring mistake shows up as a failing test
// rather than as a button that throws in someone's hand.
//
// Sign in used to be in this list and is not a placeholder any more, which
// is what the sign-in tests below cover instead.
it.each([/^sync$/i, /export gpx/i, /export geojson/i])(
'does not throw when %s is tapped',
async (name) => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name }))
expect(await screen.findByRole('heading', { name: 'You' })).toBeInTheDocument()
},
)
})
describe('signing in from Settings', () => {
it('opens the sign-in screen', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /sign in/i }))
expect(
await screen.findByRole('button', { name: /continue with google/i }),
).toBeInTheDocument()
})
it('does not claim a report is saved, because this path has no report', async () => {
// The same screen serves the contribution flow, where "your report is
// already saved" is both true and the point. Reached from Settings there
// is nothing saved, and saying so would be a promise about something that
// does not exist.
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /sign in/i }))
await screen.findByRole('button', { name: /continue with google/i })
expect(screen.queryByText(/already saved/i)).toBe(null)
})
it('backs out to the screen it came from', async () => {
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /sign in/i }))
await user.click(await screen.findByRole('button', { name: /not now/i }))
expect(await screen.findByRole('heading', { name: 'You' })).toBeInTheDocument()
})
it('offers only the providers this build has credentials for', async () => {
// ENABLED_PROVIDERS is Google alone - v1's decided provider set (#397).
// Apple needs a $99/yr membership and is deferred to v2 (#92); email left
// the default because Supabase's built-in sender is not a delivery path
// this project ships on, so offering it built a button whose sign-in
// could not complete.
//
// Both absences are asserted rather than only Apple's, because they are
// absent for different reasons and a single "not Apple" assertion would
// pass on a build that had quietly restored email.
const user = userEvent.setup()
hikerOnTrail()
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /sign in/i }))
await screen.findByRole('button', { name: /continue with google/i })
expect(screen.queryByRole('button', { name: /continue with email/i })).toBe(null)
expect(screen.queryByRole('button', { name: /continue with apple/i })).toBe(null)
})
// Two tests stood here and were removed with the email default (#397): one
// that the email button reaches a form asking only for an address, and one
// that the form offers a password fallback. They drove App -> SignInPrompt
// -> EmailSignIn, and that route is unreachable in a build offering Google
// alone, so keeping them would have meant faking the configuration to test
// a path no hiker can take.
//
// Nothing about EmailSignIn lost coverage: screens/EmailSignIn.test.tsx
// renders it directly and covers both paths further than these did - the
// address-only form, the link's wording, the password fallback, switching
// between them and the failure messages. What is uncovered while email is
// off is the App-level wiring between the button and the screen, which is
// the thing that only exists when the button does. Restore these two with
// the provider, not before.
})
describe('when the trail data cannot be downloaded', () => {
it('says so even when what failed was not an Error', async () => {
// fetch can reject with anything at all, and a rejection this code cannot
// read the message off still has to produce a sentence rather than
// "undefined" or a blank alert.
//
// It used to produce exactly one sentence for every such rejection -
// "Trail data failed to download." - which said nothing about which of the
// eight requests died. lib/trailData.ts now names the artifact and carries
// whatever the rejection stringifies to, so even a thrown string arrives
// attached to the file it was thrown for. This build has no bucket
// configured, so the host is described rather than named: printing this
// app's own origin would name the one host that is certainly not at fault.
const user = userEvent.setup()
app.onboard()
vi.mocked(fetch).mockRejectedValue('the network went away')
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await openDownloads(user)
const usgsCard = await usgsSheetCard(user)
await user.click(within(usgsCard).getByRole('button', { name: /download the map/i }))
const notice = await screen.findByText(/could not be fetched/i)
expect(notice).toHaveTextContent(/trails\.geojson/)
expect(notice).toHaveTextContent(/the network went away/)
expect(notice).toHaveTextContent(/the data source/)
})
})
describe('a download left running', () => {
it('is still visible from the map after its window is shut', async () => {
// The download belongs to the shell, not to the window it was started
// from, so shutting that window used to leave an app that looked
// completely idle while it pulled several hundred megabytes over a
// connection somebody is paying for by the mile. The only way to find out
// was to open the window again and hope.
//
// Driven through the real transfer rather than by handing the legend a
// prop, because the wiring is the part that was missing: every piece of
// this existed except the line joining them.
const user = userEvent.setup()
hikerOnTrail()
// A body that arrives and then simply does not end - which is what a
// download in progress IS. Held open deliberately: a stream that closes
// races the assertions to 'downloaded', and the state under test would be
// gone before anything could look at it.
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-length': '10' }),
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3, 4]))
},
}),
} as unknown as Response)
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await openDownloads(user)
const usgsCard = await usgsSheetCard(user)
await user.click(within(usgsCard).getByRole('button', { name: /download the map/i }))
// The window's own bar first: proof the transfer really is running, so
// that what the footer says next is a report and not a coincidence.
await waitFor(() =>
expect(within(usgsCard).getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
'40',
),
)
// Away: window shut, back to the map, legend open again - the walk a
// hiker actually takes.
await user.click(screen.getByRole('button', { name: /close/i }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: /offline map/i })).toBeNull(),
)
await user.click(screen.getByRole('button', { name: /legend/i }))
const legend = await screen.findByRole('dialog', { name: 'Legend' })
expect(within(legend).getByText('Downloading 40%')).toBeVisible()
})
})
describe('resuming an interrupted download', () => {
it('picks up where it left off rather than starting again', async () => {
// WIREFRAMES.md 7a. Re-pulling 300 MB from zero because a connection
// dropped at 90% is the failure that promise exists to prevent.
const user = userEvent.setup()
hikerOnTrail()
// Held as a segment record - where an interrupted transfer leaves its bytes
// since #553, checkpointed as they arrived.
store.set(
segmentKeyFor(CORRIDOR_ARCHIVE_KEY, 0, 0),
new Blob([new Uint8Array([1, 2, 3])]),
)
store.set('ourhike:corridor-archive:progress', { receivedBytes: 3, totalBytes: 6 })
// Must be the URL this build would request. VITE_DATA_BASE_URL is unset
// under test, so that is a bare '/background.pmtiles' - and a partial
// recorded against any other URL is deliberately discarded, not resumed.
store.set('ourhike:corridor-archive:source', {
url: archiveUrl('standard'),
generation: 0,
segments: 1,
})
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
await openDownloads(user)
const usgsCard = await usgsSheetCard(user)
const resume = within(usgsCard).getByRole('button', { name: /resume/i })
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 206,
statusText: 'Partial Content',
headers: new Headers({ 'content-length': '3', 'content-range': 'bytes 3-5/6' }),
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([4, 5, 6]))