~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
297 lines · 9.8 KB Raw History
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(
                "social.grain.gallery.searchGalleries",
                """{"items":[${json(Fixtures.gallery(title = "Fog"))}]}""",
            )
            .on(
                "social.grain.actor.searchActors",
                """{"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.gallery.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.gallery.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.gallery.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.gallery.searchGalleries")

        model.selectTab(SearchTab.GALLERIES)
        settle()

        assertEquals(before, harness.server.countOf("social.grain.gallery.searchGalleries"))
    }

    @Test
    fun switchingTabsWithAnEmptyFieldFetchesNothing() {
        val model = viewModel()
        model.selectTab(SearchTab.PROFILES)
        settle()

        assertEquals(0, harness.server.countOf("social.grain.actor.searchActors"))
    }

    @Test
    fun aSearchThatFailsStillCountsAsHavingSearched() {
        harness.server.on("social.grain.gallery.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("com.atproto.repo.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("com.atproto.repo.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.gallery.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("com.atproto.repo.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("com.atproto.repo.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(
            "com.atproto.repo.applyWrites",
            """{"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)
    }
}