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.feed.CameraFeedViewModel import social.grain.feature.feed.HashtagFeedViewModel import social.grain.feature.feed.LocationFeedViewModel 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 feeds reached by tapping something on a card — a hashtag, a place name. * * They share a base class, so most of this exercises that: paging, optimistic * favourites, deletes. What the two subclasses add is one line each — which * `feed` parameter goes out — and that line is exactly what a screen test * cannot see. */ @RunWith(AndroidJUnit4::class) class GalleryFeedViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness @Before fun setUp() { harness = Harness() harness.server .on("dev.hatk.describeLabels", """{"definitions":[]}""") .on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") } @After fun tearDown() = harness.tearDown() private fun hashtag(tag: String = "fog") = HashtagFeedViewModel( client = harness.client, auth = harness.auth, labelDefsCache = harness.labelDefinitions, savedStateHandle = SavedStateHandle(mapOf("tag" to tag)), ) private fun location(cell: String = "8928308280fffff", name: String = "Mission District") = LocationFeedViewModel( client = harness.client, auth = harness.auth, locations = LocationServices(OkHttpClient(), testJson), labelDefsCache = harness.labelDefinitions, savedStateHandle = SavedStateHandle(mapOf("cell" to cell, "name" to name)), ) @Test fun aHashtagFeedAsksForThatTag() { val model = hashtag("fog") val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } assertEquals("fog", model.tag) assertFalse(state.isLoading) assertFalse(state.isEmpty) assertEquals(harness.did, state.viewerDid) val call = harness.server.lastCall("dev.hatk.getFeed") assertEquals("hashtag", call.params["feed"]) assertEquals("fog", call.params["tag"]) } @Test fun aLocationFeedAsksForThatCellAndKeepsTheNameItWasGiven() { val model = location() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } assertEquals("Mission District", model.locationName) assertEquals("8928308280fffff", model.cell) val call = harness.server.lastCall("dev.hatk.getFeed") assertEquals("location", call.params["feed"]) assertEquals("8928308280fffff", call.params["location"]) // The index counts a place across every cell it folded in; the name // is what makes the feed cover the same ground. assertEquals("Mission District", call.params["name"]) } @Test fun aCameraFeedAsksForThatCamera() { val model = CameraFeedViewModel( client = harness.client, auth = harness.auth, labelDefsCache = harness.labelDefinitions, savedStateHandle = SavedStateHandle(mapOf("name" to "Leica M6")), ) model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } assertEquals("Leica M6", model.camera) val call = harness.server.lastCall("dev.hatk.getFeed") assertEquals("camera", call.params["feed"]) assertEquals("Leica M6", call.params["camera"]) } @Test fun anEmptyAnswerIsOnlyReportedAsEmptyOnceItHasAnswered() { harness.server.on("dev.hatk.getFeed", """{"items":[]}""") val model = hashtag() val state = model.state.awaitValue("the answer") { !it.isLoading } assertTrue(state.isEmpty) } @Test fun aFailedLoadFallsBackToTheFeedsOwnWording() { harness.server.on("dev.hatk.getFeed", """{"error":"no"}""", code = 500) val model = hashtag() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("HTTP error 500", state.errorMessage) assertFalse(state.isRefreshing) model.dismissError() assertNull(model.state.value.errorMessage) } @Test fun pullingToRefreshShowsTheIndicatorAndReReads() { val model = hashtag() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } val before = harness.server.countOf("dev.hatk.getFeed") model.refresh() harness.server.awaitCall("dev.hatk.getFeed", before + 1) model.state.awaitValue("the refresh to finish") { !it.isRefreshing } } @Test fun moreGalleriesAppendBehindTheCursor() { harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""", ) val model = hashtag() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery(rkey = "g2", title = "Second"))}]}""", ) model.loadMore() val state = model.state.awaitValue("the second page") { it.galleries.size == 2 } assertEquals("Second", state.galleries[1].title) assertEquals("c1", harness.server.lastCall("dev.hatk.getFeed").params["cursor"]) assertFalse(state.isLoadingMore) } @Test fun aPageWithNoCursorIsTheEndOfTheFeed() { val model = hashtag() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } val before = harness.server.countOf("dev.hatk.getFeed") model.loadMore() settle() assertEquals(before, harness.server.countOf("dev.hatk.getFeed")) } @Test fun favoritingFlipsTheHeartThenKeepsTheRecordUri() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/1"}""") val model = hashtag() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.toggleFavorite(gallery.uri) assertEquals(13, model.state.value.galleries.single().favCount) model.state.awaitValue("the record uri") { it.galleries.single().viewer?.fav == "at://did:plc:me/fav/1" } } @Test fun aFavoriteThatFailsIsPutBack() { harness.server.on("dev.hatk.createRecord", """{"error":"no"}""", code = 500) val model = hashtag() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.toggleFavorite(gallery.uri) val state = model.state.awaitValue("the revert") { !it.galleries.single().isFavorited } assertEquals(12, state.galleries.single().favCount) } @Test fun unfavoritingDeletesTheRecord() { harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery(favUri = "at://did:plc:me/social.grain.favorite/f1"))}]}""", ) val model = hashtag() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.toggleFavorite(gallery.uri) assertTrue(harness.server.awaitCall("dev.hatk.deleteRecord").body.contains("\"rkey\":\"f1\"")) assertEquals(11, model.state.value.galleries.single().favCount) } @Test fun favoritingSomethingNotInTheFeedIsIgnored() { val model = hashtag() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } model.toggleFavorite("at://nothing") settle() assertEquals(0, harness.server.countOf("dev.hatk.createRecord")) } @Test fun theCommentCountFollowsTheSheetOpenedOverACard() { val model = hashtag() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.setCommentCount(gallery.uri, 7) assertEquals(7, model.state.value.galleries.single().commentCount) } @Test fun deletingAGalleryRemovesTheCardOnlyOnceTheWriteLands() { val model = hashtag() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.deleteGallery(gallery.uri) model.state.awaitValue("the card to go") { it.galleries.isEmpty() } } @Test fun aDeleteThatFailsLeavesTheCardAndSaysSo() { harness.server.on( "social.grain.unspecced.deleteGallery", """{"error":"no"}""", code = 500, ) val model = hashtag() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.deleteGallery(gallery.uri) val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("Couldn't delete that gallery.", state.errorMessage) assertEquals(1, state.galleries.size) } }