package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse 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.api.ActorListItem import social.grain.feature.search.SearchTab import social.grain.feature.search.SearchViewModel import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.applySnapshot import social.grain.support.awaitCall import social.grain.support.awaitValue import social.grain.support.json import social.grain.support.settle import social.grain.support.type /** * Search, which is two searches behind one field. * * The query is debounced and the collector cancels the request in flight when * the next keystroke arrives, so what this pins down is that a slow answer for * "fo" can't land after a fast one for "fog" — and that recents are per-account * and survive being edited. */ @RunWith(AndroidJUnit4::class) class SearchViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness @Before fun setUp() { harness = Harness() harness.server .on("dev.hatk.describeLabels", """{"definitions":[]}""") .on( "social.grain.unspecced.searchGalleries", """{"items":[${json(Fixtures.gallery(title = "Fog"))}]}""", ) .on( "social.grain.unspecced.searchProfiles", """{"items":[{"did":"did:plc:bob","handle":"bob.test","displayName":"Bob"}]}""", ) } @After fun tearDown() { harness.recentSearches.switchAccount(harness.did) harness.recentSearches.clear() harness.tearDown() } private fun viewModel(): SearchViewModel { harness.recentSearches.switchAccount(harness.did) harness.recentSearches.clear() return SearchViewModel( client = harness.client, auth = harness.auth, recents = harness.recentSearches, labelDefsCache = harness.labelDefinitions, ) } @Test fun typingAQuerySearchesGalleriesOnceTheTypingStops() { val model = viewModel() model.queryState.type("fog") val state = model.state.awaitValue("the results") { it.hasSearched } assertEquals("Fog", state.galleries.single().title) assertEquals("fog", state.query) assertFalse(state.isSearching) assertFalse(state.isEmptyQuery) assertEquals("fog", harness.server.lastCall("social.grain.unspecced.searchGalleries").params["q"]) } @Test fun clearingTheFieldEmptiesTheResultsWithoutSearching() { val model = viewModel() model.queryState.type("fog") model.state.awaitValue("the results") { it.hasSearched } val before = harness.server.countOf("social.grain.unspecced.searchGalleries") model.clearQuery() applySnapshot() val state = model.state.awaitValue("the results to clear") { !it.hasSearched } assertTrue(state.galleries.isEmpty()) assertTrue(state.isEmptyQuery) assertEquals(before, harness.server.countOf("social.grain.unspecced.searchGalleries")) } @Test fun theProfilesTabSearchesItsOwnEndpoint() { val model = viewModel() model.queryState.type("bob") model.state.awaitValue("the gallery results") { it.hasSearched } model.selectTab(SearchTab.PROFILES) val state = model.state.awaitValue("the profile results") { it.profiles.isNotEmpty() } assertEquals(SearchTab.PROFILES, state.tab) assertEquals("Bob", state.profiles.single().displayName) } @Test fun switchingToTheTabAlreadyShowingDoesNothing() { val model = viewModel() model.queryState.type("fog") model.state.awaitValue("the results") { it.hasSearched } val before = harness.server.countOf("social.grain.unspecced.searchGalleries") model.selectTab(SearchTab.GALLERIES) settle() assertEquals(before, harness.server.countOf("social.grain.unspecced.searchGalleries")) } @Test fun switchingTabsWithAnEmptyFieldFetchesNothing() { val model = viewModel() model.selectTab(SearchTab.PROFILES) settle() assertEquals(0, harness.server.countOf("social.grain.unspecced.searchProfiles")) } @Test fun aSearchThatFailsStillCountsAsHavingSearched() { harness.server.on("social.grain.unspecced.searchGalleries", """{"error":"no"}""", code = 500) val model = viewModel() model.queryState.type("fog") val state = model.state.awaitValue("the failed search") { it.hasSearched } assertFalse(state.isSearching) assertTrue(state.galleries.isEmpty()) } @Test fun committingASearchRemembersTheTerm() { val model = viewModel() model.queryState.type("fog") model.commitQuery() assertEquals(listOf("fog"), model.state.value.recentTerms) assertTrue(model.state.value.hasRecents) } @Test fun anEmptyQueryIsNotRemembered() { val model = viewModel() model.commitQuery() assertTrue(model.state.value.recentTerms.isEmpty()) assertFalse(model.state.value.hasRecents) } @Test fun pickingARecentTermRunsItAgain() { val model = viewModel() model.searchFor("fog") applySnapshot() val state = model.state.awaitValue("the results") { it.hasSearched } assertEquals("fog", state.query) assertEquals(listOf("fog"), state.recentTerms) } @Test fun recentTermsAndProfilesCanBeRemovedOneAtATimeOrAllAtOnce() { val model = viewModel() model.searchFor("fog") applySnapshot() model.state.awaitValue("the results") { it.hasSearched } model.rememberProfile( ActorListItem(did = "did:plc:bob", handle = "bob.test", displayName = "Bob"), ) assertEquals(1, model.state.value.recentProfiles.size) assertEquals("Bob", model.state.value.recentProfiles.single().displayName) model.removeRecentTerm("fog") assertTrue(model.state.value.recentTerms.isEmpty()) model.removeRecentProfile("did:plc:bob") assertTrue(model.state.value.recentProfiles.isEmpty()) model.searchFor("mist") applySnapshot() model.state.awaitValue("the second search") { it.recentTerms.isNotEmpty() } model.clearRecents() assertFalse(model.state.value.hasRecents) } @Test fun favoritingAResultFlipsTheHeartAndKeepsTheRecordUri() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/1"}""") val model = viewModel() model.queryState.type("fog") val gallery = model.state.awaitValue("the results") { it.hasSearched }.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 = viewModel() model.queryState.type("fog") val gallery = model.state.awaitValue("the results") { it.hasSearched }.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 unfavoritingAResultDeletesTheRecord() { harness.server.on( "social.grain.unspecced.searchGalleries", """{"items":[${json(Fixtures.gallery(favUri = "at://did:plc:me/social.grain.favorite/f1"))}]}""", ) val model = viewModel() model.queryState.type("fog") val gallery = model.state.awaitValue("the results") { it.hasSearched }.galleries.single() model.toggleFavorite(gallery.uri) assertTrue(harness.server.awaitCall("dev.hatk.deleteRecord").body.contains("\"rkey\":\"f1\"")) } @Test fun favoritingSomethingNotInTheResultsIsIgnored() { val model = viewModel() model.queryState.type("fog") model.state.awaitValue("the results") { it.hasSearched } model.toggleFavorite("at://nothing") settle() assertEquals(0, harness.server.countOf("dev.hatk.createRecord")) } @Test fun aCommentCountFollowsTheSheetOpenedOverAResult() { val model = viewModel() model.queryState.type("fog") val gallery = model.state.awaitValue("the results") { it.hasSearched }.galleries.single() model.setCommentCount(gallery.uri, 11) assertEquals(11, model.state.value.galleries.single().commentCount) } @Test fun deletingAResultRemovesTheCardOnceTheWriteLands() { val model = viewModel() model.queryState.type("fog") val gallery = model.state.awaitValue("the results") { it.hasSearched }.galleries.single() model.deleteGallery(gallery.uri) model.state.awaitValue("the card to go") { it.galleries.isEmpty() } } @Test fun aResultDeleteThatFailsLeavesTheCard() { harness.server.on( "social.grain.unspecced.deleteGallery", """{"error":"no"}""", code = 500, ) val model = viewModel() model.queryState.type("fog") val gallery = model.state.awaitValue("the results") { it.hasSearched }.galleries.single() model.deleteGallery(gallery.uri) settle() Thread.sleep(200) assertEquals(1, model.state.value.galleries.size) } }