forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathappHarness.ts
More file actions
203 lines (185 loc) · 7.17 KB
/
Copy pathappHarness.ts
File metadata and controls
203 lines (185 loc) · 7.17 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
// The setup every App.*.test.tsx file was writing out for itself.
//
// Nine files each built the same fake for the mocked idb-keyval, the same
// north-running synthetic centerline, the same geolocation watch, and the same
// teardown. That is roughly three hundred lines saying nothing about any of
// them - and worse, it buried the four or five lines per file that DO say
// something. App.safety.test.tsx's navigator stub carried the comment "onLine:
// true, unlike App.flows' stub - the closure reads are gated on it", which is
// exactly the kind of difference a reader should be able to see at a glance
// and could not.
//
// What stays in each file: its `vi.mock(...)` calls, which are hoisted and are
// the real subject - whether lib/api is configured, whether lib/config is,
// what the map protocol resolves to. Those differ on purpose and the files say
// so in their own headers.
//
// `appHarness()` is called at MODULE scope, not inside `beforeEach`. It
// registers its own hooks there, so the store it returns has a stable identity
// the tests below can close over, and its `beforeEach` is registered before the
// file's own - which is the order the seeding needs.
import { afterEach, beforeEach, expect, vi } from 'vitest'
import { act, cleanup, waitFor } from '@testing-library/react'
import { del, get, set } from 'idb-keyval'
import { resetMapLibreMock } from './mocks/maplibre-gl'
import { PREFERENCES_KEY } from '../lib/preferences'
import { DEFAULT_PREFERENCES } from '../lib/userPreferences'
import { POIS_KEY, TRAILS_BLOB_KEY } from '../lib/trailData'
/** A mile of latitude, near enough that a vertex index IS a mile marker on the
* due-north centerline below. */
export const MILE_LAT = 1 / 69.05
/** The latitude a given mile of that centerline sits at. */
export function latOfMile(mile: number): number {
return 39 + mile * MILE_LAT
}
/**
* A centerline running due north from (-77, 39), one vertex per mile.
*
* Due north so that the mile arithmetic is legible in the assertions: a fix at
* `latOfMile(5)` is at mile 5, and a closure from marker 8 to 9 is three miles
* ahead of it. A real centerline wanders, and a test that had to account for
* that would be testing trigonometry rather than the shell.
*/
export function centerlineGeoJSON(
miles = 40,
properties: Record<string, unknown> = { source: 'centerline' },
): string {
return JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties,
geometry: {
type: 'LineString',
coordinates: Array.from({ length: miles }, (_, i) => [-77, latOfMile(i)]),
},
},
],
})
}
export interface HarnessOptions {
/**
* Replace `navigator`, or leave jsdom's alone.
*
* OPT-IN, and that is the important part. A stubbed navigator is a plain
* object, so `vi.spyOn(navigator, 'onLine', 'get')` cannot be applied to it -
* and three tests in App.trailData.test.tsx do exactly that to go offline
* mid-test. Files that only ever need one answer stub it; files that need to
* change the answer do not.
*
* `onLine` is worth setting deliberately when it is set at all: the shell's
* reads of closures, reports and ATC notices are all gated on it, so a file
* asserting a banner appears needs true and one asserting the offline path
* needs false. `geolocation` installs the watch `reportFixAtMile` delivers
* through.
*/
navigator?: { onLine?: boolean; geolocation?: boolean }
/**
* Stub `URL.createObjectURL`/`revokeObjectURL`, which jsdom implements
* neither of and the shell calls once per trail-data load.
*/
objectUrls?: boolean
/** Stub `fetch` with a mock that resolves nothing. On by default - a test
* reaching the real one is a test making a network call. Off for files
* that install their own. */
stubFetch?: boolean
}
export interface AppHarness {
/** What the mocked idb-keyval reads and writes. Cleared before each test. */
store: Map<string, unknown>
/**
* Preferences that put a hiker past onboarding and past the download
* question - the state nearly every test wants to start from, since neither
* screen is the subject.
*/
onboard(overrides?: Record<string, unknown>): void
/** Trail data already on the phone, so nothing is fetched and the centerline
* index is built from exactly this geometry. */
putTrailData(options?: { miles?: number; pois?: readonly unknown[] }): void
/**
* Deliver a GPS fix at a mile of the synthetic centerline.
*
* Waits for the watch to exist first, and that is not defensive padding. The
* watch starts in an effect that runs only once loaded preferences say
* location is allowed - a commit or two AFTER the map screen appears - so
* firing straight after `findByRole` sometimes found nothing registered, the
* fix was swallowed, and the test failed looking for a mile that was never
* going to arrive.
*/
reportFixAtMile(mile: number, lon?: number): Promise<void>
}
export function appHarness(options: HarnessOptions = {}): AppHarness {
const { navigator: nav, objectUrls = false, stubFetch = true } = options
const store = new Map<string, unknown>()
let watchSuccess: ((position: GeolocationPosition) => void) | undefined
beforeEach(() => {
store.clear()
watchSuccess = undefined
resetMapLibreMock()
vi.mocked(get).mockImplementation((key) => Promise.resolve(store.get(key as string)))
vi.mocked(set).mockImplementation((key, value) => {
store.set(key as string, value)
return Promise.resolve()
})
vi.mocked(del).mockImplementation((key) => {
store.delete(key as string)
return Promise.resolve()
})
if (stubFetch) vi.stubGlobal('fetch', vi.fn())
if (nav !== undefined) {
vi.stubGlobal('navigator', {
onLine: nav.onLine ?? false,
userAgent: '',
platform: '',
...(nav.geolocation === true
? {
geolocation: {
watchPosition: (success: (position: GeolocationPosition) => void) => {
watchSuccess = success
return 1
},
clearWatch: () => {},
},
}
: {}),
})
}
if (objectUrls) {
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:trails'),
revokeObjectURL: vi.fn(),
})
}
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
return {
store,
onboard(overrides: Record<string, unknown> = {}) {
store.set(PREFERENCES_KEY, {
...DEFAULT_PREFERENCES,
onboarding_completed: true,
download_choice_made: true,
...overrides,
})
},
putTrailData({ miles = 40, pois = [] } = {}) {
store.set(TRAILS_BLOB_KEY, new Blob([centerlineGeoJSON(miles)]))
store.set(POIS_KEY, pois)
},
async reportFixAtMile(mile: number, lon = -77) {
await waitFor(() => expect(watchSuccess).toBeDefined())
await act(async () => {
watchSuccess?.({
coords: { latitude: latOfMile(mile), longitude: lon, accuracy: 5 },
timestamp: 1_754_000_000_000,
} as unknown as GeolocationPosition)
})
},
}
}