From be819cc05b77cf0cb69c868fa9ae9a06607b56ac Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Wed, 2 May 2018 22:43:12 +0200 Subject: [PATCH] Add tests for search functionality in SFragment (#617) * Add tests for search functionality in SFragment * Parameterize url matching tests * Clean up / compartmentalize search tests * Make SFragmentTest filesystem location match package name --- .../tusky/fragment/SFragment.java | 27 +- .../tusky/fragment/SFragmentTest.kt | 311 ++++++++++++++++++ 2 files changed, 327 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/keylesspalace/tusky/fragment/SFragmentTest.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index e1f369da..f083184f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -266,7 +266,7 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov // https://pleroma.foo.bar/users/43456787654678 // https://pleroma.foo.bar/notice/43456787654678 // https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 - private static boolean looksLikeMastodonUrl(String urlString) { + static boolean looksLikeMastodonUrl(String urlString) { URI uri; try { uri = new URI(urlString); @@ -281,26 +281,27 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov } String path = uri.getPath(); - return path.matches("^/@[^/]*$") || + return path.matches("^/@[^/]+$") || path.matches("^/users/[^/]+$") || - path.matches("^/(@|notice)[^/]*/\\d+$") || + path.matches("^/@[^/]+/\\d+$") || + path.matches("^/notice/\\d+$") || path.matches("^/objects/[-a-f0-9]+$"); } - private void onBeginSearch(@NonNull String url) { + void onBeginSearch(@NonNull String url) { searchUrl = url; showQuerySheet(); } - private boolean getCancelSearchRequested(@NonNull String url) { + boolean getCancelSearchRequested(@NonNull String url) { return !url.equals(searchUrl); } - private boolean isSearching() { + boolean isSearching() { return searchUrl != null; } - private void onEndSearch(@NonNull String url) { + void onEndSearch(@NonNull String url) { if (url.equals(searchUrl)) { // Don't clear query if there's no match, // since we might just now be getting the response for a canceled search @@ -309,16 +310,20 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov } } - private void cancelActiveSearch() + void cancelActiveSearch() { if (isSearching()) { onEndSearch(searchUrl); } } + void openLink(@NonNull String url) { + LinkHelper.openLink(url, getContext()); + } + public void onViewURL(String url) { if (!looksLikeMastodonUrl(url)) { - LinkHelper.openLink(url, getContext()); + openLink(url); return; } @@ -346,14 +351,14 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov return; } } - LinkHelper.openLink(url, getContext()); + openLink(url); } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { if (!getCancelSearchRequested(url)) { onEndSearch(url); - LinkHelper.openLink(url, getContext()); + openLink(url); } } }); diff --git a/app/src/test/java/com/keylesspalace/tusky/fragment/SFragmentTest.kt b/app/src/test/java/com/keylesspalace/tusky/fragment/SFragmentTest.kt new file mode 100644 index 00000000..9d26cbcd --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/fragment/SFragmentTest.kt @@ -0,0 +1,311 @@ +/* Copyright 2018 Levi Bard + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment + +import android.text.SpannedString +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.SearchResults +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import okhttp3.Request +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.Mockito.* +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.* + +class SFragmentTest { + private lateinit var fragment : FakeSFragment + private lateinit var apiMock: MastodonApi + private val accountQuery = "http://mastodon.foo.bar/@User" + private val statusQuery = "http://mastodon.foo.bar/@User/345678" + private val nonMastodonQuery = "http://medium.com/@correspondent/345678" + private val emptyCallback = FakeSearchResults() + + private val account = Account ( + "1", + "admin", + "admin", + "Ad Min", + SpannedString(""), + "http://mastodon.foo.bar", + "", + "", + false, + 0, + 0, + 0, + null + ) + private val accountCallback = FakeSearchResults(account) + + private val status = Status( + "1", + statusQuery, + account, + null, + null, + null, + SpannedString("omgwat"), + Date(), + Collections.emptyList(), + 0, + 0, + false, + false, + false, + "", + Status.Visibility.PUBLIC, + arrayOf(), + arrayOf(), + null + ) + private val statusCallback = FakeSearchResults(status) + + @Before + fun setup() { + fragment = FakeSFragment() + + apiMock = Mockito.mock(MastodonApi::class.java) + `when`(apiMock.search(eq(accountQuery), ArgumentMatchers.anyBoolean())).thenReturn(accountCallback) + `when`(apiMock.search(eq(statusQuery), ArgumentMatchers.anyBoolean())).thenReturn(statusCallback) + `when`(apiMock.search(eq(nonMastodonQuery), ArgumentMatchers.anyBoolean())).thenReturn(emptyCallback) + fragment.mastodonApi = apiMock + } + + @RunWith(Parameterized::class) + class UrlMatchingTests(val url: String, val expectedResult: Boolean) { + companion object { + @Parameterized.Parameters(name = "match_{0}") + @JvmStatic + fun data() : Iterable { + return listOf( + arrayOf("https://mastodon.foo.bar/@User", true), + arrayOf("http://mastodon.foo.bar/@abc123", true), + arrayOf("https://mastodon.foo.bar/@user/345667890345678", true), + arrayOf("https://mastodon.foo.bar/@user/3", true), + arrayOf("https://pleroma.foo.bar/users/meh3223", true), + arrayOf("https://pleroma.foo.bar/users/2345", true), + arrayOf("https://pleroma.foo.bar/notice/9", true), + arrayOf("https://pleroma.foo.bar/notice/9345678", true), + arrayOf("https://pleroma.foo.bar/objects/abcdef-123-abcd-9876543", true), + arrayOf("https://google.com/", false), + arrayOf("https://mastodon.foo.bar/@User?foo=bar", false), + arrayOf("https://mastodon.foo.bar/@User#foo", false), + arrayOf("http://mastodon.foo.bar/@", false), + arrayOf("http://mastodon.foo.bar/@/345678", false), + arrayOf("https://mastodon.foo.bar/@user/345667890345678/", false), + arrayOf("https://mastodon.foo.bar/@user/3abce", false), + arrayOf("https://pleroma.foo.bar/users/", false), + arrayOf("https://pleroma.foo.bar/user/2345", false), + arrayOf("https://pleroma.foo.bar/notice/wat", false), + arrayOf("https://pleroma.foo.bar/notices/123456", false), + arrayOf("https://pleroma.foo.bar/object/abcdef-123-abcd-9876543", false), + arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd-9876543", false), + arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd-9876543/", false), + arrayOf("https://pleroma.foo.bar/objects/xabcdef-123-abcd_9876543", false) + ) + } + } + + @Test + fun test() { + Assert.assertEquals(expectedResult, SFragment.looksLikeMastodonUrl(url)) + } + } + + @Test + fun beginEndSearch_setIsSearching_isSearchingAfterBegin() { + fragment.onBeginSearch("https://mastodon.foo.bar/@User") + Assert.assertTrue(fragment.isSearching) + } + + @Test + fun beginEndSearch_setIsSearching_isNotSearchingAfterEnd() { + val validUrl = "https://mastodon.foo.bar/@User" + fragment.onBeginSearch(validUrl) + fragment.onEndSearch(validUrl) + Assert.assertFalse(fragment.isSearching) + } + + @Test + fun beginEndSearch_setIsSearching_doesNotCancelSearchWhenResponseFromPreviousSearchIsReceived() { + val validUrl = "https://mastodon.foo.bar/@User" + val invalidUrl = "" + + fragment.onBeginSearch(validUrl) + fragment.onEndSearch(invalidUrl) + Assert.assertTrue(fragment.isSearching) + } + + @Test + fun cancelActiveSearch() { + val url = "https://mastodon.foo.bar/@User" + + fragment.onBeginSearch(url) + fragment.cancelActiveSearch() + Assert.assertFalse(fragment.isSearching) + } + + @Test + fun getCancelSearchRequested_detectsURL() { + val firstUrl = "https://mastodon.foo.bar/@User" + val secondUrl = "https://mastodon.foo.bar/@meh" + + fragment.onBeginSearch(firstUrl) + fragment.cancelActiveSearch() + + fragment.onBeginSearch(secondUrl) + Assert.assertTrue(fragment.getCancelSearchRequested(firstUrl)) + Assert.assertFalse(fragment.getCancelSearchRequested(secondUrl)) + } + + @Test + fun search_inIdealConditions_returnsRequestedResults_forAccount() { + fragment.onViewURL(accountQuery) + accountCallback.invokeCallback() + Assert.assertEquals(account.id, fragment.accountId) + } + + @Test + fun search_inIdealConditions_returnsRequestedResults_forStatus() { + fragment.onViewURL(statusQuery) + statusCallback.invokeCallback() + Assert.assertEquals(status, fragment.status) + } + + @Test + fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() { + fragment.onViewURL(nonMastodonQuery) + emptyCallback.invokeCallback() + Assert.assertEquals(nonMastodonQuery, fragment.url) + } + + @Test + fun search_withCancellation_doesNotLoadUrl_forAccount() { + fragment.onViewURL(accountQuery) + Assert.assertTrue(fragment.isSearching) + fragment.cancelActiveSearch() + Assert.assertFalse(fragment.isSearching) + accountCallback.invokeCallback() + Assert.assertEquals(null, fragment.accountId) + } + + @Test + fun search_withCancellation_doesNotLoadUrl_forStatus() { + fragment.onViewURL(accountQuery) + fragment.cancelActiveSearch() + accountCallback.invokeCallback() + Assert.assertEquals(null, fragment.accountId) + } + + @Test + fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() { + fragment.onViewURL(nonMastodonQuery) + fragment.cancelActiveSearch() + emptyCallback.invokeCallback() + Assert.assertEquals(null, fragment.url) + } + + @Test + fun search_withPreviousCancellation_completes() { + // begin/cancel account search + fragment.onViewURL(accountQuery) + fragment.cancelActiveSearch() + + // begin status search + fragment.onViewURL(statusQuery) + + // return response from account search + accountCallback.invokeCallback() + + // ensure that status search is still ongoing + Assert.assertTrue(fragment.isSearching) + statusCallback.invokeCallback() + + // ensure that the result of the status search was recorded + // and the account search wasn't + Assert.assertEquals(status, fragment.status) + Assert.assertEquals(null, fragment.accountId) + } + + class FakeSearchResults : Call + { + private var searchResults: SearchResults + private var callback: Callback? = null + + constructor() { + searchResults = SearchResults(Collections.emptyList(), Collections.emptyList(), Collections.emptyList()) + } + + constructor(status: Status) { + searchResults = SearchResults(Collections.emptyList(), listOf(status), Collections.emptyList()) + } + + constructor(account: Account) { + searchResults = SearchResults(listOf(account), Collections.emptyList(), Collections.emptyList()) + } + + fun invokeCallback() { + callback?.onResponse(this, Response.success(searchResults)) + } + + override fun enqueue(callback: Callback?) { + this.callback = callback + } + + override fun isExecuted(): Boolean { throw NotImplementedError() } + override fun clone(): Call { throw NotImplementedError() } + override fun isCanceled(): Boolean { throw NotImplementedError() } + override fun cancel() { throw NotImplementedError() } + override fun execute(): Response { throw NotImplementedError() } + override fun request(): Request { throw NotImplementedError() } + } + + class FakeSFragment : SFragment() { + var status: Status? = null + var accountId: String? = null + var url: String? = null + + init { + callList = mutableListOf() + } + + override fun openLink(url: String) { + this.url = url + } + + override fun viewAccount(id: String?) { + accountId = id + } + + override fun viewThread(status: Status?) { + this.status = status + } + + override fun removeItem(position: Int) { throw NotImplementedError() } + override fun removeAllByAccountId(accountId: String?) { throw NotImplementedError() } + override fun timelineCases(): TimelineCases { throw NotImplementedError() } + } +} \ No newline at end of file