forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.hike.test.tsx
More file actions
200 lines (168 loc) · 7.55 KB
/
Copy pathApp.hike.test.tsx
File metadata and controls
200 lines (168 loc) · 7.55 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
// What a declared hike buys, at the level a hiker experiences it (#335).
//
// Its own file for the reason App.mapOverlays.test.tsx has one: this needs
// lib/api mocked as CONFIGURED so there are closures to warn about, and that
// would change the subject of every test in App.test.tsx, which deliberately
// runs unconfigured.
//
// The assertion that matters is the FIRST one. lib/hikeDirection.ts will not
// commit to NOBO or SOBO until a quarter mile has been walked in one
// direction - deliberately, so a GPS wandering under tree cover at a lunch
// stop does not flip the header - and until it does, "ahead" has no meaning
// and the banners say nothing. That quarter mile is exactly the stretch a
// hiker walks leaving a trailhead, which is where a closure two miles up the
// trail most needs saying.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { appHarness } from './test/appHarness'
import { PLANNED_HIKE_KEY } from './lib/plannedHike'
vi.mock('maplibre-gl', () => import('./test/mocks/maplibre-gl'))
vi.mock('idb-keyval', () => ({ get: vi.fn(), set: vi.fn(), del: vi.fn() }))
vi.mock('./map/archiveZooms', () => ({ readArchiveZooms: () => Promise.resolve(null) }))
/** Closed from mile 8 to 9 - two and a bit miles north of where the fix below
* puts the hiker, and behind a southbound one. */
const CLOSURE = {
id: 'c1',
reason_type: 'storm_damage' as const,
note: null,
status: 'closed' as const,
start_mile_marker: 8,
end_mile_marker: 9,
reported_at: '2026-08-01T10:00:00Z',
verified_at: '2026-08-02T10:00:00Z',
}
vi.mock('./lib/api', () => ({
API_CONFIGURED: true,
accessToken: vi.fn(async () => null),
sendReport: vi.fn(async () => undefined),
permanentFailureReason: vi.fn(() => null),
fetchClosures: vi.fn(async () => [CLOSURE]),
fetchReports: vi.fn(async () => []),
}))
// onLine because the map's own reads are gated on it (App.tsx): a navigator
// without it is `undefined`, which is falsy, so no closure ever arrives and
// every banner assertion below would pass by being vacuously silent.
const app = appHarness({
navigator: { onLine: true, geolocation: true },
objectUrls: true,
})
const store = app.store
beforeEach(() => {
app.onboard({ location_permission_requested: true })
app.putTrailData()
})
/**
* ONE fix, five miles up the trail, and deliberately only one.
*
* A second fix a quarter mile further on is what makes `hikeDirection.ts`
* commit. Sending only this one holds the app in exactly the state this
* feature exists for: a hiker who is somewhere, and about whom nothing is yet
* known regarding which way they are pointed.
*/
const reportOneFix = () => app.reportFixAtMile(5)
async function renderApp() {
render(<App />)
await screen.findByRole('region', { name: /trail map/i })
}
// Imported after the mocks above are in place.
const { default: App } = await import('./App')
describe('a hiker who has said which way they are walking', () => {
it('is warned about the closure ahead without walking a step', async () => {
// The payoff. Northbound from the southern terminus, one GPS fix, no
// movement - and the closure two miles up the trail is on screen.
store.set(PLANNED_HIKE_KEY, { startMile: 0, endMile: 30 })
await renderApp()
await reportOneFix()
expect(await screen.findByRole('alert')).toHaveTextContent(/trail closed/i)
})
it('reads that warning in the units they chose in Settings (#619)', async () => {
// The whole thread, end to end: a stored preference, read by the shell,
// handed to lib/closureBanner.ts, on screen in the one line a hiker sees
// without tapping anything. Every other test of this reaches one seam;
// this one proves the seams are joined.
//
// Mile 5, closure at 8: three miles ahead is 4.8 km. The mile-marker range
// in the same sentence stays as the trail has it, which is the exception
// lib/units.ts exists to keep visible.
app.onboard({ location_permission_requested: true, unit_system: 'metric' })
store.set(PLANNED_HIKE_KEY, { startMile: 0, endMile: 30 })
await renderApp()
await reportOneFix()
const banner = await screen.findByRole('alert')
expect(banner).toHaveTextContent('4.8 km ahead')
expect(banner).toHaveTextContent('mi 8.0 – 9.0')
})
it('is told nothing about a closure behind them', async () => {
// Southbound past the same closure. "Ahead" is the entire content of the
// banner, and getting it backwards would be worse than silence - it would
// warn about trail already walked while saying nothing about what is
// coming.
store.set(PLANNED_HIKE_KEY, { startMile: 30, endMile: 0 })
await renderApp()
await reportOneFix()
expect(screen.queryByRole('alert')).toBeNull()
})
})
describe('a hiker who has not', () => {
it('gets no closure banner until the GPS works out which way they are going', async () => {
// The gap this feature closes, asserted rather than described - without
// it, the test above would pass for the wrong reason.
await renderApp()
await reportOneFix()
expect(screen.queryByRole('alert')).toBeNull()
})
it('still has a working app, because a hike is optional', async () => {
await renderApp()
await reportOneFix()
expect(await screen.findByText(/mi 5\./)).toBeInTheDocument()
})
})
describe('setting one', () => {
it('goes from More, and shows up as a summary afterwards', async () => {
const user = userEvent.setup()
await renderApp()
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(
await screen.findByRole('button', { name: /say where you are walking/i }),
)
await user.click(
await screen.findByRole('button', { name: /whole trail, northbound/i }),
)
await user.click(screen.getByRole('button', { name: /^save$/i }))
expect(
await screen.findByRole('button', { name: /northbound · mi/i }),
).toBeInTheDocument()
})
it('survives the app being closed, because it is written to the phone', async () => {
const user = userEvent.setup()
await renderApp()
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(
await screen.findByRole('button', { name: /say where you are walking/i }),
)
await user.click(
await screen.findByRole('button', { name: /whole trail, northbound/i }),
)
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(store.get(PLANNED_HIKE_KEY)).toBeDefined())
// The end is the trail's own measured length rather than a round number:
// the shortcut reads `trailIndex.totalMiles`, which is haversine over the
// real geometry, so 40 vertices a mile apart come to a little over 39.
const saved = store.get(PLANNED_HIKE_KEY) as { startMile: number; endMile: number }
expect(saved.startMile).toBe(0)
expect(saved.endMile).toBeCloseTo(39, 1)
})
it('can be cleared back to having none', async () => {
const user = userEvent.setup()
store.set(PLANNED_HIKE_KEY, { startMile: 0, endMile: 30 })
await renderApp()
await user.click(screen.getByRole('tab', { name: 'More' }))
await user.click(await screen.findByRole('button', { name: /northbound · mi/i }))
await user.click(await screen.findByRole('button', { name: /clear this hike/i }))
await waitFor(() => expect(store.has(PLANNED_HIKE_KEY)).toBe(false))
expect(
await screen.findByRole('button', { name: /say where you are walking/i }),
).toBeInTheDocument()
})
})