package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull 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.feature.feed.FeedViewModel 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 /** * The feed's own state machine, with the server stubbed out. * * Everything here is behaviour a screen test can't see and a decode test * doesn't reach: which feed the pins resolve to, what a failed refresh leaves * on screen, and — the one that actually shipped broken once — whether an * optimistic favorite is put back when the write fails. */ @RunWith(AndroidJUnit4::class) class FeedViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness private val feed = """{"items":[${json(Fixtures.gallery())}],"cursor":"page2"}""" @Before fun setUp() { harness = Harness() runBlocking { harness.feedCache.purge(harness.did) } harness.server .on("dev.hatk.describeLabels", """{"definitions":[]}""") .on("dev.hatk.getPreferences", """{"preferences":{}}""") .on("social.grain.unspecced.getStoryAuthors", """{"authors":[]}""") .on("dev.hatk.getFeed", feed) } @After fun tearDown() { runBlocking { harness.feedCache.purge(harness.did) } harness.tearDown() } private fun viewModel() = FeedViewModel( client = harness.client, auth = harness.auth, feedCache = harness.feedCache, viewedStories = harness.viewedStories, storyStatusCache = harness.storyStatus, labelDefsCache = harness.labelDefinitions, ) @Test fun aColdStartLoadsTheDefaultFeedAndTheSignedInViewer() { val model = viewModel() val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } assertEquals("A walk in the fog", state.galleries.single().title) assertEquals("recent", state.selectedFeedId) assertFalse(state.isLoading) assertNull(state.errorMessage) val viewer = model.state.awaitValue("the viewer") { it.viewerDid != null } assertEquals(harness.did, viewer.viewerDid) assertEquals(harness.handle, viewer.viewerHandle) } @Test fun theFeedRequestIsAuthenticatedAndNamesTheSelectedFeed() { viewModel() val call = harness.server.awaitCall("dev.hatk.getFeed") assertEquals("recent", call.params["feed"]) assertEquals("DPoP test-access", call.authorization) } @Test fun preferencesReplaceTheDefaultPinsAndKeepTheSelectionWhenItSurvives() { harness.server.on( "dev.hatk.getPreferences", """{"preferences":{"pinnedFeeds":[ {"id":"recent","label":"Recent","type":"feed","path":"/"}, {"id":"camera:Leica M6","label":"Leica M6","type":"camera","path":"/c"} ]}}""", ) val model = viewModel() val state = model.state.awaitValue("the pins") { it.pinnedFeeds.size == 2 } assertEquals("recent", state.selectedFeedId) assertEquals("Leica M6", state.pinnedFeeds[1].label) } @Test fun pinsThatDoNotIncludeTheSelectedFeedFallBackToTheFirstOne() { harness.server.on( "dev.hatk.getPreferences", """{"preferences":{"pinnedFeeds":[ {"id":"foryou","label":"For you","type":"feed","path":"/f"} ]}}""", ) val model = viewModel() val state = model.state.awaitValue("the pins") { it.pinnedFeeds.size == 1 } assertEquals("foryou", state.selectedFeedId) } @Test fun aFollowingFeedSendsTheViewerAsTheActor() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } val before = harness.server.countOf("dev.hatk.getFeed") model.selectFeed("following") val call = harness.server.awaitCall("dev.hatk.getFeed", count = before + 1) assertEquals("following", call.params["feed"]) assertEquals(harness.did, call.params["actor"]) } @Test fun aCameraFeedSendsTheCameraRatherThanTheCompoundId() { harness.server.on( "dev.hatk.getPreferences", """{"preferences":{"pinnedFeeds":[ {"id":"recent","label":"Recent","type":"feed","path":"/"}, {"id":"camera:Leica M6","label":"Leica M6","type":"camera","path":"/c"} ]}}""", ) val model = viewModel() model.state.awaitValue("the pins and the first page") { it.pinnedFeeds.size == 2 && it.galleries.isNotEmpty() } val before = harness.server.countOf("dev.hatk.getFeed") model.selectFeed("camera:Leica M6") val call = harness.server.awaitCall("dev.hatk.getFeed", count = before + 1) assertEquals("camera", call.params["feed"]) assertEquals("Leica M6", call.params["camera"]) assertNull(call.params["actor"]) } @Test fun selectingTheFeedAlreadyShowingDoesNothing() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } val before = harness.server.countOf("dev.hatk.getFeed") model.selectFeed("recent") settle() assertEquals(before, harness.server.countOf("dev.hatk.getFeed")) } @Test fun aFailedRefreshKeepsTheGalleriesAndRaisesAMessage() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } harness.server.on("dev.hatk.getFeed", """{"error":"boom"}""", code = 500) model.refresh() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals(1, state.galleries.size) assertFalse(state.isRefreshing) model.dismissError() assertNull(model.state.value.errorMessage) } @Test fun loadingMoreAppendsThePageBehindTheCursor() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery(rkey = "gallery2", title = "Second"))}]}""", ) model.loadMore() val state = model.state.awaitValue("the second page") { it.galleries.size == 2 } assertEquals("Second", state.galleries[1].title) assertEquals("page2", harness.server.lastCall("dev.hatk.getFeed").params["cursor"]) assertFalse(state.isLoadingMore) } @Test fun theEndOfTheFeedStopsLoadingMore() { harness.server.on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } val before = harness.server.countOf("dev.hatk.getFeed") model.loadMore() settle() assertEquals("A cursorless page is the end", before, harness.server.countOf("dev.hatk.getFeed")) } @Test fun favoritingFlipsTheHeartBeforeTheWriteAndKeepsTheRecordUri() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/1"}""") val model = viewModel() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.toggleFavorite(gallery.uri) val optimistic = model.state.value.galleries.single() assertTrue("The heart fills before the write lands", optimistic.isFavorited) assertEquals(13, optimistic.favCount) val settled = model.state.awaitValue("the record uri") { it.galleries.single().viewer?.fav == "at://did:plc:me/fav/1" } assertEquals(13, settled.galleries.single().favCount) } @Test fun aFavoriteThatFailsIsPutBack() { harness.server.on("dev.hatk.createRecord", """{"error":"nope"}""", code = 500) val model = viewModel() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.toggleFavorite(gallery.uri) val reverted = model.state.awaitValue("the heart to empty again") { !it.galleries.single().isFavorited } assertEquals(12, reverted.galleries.single().favCount) } @Test fun unfavoritingDeletesTheRecordAndDecrementsTheCount() { harness.server.on( "dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery(favUri = "at://did:plc:me/social.grain.favorite/f1"))}]}""", ) val model = viewModel() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() assertTrue(gallery.isFavorited) model.toggleFavorite(gallery.uri) val call = harness.server.awaitCall("dev.hatk.deleteRecord") assertTrue(call.body.contains("\"rkey\":\"f1\"")) assertFalse(model.state.value.galleries.single().isFavorited) assertEquals(11, model.state.value.galleries.single().favCount) } @Test fun favoritingSomethingNotInTheFeedIsIgnored() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } model.toggleFavorite("at://nothing/here") settle() assertEquals(0, harness.server.countOf("dev.hatk.createRecord")) } @Test fun theCommentCountFollowsTheSheetOpenedOverTheCard() { val model = viewModel() val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } .galleries.single() model.setCommentCount(gallery.uri, 9) assertEquals(9, model.state.value.galleries.single().commentCount) } @Test fun deletingAGalleryRemovesTheCardOnlyOnceTheWriteLands() { val model = viewModel() 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() } assertTrue( harness.server.awaitCall("social.grain.unspecced.deleteGallery") .body.contains("gallery1"), ) } @Test fun aDeleteThatFailsLeavesTheCardAndSaysSo() { harness.server.on( "social.grain.unspecced.deleteGallery", """{"error":"nope"}""", code = 500, ) val model = viewModel() 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(1, state.galleries.size) } @Test fun theStoryStripIsFilledFromTheAuthorsCall() { harness.server.on( "social.grain.unspecced.getStoryAuthors", """{"authors":[${json(Fixtures.storyAuthor())},""" + """${json(Fixtures.storyAuthor(profile = Fixtures.profile(did = "did:plc:bob"), storyCount = 0))}]}""", ) val model = viewModel() val state = model.state.awaitValue("the strip") { it.storyAuthors.isNotEmpty() } assertEquals("An author with no live story isn't in the strip", 1, state.storyAuthors.size) assertTrue(harness.storyStatus.hasStory("did:plc:alice")) assertFalse(model.hasViewedAll(state.storyAuthors.single())) } @Test fun aFailedStoryStripDoesNotTakeTheFeedDownWithIt() { harness.server.on( "social.grain.unspecced.getStoryAuthors", """{"error":"nope"}""", code = 500, ) val model = viewModel() val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } assertTrue(state.storyAuthors.isEmpty()) assertNull(state.errorMessage) } @Test fun closingTheStoryViewerReReadsTheStrip() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } val before = harness.server.countOf("social.grain.unspecced.getStoryAuthors") model.onStoriesViewed() harness.server.awaitCall("social.grain.unspecced.getStoryAuthors", count = before + 1) } @Test fun aFreshFeedIsNotReloadedOnReturningToTheApp() { val model = viewModel() model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } settle() val before = harness.server.countOf("dev.hatk.getFeed") model.refreshIfStale() settle() assertEquals(before, harness.server.countOf("dev.hatk.getFeed")) } @Test fun theSelectedFeedResolvesThroughThePinsAndFallsBackWhenUnknown() { val model = viewModel() val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() } assertEquals("Recent", state.selectedFeed.label) assertEquals("Recent", state.copy(selectedFeedId = "nope").selectedFeed.label) } @Test fun theCachedPageRendersBeforeTheNetworkAnswers() { runBlocking { harness.feedCache.activeDid = harness.did harness.feedCache.save(listOf(Fixtures.gallery(rkey = "cached", title = "From disk")), "recent") } val model = viewModel() val fromNetwork = model.state.awaitValue("the network page") { it.galleries.singleOrNull()?.title == "A walk in the fog" } assertNotNull(fromNetwork) } }