package social.grain import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import okhttp3.OkHttpClient 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.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.data.location.LocationServices import social.grain.feature.explore.CamerasViewModel import social.grain.feature.explore.LocationsViewModel import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.awaitCall import social.grain.support.awaitValue import social.grain.support.json import social.grain.support.settle import social.grain.support.testJson /** The two index screens behind explore's "See all". */ @RunWith(AndroidJUnit4::class) class BrowseIndexViewModelsTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness @Before fun setUp() { harness = Harness() harness.server .on( "social.grain.unspecced.getCameras", """{"cameras":[{"camera":"Leica M6","photoCount":120,"thumbs":["https://cdn.grain.test/thumb/1.jpg"]}]}""", ) .on("social.grain.unspecced.getLocations") { call -> if (call.params["pins"] == "true") { """{"locations":[ {"name":"Lisbon, PT","h3Index":"85390cb7fffffff","galleryCount":3}, {"name":"Porto, PT","h3Index":"853913d3fffffff","galleryCount":1} ]}""" } else { """{"locations":[{"name":"Lisbon, PT","h3Index":"85390cb7fffffff","galleryCount":3,"thumbs":["https://cdn.grain.test/thumb/2.jpg"]}]}""" } } } @After fun tearDown() = harness.tearDown() private fun cameras() = CamerasViewModel(harness.client, harness.auth) private fun locations( services: LocationServices = LocationServices(OkHttpClient(), testJson), route: SavedStateHandle = SavedStateHandle(), ) = LocationsViewModel(harness.client, harness.auth, services, harness.labelDefinitions, route) @Test fun camerasArriveWithTheirThumbnails() { val model = cameras() val state = model.state.awaitValue("the cameras") { !it.isLoading } assertEquals("Leica M6", state.items.single().camera) assertEquals(1, state.items.single().thumbs.size) assertFalse(state.isEmpty) assertNull(state.errorMessage) } @Test fun anEmptyCamerasIndexIsEmptyOnlyOnceItHasAnswered() { harness.server.on("social.grain.unspecced.getCameras", """{"cameras":[]}""") val model = cameras() assertFalse(model.state.value.isEmpty) val state = model.state.awaitValue("the answer") { !it.isLoading } assertTrue(state.isEmpty) } @Test fun aFailedCamerasLoadSaysSoAndCanBeDismissed() { harness.server.on("social.grain.unspecced.getCameras", """{"error":"no"}""", code = 500) val model = cameras() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("HTTP error 500", state.errorMessage) assertFalse(state.isLoading) model.dismissError() assertNull(model.state.value.errorMessage) } @Test fun refreshingCamerasShowsTheIndicatorAndReReads() { val model = cameras() model.state.awaitValue("the cameras") { !it.isLoading } val before = harness.server.countOf("social.grain.unspecced.getCameras") model.refresh() harness.server.awaitCall("social.grain.unspecced.getCameras", before + 1) model.state.awaitValue("the refresh to finish") { !it.isRefreshing } } @Test fun locationsAskForTilesAndPinsSeparately() { val model = locations() val state = model.state.awaitValue("the tiles") { !it.isLoading } assertEquals("Lisbon, PT", state.items.single().name) assertEquals(1, state.items.single().thumbs.size) // The two go out together, so the tiles can answer before the pins // request has even been recorded. harness.server.awaitCall("social.grain.unspecced.getLocations", 2) val calls = harness.server.callsTo("social.grain.unspecced.getLocations") assertEquals(2, calls.size) assertEquals(setOf(null, "true"), calls.map { it.params["pins"] }.toSet()) } @Test fun everyPlaceWithACoordinateBecomesAPin() { val services = LocationServices(OkHttpClient(), testJson) val model = locations(services) harness.server.awaitCall("social.grain.unspecced.getLocations", 2) model.state.awaitValue("the tiles") { !it.isLoading } // h3 is a native library that only ships for arm; where it can't load // there are no coordinates and so no pins, and that is the contract. if (services.isAvailable) { val pins = model.pins.awaitValue("the pins") { it.size == 2 } assertEquals(listOf("Lisbon, PT", "Porto, PT"), pins.map { it.name }) assertEquals(3, pins.first().galleryCount) } else { assertTrue(model.pins.value.isEmpty()) } } @Test fun pickingAPlaceLoadsItsGalleriesWithoutLeavingTheIndex() { harness.server.on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""") val model = locations() model.state.awaitValue("the tiles") { !it.isLoading } model.select("85390cb7fffffff", "Lisbon, PT", 3) val place = model.selected.awaitValue("the galleries") { it?.galleries?.isNotEmpty() == true }!! assertEquals("Lisbon, PT", place.name) assertEquals(3, place.galleryCount) assertFalse(place.isLoading) val call = harness.server.lastCall("dev.hatk.getFeed") assertEquals("location", call.params["feed"]) assertEquals("85390cb7fffffff", call.params["location"]) assertEquals("Lisbon, PT", call.params["name"]) // The index is still there underneath. assertEquals(1, model.state.value.items.size) } @Test fun moreOfAPlacesGalleriesAppendBehindTheCursor() { harness.server.on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""") val model = locations() model.select("85390cb7fffffff", "Lisbon, PT", 3) model.selected.awaitValue("the first page") { it?.galleries?.isNotEmpty() == true } harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery(rkey = "g2", title = "Second"))}]}""", ) model.loadMoreGalleries() val place = model.selected.awaitValue("the second page") { it?.galleries?.size == 2 }!! assertEquals("Second", place.galleries[1].title) assertEquals("c1", harness.server.lastCall("dev.hatk.getFeed").params["cursor"]) assertFalse(place.isLoadingMore) } @Test fun pickingThePlaceAlreadyShownDoesNotReload() { harness.server.on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") val model = locations() model.select("85390cb7fffffff", "Lisbon, PT", 3) model.selected.awaitValue("the galleries") { it?.galleries?.isNotEmpty() == true } val before = harness.server.countOf("dev.hatk.getFeed") model.select("85390cb7fffffff", "Lisbon, PT", 3) settle() assertEquals(before, harness.server.countOf("dev.hatk.getFeed")) } @Test fun openedFromAChipTheScreenStartsOnThatPlace() { harness.server.on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") val model = locations( route = SavedStateHandle( mapOf("cell" to "85390cb7fffffff", "name" to "Lisbon, PT", "galleryCount" to 3), ), ) assertTrue(model.openedOnPlace) val place = model.selected.awaitValue("the galleries") { it?.galleries?.isNotEmpty() == true }!! assertEquals("Lisbon, PT", place.name) assertEquals(3, place.galleryCount) assertEquals("85390cb7fffffff", harness.server.lastCall("dev.hatk.getFeed").params["location"]) } @Test fun openedFromSeeAllTheScreenStartsOnTheIndex() { val model = locations() assertFalse(model.openedOnPlace) assertNull(model.selected.value) } @Test fun clearingTheSelectionBringsTheIndexBack() { harness.server.on("dev.hatk.getFeed", """{"items":[]}""") val model = locations() model.select("85390cb7fffffff", "Lisbon, PT", 3) val place = model.selected.awaitValue("the answer") { it?.isLoading == false }!! assertTrue(place.isEmpty) model.clearSelection() assertNull(model.selected.value) } @Test fun aFailedLocationsLoadSaysSo() { harness.server.on("social.grain.unspecced.getLocations", """{"error":"no"}""", code = 500) val model = locations() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("HTTP error 500", state.errorMessage) assertTrue(model.pins.value.isEmpty()) model.dismissError() assertNull(model.state.value.errorMessage) } }