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")) } }