forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPoiCard.test.tsx
More file actions
1351 lines (1120 loc) · 55.7 KB
/
Copy pathPoiCard.test.tsx
File metadata and controls
1351 lines (1120 loc) · 55.7 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, afterEach, beforeEach } from 'vitest'
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { Map as MapLibreMap } from 'maplibre-gl'
import { MockMap, resetMapLibreMock } from '../test/mocks/maplibre-gl'
import { PoiCard, type PoiDetail } from './PoiCard'
import { CARD_GAP_PX } from './poiCardPlacement'
import {
poiColor,
poiGlyphPath,
POI_FALLBACK_COLOR,
POI_PIN_SIZE,
UNKNOWN_POI_TYPE,
} from '../map/poiIcons'
// WIREFRAMES.md's waypoint detail, which the screen map derives from
// OurHikeValues.md #4 - honesty about uncertainty - as much as from the data.
//
// The line that carries that value is the unverified one. The pin says the
// same thing with a broken rim, which is a channel someone has to have learned
// to read; this is where it is said in words, and only where it is true.
//
// The card floats beside the pin it describes, so alongside the facts there is
// an anchor to test: it projects the POI's own coordinates through the live
// map, follows every camera move, and lets go of the listeners when it closes.
const SHELTER: PoiDetail = {
id: 'atc_shelters:abc',
name: 'Chairback Gap Lean-to',
type: 'shelter',
lat: 45.4732,
lon: -69.1183,
confidence: 'high',
source: 'atc_shelters',
mile: 2078.4,
}
/**
* Content tests pass no map on purpose: with nothing to anchor to, the card
* renders unpositioned but complete, which is also the honest production
* behaviour for the instant before the shell has been handed the map.
*/
function renderCard(poi: PoiDetail, onClose = vi.fn()) {
return render(<PoiCard poi={poi} map={null} onClose={onClose} />)
}
beforeEach(() => {
resetMapLibreMock()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('PoiCard', () => {
it('names the waypoint and what kind of thing it is', () => {
renderCard(SHELTER)
expect(
screen.getByRole('heading', { name: 'Chairback Gap Lean-to' }),
).toBeInTheDocument()
expect(screen.getByText('Shelter')).toBeInTheDocument()
})
it('places it on the trail', () => {
renderCard(SHELTER)
expect(screen.getByText('mi 2,078.4')).toBeInTheDocument()
})
it('says how many the shelter sleeps', () => {
renderCard({ ...SHELTER, capacity: 8 })
// "Sleeps 8", not a bare 8: beside a mile, a lone number reads as
// another distance.
expect(screen.getByText('Sleeps 8')).toBeInTheDocument()
})
it('omits the capacity rather than implying nobody fits', () => {
// Most POI types have no capacity at all, and ATC's shelter layer does
// not carry one - the pipeline joins it from a list that leaves some
// shelters blank on purpose (build_shelter_capacity.py). Absent means
// unknown, and a hiker choosing whether to push on to the next shelter
// is better served by silence than by a figure nobody published.
renderCard({ ...SHELTER, capacity: undefined })
expect(screen.queryByText(/Sleeps/)).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: SHELTER.name })).toBeInTheDocument()
})
it('says what the place is', () => {
renderCard({
...SHELTER,
description: 'Two-storey log shelter, sleeps 8, with a fireplace. Built 1954.',
})
expect(
screen.getByText('Two-storey log shelter, sleeps 8, with a fireplace. Built 1954.'),
).toBeInTheDocument()
})
it('omits the description rather than showing an empty line', () => {
// Only shelters and campsites have one, and a phone that downloaded
// before the field existed has none at all.
renderCard({ ...SHELTER, description: undefined })
expect(screen.getByRole('heading', { name: SHELTER.name })).toBeInTheDocument()
expect(screen.queryByText(/shelter, sleeps/)).not.toBeInTheDocument()
})
it('omits the mile rather than guessing one when the trail lines are missing', () => {
// The centerline index is a separate download and can legitimately be
// absent. A shelter with no mile is still worth a card - it just cannot
// say where along the trail it is.
renderCard({ ...SHELTER, mile: undefined })
expect(screen.queryByText(/^mi /)).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: SHELTER.name })).toBeInTheDocument()
})
it('gives coordinates precise enough to read out to somebody', () => {
renderCard(SHELTER)
expect(screen.getByText(/45\.47320, -69\.11830/)).toBeInTheDocument()
})
it('writes coordinates with a plain hyphen, so they paste into another device', () => {
renderCard(SHELTER)
expect(screen.queryByText(/−/)).not.toBeInTheDocument()
})
it('says in words when nobody has confirmed the waypoint exists', () => {
renderCard({ ...SHELTER, confidence: 'low' })
expect(screen.getByText(/nobody has confirmed/i)).toBeInTheDocument()
})
it('does not cast doubt on a waypoint that came from facility data', () => {
renderCard(SHELTER)
expect(screen.queryByText(/unverified/i)).not.toBeInTheDocument()
})
it('says where the claim came from, in words rather than a source id', () => {
renderCard(SHELTER)
expect(screen.getByText(/Appalachian Trail Conservancy/)).toBeInTheDocument()
})
it('distinguishes an A.T. Community town from the ATC’s own facility data', () => {
// The two are not interchangeable, and the difference is exactly why one
// is published at low confidence: a town applied for a designation, which
// is a proxy for resupply rather than a tagged resupply point.
renderCard({ ...SHELTER, type: 'resupply', source: 'atc_communities' })
expect(screen.getByText(/A\.T\. Community towns/)).toBeInTheDocument()
})
it('names each of the ATC facility layers as the kind of data it is', () => {
// Three layers, three sentences, because the card's job here is to let a
// hiker weigh the claim - and "the ATC's privy data" and "the ATC's list
// of A.T. Community towns" are not the same kind of statement. The raw
// id would be a fourth thing again: honest, and unreadable.
const sources = [
['atc_viewpoints', /vista data/],
['atc_parking', /parking data/],
['atc_privies', /privy data/],
] as const
for (const [source, wording] of sources) {
const { unmount } = renderCard({ ...SHELTER, source })
expect(screen.getByText(wording)).toBeInTheDocument()
unmount()
}
})
it('shows a source it has no wording for rather than hiding the POI’s origin', () => {
// A release that adds a source should reach a hiker as something, the same
// call the map makes when it draws an unknown POI type as a neutral pin.
renderCard({ ...SHELTER, source: 'nynjtc_shelters' })
expect(screen.getByText(/nynjtc_shelters/)).toBeInTheDocument()
})
it('treats a blank source as no source, not as a source called nothing', () => {
renderCard({ ...SHELTER, source: ' ' })
expect(screen.queryByText(/^From /)).not.toBeInTheDocument()
})
it('stays quiet about provenance for a download made before it was carried', () => {
// Undefined here means "this copy of the data predates the field", not
// "no source" - and a card with one line fewer beats a wrong claim.
renderCard({ ...SHELTER, source: undefined })
expect(screen.queryByText(/^From /)).not.toBeInTheDocument()
})
it('closes when asked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderCard(SHELTER, onClose)
await user.click(screen.getByRole('button', { name: /close waypoint details/i }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not claim the rest of the screen is inert, because it is not', () => {
// The map behind this card stays live and pannable - panning is how the
// card is used. Announcing it as a modal would tell a screen-reader user
// otherwise.
renderCard(SHELTER)
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true')
})
})
describe('the photo slot', () => {
it('shows the category silhouette when the waypoint has no photo', () => {
// The placeholder is honest iconography, not a stock photo pretending to
// be the shelter - and it stays the everyday state: most waypoints have
// no eligible photo even now that the pipeline can carry imagery.
renderCard(SHELTER)
expect(screen.getByTestId('poi-card-placeholder')).toBeInTheDocument()
expect(screen.queryByTestId('poi-card-photo')).not.toBeInTheDocument()
})
it('draws the placeholder in the pins’ own shape language', () => {
renderCard(SHELTER)
const path = screen.getByTestId('poi-card-placeholder').querySelector('path')
// Two subpaths: the shelter's body and the doorway the even-odd fill
// keeps open - the same silhouette the pin carries.
expect(path?.getAttribute('d')).toMatch(/^M.*Z.*M.*Z$/)
expect(path?.getAttribute('fill-rule')).toBe('evenodd')
})
it('shows the photo when the data carries one', () => {
renderCard({ ...SHELTER, photoUrl: 'blob:photo-of-the-lean-to' })
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute(
'src',
'blob:photo-of-the-lean-to',
)
expect(screen.queryByTestId('poi-card-placeholder')).not.toBeInTheDocument()
})
it('does not let a photo claim to be the waypoint - the name line does that', () => {
renderCard({ ...SHELTER, photoUrl: 'blob:photo' })
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('alt', '')
})
it('falls back to the placeholder when the photo fails to load', () => {
// Offline-first app: a photo URL the cache no longer holds is a routine
// Tuesday, not an error state worth a broken-image glyph over the name.
renderCard({ ...SHELTER, photoUrl: 'blob:gone' })
fireEvent.error(screen.getByTestId('poi-card-photo'))
expect(screen.getByTestId('poi-card-placeholder')).toBeInTheDocument()
expect(screen.queryByTestId('poi-card-photo')).not.toBeInTheDocument()
})
// A shippable Commons photo the way the pipeline publishes one: URL plus
// the three credit facts and the file page. CC BY/BY-SA photos always
// arrive with an author - the pipeline enforces that, because the credit
// is the licence's condition of use.
const PHOTO = {
photoUrl: 'blob:photo-of-the-lean-to',
photoPage: 'https://commons.wikimedia.org/wiki/File:Chairback_Gap_Lean-to.jpg',
photoAuthor: 'A. Hiker',
photoLicense: 'CC BY-SA 4.0',
photoTaken: '2025-06-18',
}
it('credits the photographer, licence and month, linking to the file page', () => {
// The credit is load-bearing: CC BY/BY-SA photos are only OurHike's to
// show while the attribution shows with them, same deal as the map's
// ODbL line. The month is this app's own honesty rule - a photo's age
// is a fact the hiker gets, not a detail to hide.
renderCard({ ...SHELTER, ...PHOTO })
const credit = screen.getByRole('link', {
name: 'Photo: A. Hiker · CC BY-SA 4.0 · Jun 2025',
})
expect(credit).toHaveAttribute('href', PHOTO.photoPage)
// A new tab, and no opener handle into the running map.
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noreferrer')
})
it('credits a public-domain photo by licence alone when nobody is named', () => {
// Public domain and CC0 photos legitimately have no author to credit -
// the line shortens rather than printing a blank where a name would go.
renderCard({
...SHELTER,
photoUrl: 'blob:pd-photo',
photoPage: 'https://commons.wikimedia.org/wiki/File:PD.jpg',
photoLicense: 'Public domain',
})
expect(screen.getByRole('link', { name: 'Photo: Public domain' })).toBeInTheDocument()
})
it('says nothing under a photo that carries no credit facts at all', () => {
// No pipeline path produces a photo without credit facts today (the
// fetch rejects CC files with no author and always records a licence),
// so this is the component's own contract, not a data state: a bare
// photoUrl renders no credit line, because "Photo:" with nothing after
// it would be noise pretending to be attribution.
renderCard({ ...SHELTER, photoUrl: 'blob:bare' })
expect(screen.queryByText(/^Photo:/)).not.toBeInTheDocument()
})
it('drops the credit with the photo when the photo fails to load', () => {
// The credit is a fact about a photo on screen. Once the slot falls back
// to the placeholder there is nothing being used that needs crediting -
// and a credit under the silhouette would claim the glyph was somebody's
// photograph.
renderCard({ ...SHELTER, ...PHOTO })
fireEvent.error(screen.getByTestId('poi-card-photo'))
expect(screen.queryByText(/^Photo:/)).not.toBeInTheDocument()
})
it('gives a category this build has never heard of the neutral pin’s own look', () => {
// Same call the map makes when it draws the pin itself: a later import
// adding a type should reach the card as the placeholder diamond on the
// fallback accent, not a blank slot behind a client release.
renderCard({ ...SHELTER, type: 'hot_springs' })
const card = screen.getByRole('dialog', { name: /waypoint/i })
expect(card.style.getPropertyValue('--poi-accent')).toBe(POI_FALLBACK_COLOR)
expect(
screen.getByTestId('poi-card-placeholder').querySelector('path')?.getAttribute('d'),
).toBe(poiGlyphPath(UNKNOWN_POI_TYPE))
})
})
describe('anchoring to the pin', () => {
/** A live mock map, typed the way the component takes it. */
function liveMap(): { mock: MockMap; map: MapLibreMap } {
const mock = new MockMap({})
return { mock, map: mock as unknown as MapLibreMap }
}
it('projects the POI’s own coordinates, not some other point', () => {
const { mock, map } = liveMap()
render(<PoiCard poi={SHELTER} map={map} onClose={vi.fn()} />)
expect(mock.projectCalls).toContainEqual([SHELTER.lon, SHELTER.lat])
})
it('floats the card above the projected pin', () => {
const { mock, map } = liveMap()
// The mock's projection is test-settable; a fixed point makes the
// expected transform a hand-checkable sum rather than a re-derivation.
mock.projection = () => ({ x: 200, y: 300 })
render(<PoiCard poi={SHELTER} map={map} onClose={vi.fn()} />)
// jsdom measures the card (and canvas) at zero, so placement degrades to
// "centred on the pin, above it": x stays 200, y clears half a pin plus
// the gap. poiCardPlacement.test.ts covers the real-size behaviour.
const expectedTop = 300 - POI_PIN_SIZE / 2 - CARD_GAP_PX
expect(screen.getByRole('dialog', { name: /waypoint/i })).toHaveStyle({
transform: `translate(200px, ${expectedTop}px)`,
})
})
it('rides along when the camera moves', () => {
const { mock, map } = liveMap()
mock.projection = () => ({ x: 200, y: 300 })
render(<PoiCard poi={SHELTER} map={map} onClose={vi.fn()} />)
// The pan: the same pin now projects somewhere else, and MapLibre says so
// with a 'move' - the exact order the real map delivers them in.
mock.projection = () => ({ x: 150, y: 260 })
act(() => {
mock.emit('move')
})
const expectedTop = 260 - POI_PIN_SIZE / 2 - CARD_GAP_PX
expect(screen.getByRole('dialog', { name: /waypoint/i })).toHaveStyle({
transform: `translate(150px, ${expectedTop}px)`,
})
})
it('re-projects on a move that changed nothing, and keeps the same placement', () => {
// The idle half of riding along: 'move' also fires for camera work that
// leaves the pin where it was, and the card's answer is the same pixels -
// asserted against a fresh projection call, so "unchanged" means
// "recomputed and equal", not "never looked".
const { mock, map } = liveMap()
mock.projection = () => ({ x: 200, y: 300 })
render(<PoiCard poi={SHELTER} map={map} onClose={vi.fn()} />)
const card = screen.getByRole('dialog', { name: /waypoint/i })
const before = card.style.transform
const projections = mock.projectCalls.length
act(() => {
mock.emit('move')
})
expect(mock.projectCalls.length).toBeGreaterThan(projections)
expect(card.style.transform).toBe(before)
})
it('re-anchors when the poi changes without a remount', () => {
const { mock, map } = liveMap()
const { rerender } = render(<PoiCard poi={SHELTER} map={map} onClose={vi.fn()} />)
rerender(
<PoiCard
poi={{ ...SHELTER, id: 'other', lon: -70, lat: 44 }}
map={map}
onClose={vi.fn()}
/>,
)
expect(mock.projectCalls).toContainEqual([-70, 44])
})
it('lets go of the map when it closes, so a dismissed card is not still listening', () => {
const { mock, map } = liveMap()
const { unmount } = render(<PoiCard poi={SHELTER} map={map} onClose={vi.fn()} />)
unmount()
expect(mock.listenerCount('move')).toBe(0)
expect(mock.listenerCount('resize')).toBe(0)
})
it('renders complete but unanchored with no map, rather than not at all', () => {
// The shell learns about the map from an effect, so a card can exist an
// instant before the map does - and a readable, closable card at the
// canvas origin beats a missing one.
renderCard(SHELTER)
const card = screen.getByRole('dialog', { name: /waypoint/i })
expect(card.style.transform).toBe('')
})
})
describe('PoiCard photo gallery', () => {
// Three photos of one shelter, the way ATC's layers actually publish them:
// same author and licence, different capture dates. 89% of POIs carrying a
// photo carry more than one (#471).
const GALLERY = [
{ url: 'blob:one', author: 'ATC', license: '© ATC', taken: '2016-09-12' },
{ url: 'blob:two', author: 'ATC', license: '© ATC', taken: '2016-09-13' },
{ url: 'blob:three', author: 'ATC', license: '© ATC', taken: '2017-06-06' },
]
it('shows no controls for a single photo, because there is nowhere to go', () => {
renderCard({ ...SHELTER, photoUrl: 'blob:only' })
expect(screen.queryByTestId('poi-card-photo-next')).not.toBeInTheDocument()
expect(screen.queryByTestId('poi-card-photo-count')).not.toBeInTheDocument()
})
it('steps to the next photo and says where you are', () => {
renderCard({ ...SHELTER, photoUrl: 'blob:one', photos: GALLERY })
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('src', 'blob:one')
expect(screen.getByTestId('poi-card-photo-count')).toHaveTextContent('1 of 3')
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('src', 'blob:two')
expect(screen.getByTestId('poi-card-photo-count')).toHaveTextContent('2 of 3')
})
it('wraps at both ends rather than offering a control that does nothing', () => {
renderCard({ ...SHELTER, photoUrl: 'blob:one', photos: GALLERY })
fireEvent.click(screen.getByTestId('poi-card-photo-prev'))
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('src', 'blob:three')
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('src', 'blob:one')
})
it('moves the credit with the photo, because the licence is owed per photograph', () => {
// The card must never show one photo over another photo's credit line.
renderCard({ ...SHELTER, photoUrl: 'blob:one', photos: GALLERY })
expect(screen.getByText(/Sep 2016/)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByText(/Jun 2017/)).toBeInTheDocument()
expect(screen.queryByText(/Sep 2016/)).not.toBeInTheDocument()
})
it('links the credit to the photo on screen, not to the first one', () => {
renderCard({
...SHELTER,
photoUrl: 'blob:one',
photos: [
{
url: 'blob:one',
author: 'ATC',
page: 'https://drive.google.com/file/d/one/view',
},
{
url: 'blob:two',
author: 'ATC',
page: 'https://drive.google.com/file/d/two/view',
},
],
})
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByRole('link', { name: /Photo:/ })).toHaveAttribute(
'href',
'https://drive.google.com/file/d/two/view',
)
})
it('lets a hiker past a photo that failed to load', () => {
// Offline-first: photo 2 of 5 missing from the cache must not trap
// someone on a broken slot with the rest unreachable.
//
// This test asserted the opposite until #481 - that the controls
// DISAPPEAR when a photo fails - while its own comment said they must
// not trap anyone. The controls were gated on the current photo having
// rendered, on the reasoning that paging a placeholder leads nowhere;
// true when every photo has failed, and this fires when the displayed
// one has, which on a freshly opened card is always the first.
renderCard({ ...SHELTER, photoUrl: 'blob:one', photos: GALLERY })
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
fireEvent.error(screen.getByTestId('poi-card-photo'))
expect(screen.getByTestId('poi-card-placeholder')).toBeInTheDocument()
// The way out of a bad image, which is the whole point.
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('src', 'blob:three')
expect(screen.getByTestId('poi-card-photo-count')).toHaveTextContent('3 of 3')
})
it('keeps the controls reachable when the very first photo will not load', () => {
// The common shape of the bug: nothing has been tapped yet, photo 1 is
// missing from the cache, and every other photograph of the shelter was
// unreachable behind a placeholder.
renderCard({ ...SHELTER, photoUrl: 'blob:one', photos: GALLERY })
fireEvent.error(screen.getByTestId('poi-card-photo'))
expect(screen.getByTestId('poi-card-placeholder')).toBeInTheDocument()
expect(screen.getByTestId('poi-card-photo-count')).toHaveTextContent('1 of 3')
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByTestId('poi-card-photo')).toHaveAttribute('src', 'blob:two')
})
it('starts a different waypoint at its own first photo', () => {
const { rerender } = render(
<PoiCard
poi={{ ...SHELTER, photoUrl: 'blob:one', photos: GALLERY }}
map={null}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('poi-card-photo-next'))
expect(screen.getByTestId('poi-card-photo-count')).toHaveTextContent('2 of 3')
rerender(
<PoiCard
poi={{
...SHELTER,
id: 'atc_shelters:other',
photoUrl: 'blob:one',
photos: GALLERY,
}}
map={null}
onClose={vi.fn()}
/>,
)
expect(screen.getByTestId('poi-card-photo-count')).toHaveTextContent('1 of 3')
})
})
// A shelter, its privy and its campsite are one place with parts, and since
// #524 gave the site one pin the members have had no pin of their own - so the
// strip of chips under the name is the only gesture in the app that reaches
// them (#526, features/POI_SITES.md §5). What is asserted here is that the row
// is a complete picture of the place, that tapping a chip really replaces the
// card rather than revealing a second one, and that the two mechanical traps
// the issue named are actually shut.
describe('the parts of one site', () => {
// Latitude-only offsets, so the distances on the chips are hand-checkable
// against the pipeline's own constant (111,320 m per degree) rather than
// re-derived from the code under test: 0.00036° is 40.1 m and 0.000225° is
// 25.0 m, which the card shows as 131 ft and 82 ft for a hiker who chose
// Feet. poiSites.test.ts owns the formula and the one conversion.
const PRIVY: PoiDetail = {
id: 'atc_privies:xyz',
name: 'Chairback Gap Privy',
type: 'privy',
lat: 45.47356,
lon: -69.1183,
// The everyday case rather than a contrived one: ATC's privy layer is
// published unverified, which is why the swap has an unverified line to
// assert on.
confidence: 'low',
source: 'atc_privies',
}
const CAMPSITE: PoiDetail = {
id: 'atc_campsites:xyz',
name: 'Chairback Gap Campsite',
type: 'campsite',
lat: 45.473425,
lon: -69.1183,
confidence: 'high',
description: 'Four tent pads below the lean-to.',
}
const SITE: readonly PoiDetail[] = [SHELTER, PRIVY, CAMPSITE]
function renderSite(site: readonly PoiDetail[] = SITE, poi: PoiDetail = SHELTER) {
return render(<PoiCard poi={poi} site={site} map={null} onClose={vi.fn()} />)
}
const chips = () => screen.getAllByTestId('poi-card-chip')
it('lists every part of the place, the one you are already on included', () => {
// The issue's own sketch listed the members only, on the reasoning that the
// anchor is the card you are reading. The maintainer asked for the anchor
// too, and it earns its place twice: the row is then the whole place rather
// than the place minus the part you can see, and it is the way back.
renderSite()
expect(chips()).toHaveLength(3)
expect(screen.getByRole('button', { name: 'Shelter' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Privy 131 ft' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Campsite 82 ft' })).toBeInTheDocument()
})
it('puts the pin you tapped first, and says that is where you are', () => {
renderSite()
expect(chips()[0]).toHaveAccessibleName('Shelter')
expect(chips()[0]).toHaveAttribute('aria-current', 'true')
// Exactly one, or "which part am I reading" has two answers.
expect(
chips().filter((chip) => chip.getAttribute('aria-current') === 'true'),
).toHaveLength(1)
})
it('names the place once, on the strip, rather than on every part', () => {
// features/POI_SITES.md's open question 5. The heading follows the part on
// screen because the coordinates under it do; the site's own name goes here,
// where it costs no height and a screen reader still gets it.
renderSite()
expect(
screen.getByRole('group', { name: 'Parts of Chairback Gap Lean-to' }),
).toBeInTheDocument()
// AND STILL AFTER A TAP, which is the half a first-render assertion cannot
// see: labelling the group from `shown` rather than from the anchor reads
// identically on open and then announces "Parts of Chairback Gap Privy"
// once you are in it - telling a screen-reader user that a privy has parts,
// which is false about the structure they are navigating, and taking the
// site's own name off the card entirely. The heading moves; the strip's
// label is the one thing here that must not.
fireEvent.click(screen.getByRole('button', { name: 'Privy 131 ft' }))
expect(
screen.getByRole('heading', { name: 'Chairback Gap Privy' }),
).toBeInTheDocument()
expect(
screen.getByRole('group', { name: 'Parts of Chairback Gap Lean-to' }),
).toBeInTheDocument()
})
it('says how far each part is, and puts no distance on the pin itself', () => {
// "Privy · 131 ft" is the design's own chip, in the units this hiker
// chose. The anchor carries no number because zero from itself is not a
// fact anybody needed.
renderSite()
expect(chips()[1]).toHaveTextContent('131 ft')
expect(chips()[2]).toHaveTextContent('82 ft')
expect(chips()[0]).not.toHaveTextContent(/\d/)
})
it('says how far in the units the hiker chose', () => {
// #625. This strip was the single line in the app exempt from the unit
// standard, printing metres at a hiker who had picked Feet in Settings -
// held open only because the same distances were also published as prose
// in metres, and converting one half would have put "131 ft" on a chip
// above a sentence saying 40 m.
render(
<PoiCard poi={SHELTER} site={SITE} map={null} units="metric" onClose={vi.fn()} />,
)
expect(chips()[1]).toHaveTextContent('40 m')
expect(chips()[2]).toHaveTextContent('25 m')
})
it('names what is around the place, in those same units', () => {
// The other half of the same fix, and the reason the chip could not move
// alone. The parts arrive as structure - a phrase and a distance in feet -
// and the card writes the sentence the pipeline used to publish finished.
const anchor: PoiDetail = {
...SHELTER,
description: 'Two-storey log shelter, sleeps 8, with a fireplace. Built 1954.',
nearby: [
{ phrase: 'a multi-seat moldering privy', distance_ft: 131.5 },
{ phrase: 'water', distance_ft: 295.3 },
],
}
const { rerender } = render(
<PoiCard poi={anchor} site={[anchor, PRIVY]} map={null} onClose={vi.fn()} />,
)
expect(
screen.getByText(
'Nearby: a multi-seat moldering privy 132 ft away and water 295 ft.',
),
).toBeInTheDocument()
// And the description it sits under is untouched by the swap - two
// paragraphs, one fact each.
expect(screen.getByText(/Two-storey log shelter/)).toBeInTheDocument()
rerender(
<PoiCard
poi={anchor}
site={[anchor, PRIVY]}
map={null}
units="metric"
onClose={vi.fn()}
/>,
)
expect(
screen.getByText('Nearby: a multi-seat moldering privy 40 m away and water 90 m.'),
).toBeInTheDocument()
})
it('puts the same number on the chip and in the sentence for one pair', () => {
// The property #625 had to preserve while moving both halves: the chip
// measures on the phone (from the pin), the sentence carries the pipeline's
// measurement (from the anchor), and where those are the same point the two
// must not print two numbers for one privy. Same formula, same constant,
// rounded once each in the same unit.
const anchor: PoiDetail = {
...SHELTER,
// What export_poi.py publishes for this pair: 0.00036° of latitude, in
// feet, unrounded.
nearby: [{ phrase: 'a multi-seat moldering privy', distance_ft: 131.48 }],
}
render(<PoiCard poi={anchor} site={[anchor, PRIVY]} map={null} onClose={vi.fn()} />)
expect(chips()[1]).toHaveTextContent('131 ft')
expect(
screen.getByText('Nearby: a multi-seat moldering privy 131 ft away.'),
).toBeInTheDocument()
})
it('says nothing about what is around a part that has nothing around it', () => {
// Most POIs, and every copy downloaded before the field existed. No empty
// paragraph either - a gap in the card reads as something failing to load.
const { container } = renderSite()
expect(container.querySelector('.poi-card__nearby')).toBeNull()
})
it('reads the parts off whichever part the card is showing', () => {
// A campsite is a member of a shelter's site AND the anchor of its own
// where there is no shelter, so tapping a chip can move to a waypoint with
// parts of its own. Reading `nearby` off the pin instead would say the
// shelter's parts under the campsite's name.
const campsite: PoiDetail = {
...CAMPSITE,
nearby: [{ phrase: 'a pit privy', distance_ft: 65.6 }],
}
render(
<PoiCard poi={SHELTER} site={[SHELTER, campsite]} map={null} onClose={vi.fn()} />,
)
expect(screen.queryByText(/Nearby:/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Campsite 82 ft' }))
expect(screen.getByText('Nearby: a pit privy 66 ft away.')).toBeInTheDocument()
})
it('prints the stated distance for a synthesized water member, never the zero its coordinates measure (#694)', () => {
// The pipeline synthesizes a water member from ATC's distance-to-water
// where no real point exists. ATC states how far, never where, so the
// member sits AT the shelter's own coordinates - and measuring that
// would print "Water · 0 ft" beside a sentence saying 120 ft, drift on
// one card in its worst form. The stated figure wins, and needs no
// conversion to do it: ATC states feet, the artifact publishes feet, and
// feet is what lib/units.ts formats from (#625).
const SYNTHESIZED_WATER: PoiDetail = {
id: 'atc_csi:xyz',
name: 'Water near Chairback Gap Lean-to',
type: 'water',
lat: SHELTER.lat,
lon: SHELTER.lon,
confidence: 'low',
source: 'atc_csi',
waterDistanceFt: 120,
}
renderSite([SHELTER, PRIVY, SYNTHESIZED_WATER])
expect(screen.getByRole('button', { name: 'Water 120 ft' })).toBeInTheDocument()
// And a real mapped water point - which carries no stated figure - keeps
// the measured offset exactly as before: 0.00036° of latitude is 40.1 m,
// which is 131 ft.
const REAL_WATER: PoiDetail = {
id: 'opentrail_at:77',
name: 'Piped Spring',
type: 'water',
lat: SHELTER.lat + 0.00036,
lon: SHELTER.lon,
confidence: 'high',
source: 'opentrail_at',
}
cleanup()
renderSite([SHELTER, PRIVY, REAL_WATER])
expect(screen.getByRole('button', { name: 'Water 131 ft' })).toBeInTheDocument()
// Both readings follow the hiker, which is the half #694 could not have:
// the stated figure and the measured one convert through one formatter.
cleanup()
render(
<PoiCard
poi={SHELTER}
site={[SHELTER, SYNTHESIZED_WATER]}
map={null}
units="metric"
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Water 37 m' })).toBeInTheDocument()
})
it('carries the same icon the map draws for each part', () => {
// One copy of the pin, which is the rule map/MapIcon.tsx is built on: a chip
// that drew its own privy silhouette would drift from the map's the first
// time either moved, and the chip's whole job is to be recognised.
renderSite()
const glyphs = chips().map((chip) => chip.querySelector('path')?.getAttribute('d'))
expect(glyphs).toEqual([
poiGlyphPath('shelter'),
poiGlyphPath('privy'),
poiGlyphPath('campsite'),
])
// And in the slot that gives it a size. MapIcon's SVG has no intrinsic
// dimensions, so a chip that asked for the pin without the class gets
// whatever the flex row decides - which jsdom would render happily and a
// stylesheet-contract test cannot see, because the rule would still be
// there with nothing using it.
for (const chip of chips()) {
expect(chip.querySelector('svg')).toHaveClass('poi-card__chip-icon')
}
})
it('leaves every chip a pin, the one you are reading included', () => {
// #711 took the words off the unselected chips; this takes them off the
// selected one too, which is what makes the strip fixed-width again - five
// 44px chips and four 4px gaps is 236 of the 240 the body has, so the
// largest site on the trail fits and tapping one no longer resizes the row
// under the thumb.
//
// THE CLASS, not the pixels, because jsdom does no layout: this is the half
// of the contract src/test/poiCardChipLayout.test.ts cannot see, and that
// file asserts the half this one cannot - that `visually-hidden` still takes
// the words out of the layout rather than merely out of sight.
renderSite()
const words = (chip: HTMLElement) => chip.querySelector('.poi-card__chip-label')
for (const chip of chips()) expect(words(chip)).toHaveClass('visually-hidden')
})
it('keeps every chip a pin whichever part you tap', () => {
// The half a first-render assertion cannot see, and the reason this is not
// "delete the conditional and move on": the words used to follow the
// selection, so the realistic regression is not the class disappearing but
// the OLD behaviour surviving a tap - the strip renders correctly until
// something is pressed, and every first-render test in this file stays
// green.
renderSite()
fireEvent.click(screen.getByRole('button', { name: 'Privy 131 ft' }))
const words = (chip: HTMLElement) => chip.querySelector('.poi-card__chip-label')
for (const chip of chips()) expect(words(chip)).toHaveClass('visually-hidden')
expect(chips()[1]).toHaveAttribute('aria-current', 'true')
})
it('says which part you are reading, and how far it is, under the strip', () => {
// WHERE THE CHIP'S WORDS WENT, and the reason taking them off the selected
// chip costs a sighted hiker nothing. The category was already on this line
// and the name is in the heading above it; the distance is the one fact that
// lived only on the chip, so it moves here rather than going away.
//
// Asserted on the meta line specifically, not on the card: `getByText` over
// the whole card would pass on the hidden chip label this change is
// deliberately keeping in the DOM, which is a test that cannot fail.
const { container } = renderSite()
const meta = () => container.querySelector('.poi-card__meta')
// The pin's own part carries no distance, exactly as its chip never did -
// the card hangs off that point, and "0 ft away" from the thing you are
// standing on is noise.
expect(meta()).toHaveTextContent('Shelter')
expect(meta()).not.toHaveTextContent('away')
fireEvent.click(screen.getByRole('button', { name: 'Privy 131 ft' }))
expect(meta()).toHaveTextContent('Privy')
expect(meta()).toHaveTextContent('131 ft away')
// And it follows the selection rather than being written once: the campsite
// is 82 ft, and a line that kept saying 131 would be the drift this card's
// whole distance story exists to prevent.
fireEvent.click(screen.getByRole('button', { name: 'Campsite 82 ft' }))
expect(meta()).toHaveTextContent('Campsite')
expect(meta()).toHaveTextContent('82 ft away')
expect(meta()).not.toHaveTextContent('131 ft')
})
it('states the meta line’s distance in the hiker’s own units', () => {
// The chip's distance was the single line in the app that had to be argued
// into the hiker's units (#625, features/POI_SITES.md), so moving it is
// exactly where that could be lost - `partDistance` takes `units` and a
// caller that stopped passing it would silently print feet to a hiker who
// chose metres, with every other test here green.
const { container } = render(
<PoiCard poi={SHELTER} site={SITE} map={null} units="metric" onClose={vi.fn()} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Privy 40 m' }))
expect(container.querySelector('.poi-card__meta')).toHaveTextContent('40 m away')
})
it('says as much to a screen reader with the words off as with them on', () => {
// The reason #711 is a small change rather than a risky one, and the thing
// most easily lost by "simplifying" it: `visually-hidden` rather than
// `display: none` or dropping the text, so the words stay in the
// accessibility tree and the buttons keep the names they had. A chip
// reduced to its pin with nothing else in it is a button whose accessible
// name is empty - unreachable by name, announced as "button".
renderSite()
expect(chips()[1]).toHaveAccessibleName('Privy 131 ft')
expect(chips()[2]).toHaveAccessibleName('Campsite 82 ft')
// The pin's own chip too, which is the one being read on a fresh card - so
// the part a sighted hiker now learns about from the meta line instead is
// still named on the button itself for anyone who cannot see either.
expect(chips()[0]).toHaveAccessibleName('Shelter')
})
it('carries each part’s own rim, broken where nobody has checked', () => {
// The chip's rim is a fact about ONE privy - which is where it parts company
// with the legend, whose pins carry no confidence at all because a key says
// what a category's symbol is. Drop the prop and every chip claims the same
// confidence: an unverified privy looks surveyed until you tap it, which is
// the honesty-about-uncertainty channel (OurHikeValues.md #4) this card is
// built around, silently gone. Assertable because MapIcon gives a verified
// pin no `stroke-dasharray` attribute at all rather than a solid-looking
// one - see the comment on `broken` there.
renderSite()
const rim = (chip: HTMLElement) => chip.querySelector('.map-icon__halo')
expect(rim(chips()[1])).toHaveAttribute('stroke-dasharray')