Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401package social.grain
import androidx.test.ext.junit.runners.AndroidJUnit4import kotlinx.coroutines.runBlockingimport org.junit.Afterimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertFalseimport org.junit.Assert.assertNotNullimport org.junit.Assert.assertNullimport org.junit.Assert.assertTrueimport org.junit.Beforeimport org.junit.Ruleimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.feature.feed.FeedViewModelimport social.grain.support.Fixturesimport social.grain.support.Harnessimport social.grain.support.MainDispatcherRuleimport social.grain.support.awaitCallimport social.grain.support.awaitValueimport social.grain.support.jsonimport 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) }}