~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
181 lines · 6.2 KB Raw History
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"))
    }
}