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
package social.grain
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.jsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.location.LocationServices
import social.grain.support.StubServer
import social.grain.support.testJson
/**
* Place search and reverse geocoding against Nominatim.
*
* The parsing is the whole of it, and the shapes it has to survive are the
* awkward ones: a result with no `name`, one with no country code, and the
* `display_name` that has to be split into a name and the context that tells
* two identically-named corners apart.
*
* H3 itself is absent here — the native library ships for arm only — so this
* also pins the behaviour of the build where it didn't load: no cell, no
* coordinate, and nothing that throws.
*/
@RunWith(AndroidJUnit4::class)
class LocationServicesTest {
private lateinit var server: StubServer
private lateinit var locations: LocationServices
@Before
fun setUp() {
server = StubServer()
locations = LocationServices(server.client(), testJson)
}
@After
fun tearDown() = server.shutdown()
private fun result(
name: String? = "Mission District",
displayName: String? = "Mission District, San Francisco, California, United States",
lat: String? = "37.7599",
lon: String? = "-122.4148",
address: String = """{"city":"San Francisco","state":"California",""" +
""""country":"United States","country_code":"us","postcode":"94110",""" +
""""road":"Valencia Street","house_number":"1000"}""",
) = buildString {
append("{")
name?.let { append(""""name":"$it",""") }
displayName?.let { append(""""display_name":"$it",""") }
lat?.let { append(""""lat":"$it",""") }
lon?.let { append(""""lon":"$it",""") }
append(""""address":$address}""")
}
@Test
fun aSearchParsesTheNameTheContextAndTheAddressRecord() = runBlocking {
server.on("search", "[${result()}]")
val place = locations.search("mission").single()
assertEquals("Mission District", place.name)
assertEquals("San Francisco, California, United States", place.context)
assertEquals(37.7599, place.latitude, 0.0001)
assertEquals(-122.4148, place.longitude, 0.0001)
val address = place.address!!
assertEquals("US", address["country"]!!.jsonPrimitive.content)
assertEquals("San Francisco", address["locality"]!!.jsonPrimitive.content)
assertEquals("California", address["region"]!!.jsonPrimitive.content)
assertEquals("94110", address["postalCode"]!!.jsonPrimitive.content)
assertEquals(
"The house number is folded into the street line",
"1000 Valencia Street",
address["street"]!!.jsonPrimitive.content,
)
}
@Test
fun theQueryIsUrlEncodedAndCapped() = runBlocking {
server.on("search", "[]")
locations.search("mission district")
val call = server.lastCall("search")
assertEquals("mission district", call.params["q"])
assertEquals("5", call.params["limit"])
assertEquals("json", call.params["format"])
}
@Test
fun aQueryTooShortToBeUsefulIsNotSent() = runBlocking {
assertTrue(locations.search("m").isEmpty())
assertTrue(locations.search(" ").isEmpty())
assertEquals(0, server.countOf("search"))
}
@Test
fun aResultWithNoNameFallsBackToTheAddressItDoesHave() = runBlocking {
server.on("search", "[${result(name = null)}]")
val place = locations.search("mission").single()
assertEquals("San Francisco, California, United States", place.name)
}
@Test
fun aResultWithNoAddressAtAllFallsBackToTheDisplayName() = runBlocking {
server.on("search", "[${result(name = null, address = "{}")}]")
val place = locations.search("mission").single()
assertEquals("Mission District", place.name)
assertNull("With no country code there is no address record", place.address)
}
@Test
fun aResultWithNoCoordinatesIsDropped() = runBlocking {
server.on("search", "[${result(lat = null)}, ${result()}]")
assertEquals(1, locations.search("mission").size)
}
@Test
fun aSearchThatFailsComesBackEmptyRatherThanThrowing() = runBlocking {
server.on("search", """{"error":"rate limited"}""", code = 429)
assertTrue(locations.search("mission").isEmpty())
}
@Test
fun aBodyThatIsNotAListComesBackEmpty() = runBlocking {
server.on("search", """{"not":"a list"}""")
assertTrue(locations.search("mission").isEmpty())
}
@Test
fun reverseGeocodingReadsOnePlaceOutOfACoordinate() = runBlocking {
server.on("reverse", result())
val place = locations.reverseGeocode(37.76, -122.41)
assertEquals("Mission District", place!!.name)
val call = server.lastCall("reverse")
assertEquals("37.76", call.params["lat"])
assertEquals("-122.41", call.params["lon"])
}
@Test
fun reverseGeocodingThatFailsAnswersWithNothing() = runBlocking {
server.on("reverse", """{"error":"no"}""", code = 500)
assertNull(locations.reverseGeocode(37.76, -122.41))
}
@Test
fun theRequestIdentifiesItselfAsNominatimAsks() = runBlocking {
server.on("search", "[]")
locations.search("mission")
assertEquals("Nominatim's policy requires a User-Agent", "search", server.lastCall("search").nsid)
}
@Test
fun withoutTheNativeLibraryThereIsNoCellAndNoCoordinate() {
// h3 ships arm binaries only, so a JVM sandbox never has it — which is
// the same state an x86 emulator is in, and the app has to stay usable
// in both.
assertFalse(locations.isAvailable)
assertNull(locations.cellFor(37.76, -122.41))
assertNull(locations.coordinateFor("8928308280fffff"))
}
}