#3734 Refactor and convert CategoryPresenter to kotlin (#3735)

This commit is contained in:
Seán Mac Gillicuddy 2020-05-15 14:17:26 +01:00 committed by GitHub
parent e597a7c96f
commit 4690925cf5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 593 additions and 888 deletions

View file

@ -1,242 +0,0 @@
package fr.free.nrw.commons.category;
import android.text.TextUtils;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.upload.GpsCategoryModel;
import fr.free.nrw.commons.utils.StringSortingUtils;
import io.reactivex.Observable;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
/**
* The model class for categories in upload
*/
public class CategoriesModel{
private static final int SEARCH_CATS_LIMIT = 25;
private final CategoryClient categoryClient;
private final CategoryDao categoryDao;
private final JsonKvStore directKvStore;
private final GpsCategoryModel gpsCategoryModel;
private List<CategoryItem> selectedCategories;
@Inject
public CategoriesModel(CategoryClient categoryClient,
CategoryDao categoryDao,
@Named("default_preferences") JsonKvStore directKvStore,
final GpsCategoryModel gpsCategoryModel) {
this.categoryClient = categoryClient;
this.categoryDao = categoryDao;
this.directKvStore = directKvStore;
this.gpsCategoryModel = gpsCategoryModel;
this.selectedCategories = new ArrayList<>();
}
/**
* Sorts CategoryItem by similarity
* @param filter
* @return
*/
public Comparator<CategoryItem> sortBySimilarity(final String filter) {
Comparator<String> stringSimilarityComparator = StringSortingUtils.sortBySimilarity(filter);
return (firstItem, secondItem) -> stringSimilarityComparator
.compare(firstItem.getName(), secondItem.getName());
}
/**
* Returns if the item contains an year
* @param item
* @return
*/
public boolean containsYear(String item) {
//Check for current and previous year to exclude these categories from removal
Calendar now = Calendar.getInstance();
int year = now.get(Calendar.YEAR);
String yearInString = String.valueOf(year);
int prevYear = year - 1;
String prevYearInString = String.valueOf(prevYear);
Timber.d("Previous year: %s", prevYearInString);
//Check if item contains a 4-digit word anywhere within the string (.* is wildcard)
//And that item does not equal the current year or previous year
//And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750)
//Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029
return ((item.matches(".*(19|20)\\d{2}.*") && !item.contains(yearInString) && !item.contains(prevYearInString))
|| item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)")
|| (item.matches(".*0s.*") && !item.matches(".*(200|201)0s.*")));
}
/**
* Updates category count in category dao
* @param item
*/
public void updateCategoryCount(CategoryItem item) {
Category category = categoryDao.find(item.getName());
// Newly used category...
if (category == null) {
category = new Category(null, item.getName(), new Date(), 0);
}
category.incTimesUsed();
categoryDao.save(category);
}
/**
* Regional category search
* @param term
* @param imageTitleList
* @return
*/
public Observable<CategoryItem> searchAll(String term, List<String> imageTitleList) {
//If query text is empty, show him category based on gps and title and recent searches
if (TextUtils.isEmpty(term)) {
Observable<CategoryItem> categoryItemObservable =
Observable.concat(gpsCategories(), titleCategories(imageTitleList));
if (hasDirectCategories()) {
return Observable.concat(
categoryItemObservable,
directCategories(),
recentCategories()
);
}
return categoryItemObservable;
}
//otherwise, search API for matching categories
//term passed as lower case to make search case-insensitive(taking only lower case for everything)
return categoryClient
.searchCategoriesForPrefix(term.toLowerCase(), SEARCH_CATS_LIMIT)
.map(name -> new CategoryItem(name, false));
}
/**
* Returns if we have a category in DirectKV Store
* @return
*/
private boolean hasDirectCategories() {
return !directKvStore.getString("Category", "").equals("");
}
/**
* Returns categories in DirectKVStore
* @return
*/
private Observable<CategoryItem> directCategories() {
String directCategory = directKvStore.getString("Category", "");
List<String> categoryList = new ArrayList<>();
Timber.d("Direct category found: " + directCategory);
if (!directCategory.equals("")) {
categoryList.add(directCategory);
Timber.d("DirectCat does not equal emptyString. Direct Cat list has " + categoryList);
}
return Observable.fromIterable(categoryList).map(name -> new CategoryItem(name, false));
}
/**
* Returns GPS categories
* @return
*/
Observable<CategoryItem> gpsCategories() {
return Observable.fromIterable(gpsCategoryModel.getCategoryList())
.map(name -> new CategoryItem(name, false));
}
/**
* Returns title based categories
* @param titleList
* @return
*/
private Observable<CategoryItem> titleCategories(List<String> titleList) {
return Observable.fromIterable(titleList)
.concatMap(this::getTitleCategories);
}
/**
* Return category for single title
* title is converted to lower case to make search case-insensitive
* @param title
* @return
*/
private Observable<CategoryItem> getTitleCategories(String title) {
return categoryClient.searchCategories(title.toLowerCase(), SEARCH_CATS_LIMIT)
.map(name -> new CategoryItem(name, false));
}
/**
* Returns recent categories
* @return
*/
private Observable<CategoryItem> recentCategories() {
return Observable.fromIterable(categoryDao.recentCategories(SEARCH_CATS_LIMIT))
.map(s -> new CategoryItem(s, false));
}
/**
* Handles category item selection
* @param item
*/
public void onCategoryItemClicked(CategoryItem item) {
if (item.isSelected()) {
selectCategory(item);
updateCategoryCount(item);
} else {
unselectCategory(item);
}
}
/**
* Select's category
* @param item
*/
public void selectCategory(CategoryItem item) {
selectedCategories.add(item);
}
/**
* Unselect Category
* @param item
*/
public void unselectCategory(CategoryItem item) {
selectedCategories.remove(item);
}
/**
* Get Selected Categories
* @return
*/
public List<CategoryItem> getSelectedCategories() {
return selectedCategories;
}
/**
* Get Categories String List
* @return
*/
public List<String> getCategoryStringList() {
List<String> output = new ArrayList<>();
for (CategoryItem item : selectedCategories) {
output.add(item.getName());
}
return output;
}
/**
* Cleanup the existing in memory cache's
*/
public void cleanUp() {
this.selectedCategories.clear();
}
}

View file

@ -0,0 +1,152 @@
package fr.free.nrw.commons.category
import android.text.TextUtils
import fr.free.nrw.commons.upload.GpsCategoryModel
import fr.free.nrw.commons.utils.StringSortingUtils
import io.reactivex.Observable
import io.reactivex.functions.Function3
import timber.log.Timber
import java.util.*
import javax.inject.Inject
/**
* The model class for categories in upload
*/
class CategoriesModel @Inject constructor(
private val categoryClient: CategoryClient,
private val categoryDao: CategoryDao,
private val gpsCategoryModel: GpsCategoryModel
) {
private val selectedCategories: MutableList<CategoryItem> = mutableListOf()
/**
* Returns if the item contains an year
* @param item
* @return
*/
fun containsYear(item: String): Boolean {
//Check for current and previous year to exclude these categories from removal
val now = Calendar.getInstance()
val year = now[Calendar.YEAR]
val yearInString = year.toString()
val prevYear = year - 1
val prevYearInString = prevYear.toString()
Timber.d("Previous year: %s", prevYearInString)
//Check if item contains a 4-digit word anywhere within the string (.* is wildcard)
//And that item does not equal the current year or previous year
//And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750)
//Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029
return item.matches(".*(19|20)\\d{2}.*".toRegex())
&& !item.contains(yearInString)
&& !item.contains(prevYearInString)
|| item.matches("(.*)needing(.*)".toRegex())
|| item.matches("(.*)taken on(.*)".toRegex())
|| item.matches(".*0s.*".toRegex())
&& !item.matches(".*(200|201)0s.*".toRegex())
}
/**
* Updates category count in category dao
* @param item
*/
fun updateCategoryCount(item: CategoryItem) {
var category = categoryDao.find(item.name)
// Newly used category...
if (category == null) {
category = Category(null, item.name, Date(), 0)
}
category.incTimesUsed()
categoryDao.save(category)
}
/**
* Regional category search
* @param term
* @param imageTitleList
* @return
*/
fun searchAll(term: String, imageTitleList: List<String>): Observable<List<CategoryItem>> {
return suggestionsOrSearch(term, imageTitleList)
.map { it.map { CategoryItem(it, false) } }
}
private fun suggestionsOrSearch(term: String, imageTitleList: List<String>):
Observable<List<String>> {
return if (TextUtils.isEmpty(term))
Observable.combineLatest(
gpsCategoryModel.categoriesFromLocation,
titleCategories(imageTitleList),
Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)),
Function3(::combine)
)
else
categoryClient.searchCategoriesForPrefix(term.toLowerCase(), SEARCH_CATS_LIMIT)
.map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) }
}
private fun combine(
locationCategories: List<String>,
titles: List<String>,
recents: List<String>
) = locationCategories + titles + recents
/**
* Returns title based categories
* @param titleList
* @return
*/
private fun titleCategories(titleList: List<String>): Observable<List<String>> {
return if (titleList.isNotEmpty())
Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults ->
searchResults.map { it as List<String> }.flatten()
}
else
Observable.just(emptyList())
}
/**
* Return category for single title
* title is converted to lower case to make search case-insensitive
* @param title
* @return
*/
private fun getTitleCategories(title: String): Observable<List<String>> {
return categoryClient.searchCategories(title.toLowerCase(), SEARCH_CATS_LIMIT)
}
/**
* Handles category item selection
* @param item
*/
fun onCategoryItemClicked(item: CategoryItem) {
if (item.isSelected) {
selectedCategories.add(item)
updateCategoryCount(item)
} else {
selectedCategories.remove(item)
}
}
/**
* Get Selected Categories
* @return
*/
fun getSelectedCategories(): List<CategoryItem> {
return selectedCategories
}
/**
* Cleanup the existing in memory cache's
*/
fun cleanUp() {
selectedCategories.clear()
}
companion object {
private const val SEARCH_CATS_LIMIT = 25
}
}

View file

@ -1,125 +0,0 @@
package fr.free.nrw.commons.category;
import androidx.annotation.NonNull;
import org.wikipedia.dataclient.mwapi.MwQueryPage;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.dataclient.mwapi.MwQueryResult;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import io.reactivex.Observable;
import timber.log.Timber;
/**
* Category Client to handle custom calls to Commons MediaWiki APIs
*/
@Singleton
public class CategoryClient {
private final CategoryInterface CategoryInterface;
@Inject
public CategoryClient(CategoryInterface CategoryInterface) {
this.CategoryInterface = CategoryInterface;
}
/**
* Searches for categories containing the specified string.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
* @return
*/
public Observable<String> searchCategories(String filter, int itemLimit, int offset) {
return responseToCategoryName(CategoryInterface.searchCategories(filter, itemLimit, offset));
}
/**
* Searches for categories containing the specified string.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @return
*/
public Observable<String> searchCategories(String filter, int itemLimit) {
return searchCategories(filter, itemLimit, 0);
}
/**
* Searches for categories starting with the specified string.
*
* @param prefix The prefix to be searched
* @param itemLimit How many results are returned
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
* @return
*/
public Observable<String> searchCategoriesForPrefix(String prefix, int itemLimit, int offset) {
return responseToCategoryName(CategoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset));
}
/**
* Searches for categories starting with the specified string.
*
* @param prefix The prefix to be searched
* @param itemLimit How many results are returned
* @return
*/
public Observable<String> searchCategoriesForPrefix(String prefix, int itemLimit) {
return searchCategoriesForPrefix(prefix, itemLimit, 0);
}
/**
* The method takes categoryName as input and returns a List of Subcategories
* It uses the generator query API to get the subcategories in a category, 500 at a time.
*
* @param categoryName Category name as defined on commons
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
*/
public Observable<String> getSubCategoryList(String categoryName) {
return responseToCategoryName(CategoryInterface.getSubCategoryList(categoryName));
}
/**
* The method takes categoryName as input and returns a List of parent categories
* It uses the generator query API to get the parent categories of a category, 500 at a time.
*
* @param categoryName Category name as defined on commons
* @return
*/
@NonNull
public Observable<String> getParentCategoryList(String categoryName) {
return responseToCategoryName(CategoryInterface.getParentCategoryList(categoryName));
}
/**
* Internal function to reduce code reuse. Extracts the categories returned from MwQueryResponse.
*
* @param responseObservable The query response observable
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
*/
private Observable<String> responseToCategoryName(Observable<MwQueryResponse> responseObservable) {
return responseObservable
.flatMap(mwQueryResponse -> {
MwQueryResult query;
List<MwQueryPage> pages;
if ((query = mwQueryResponse.query()) == null ||
(pages = query.pages()) == null) {
Timber.d("No categories returned.");
return Observable.empty();
} else
return Observable.fromIterable(pages);
})
.map(MwQueryPage::title)
.doOnEach(s -> Timber.d("Category returned: %s", s))
.map(cat -> cat.replace("Category:", ""));
}
}

View file

@ -0,0 +1,79 @@
package fr.free.nrw.commons.category
import io.reactivex.Observable
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import javax.inject.Inject
import javax.inject.Singleton
/**
* Category Client to handle custom calls to Commons MediaWiki APIs
*/
@Singleton
class CategoryClient @Inject constructor(private val categoryInterface: CategoryInterface) {
/**
* Searches for categories containing the specified string.
*
* @param filter The string to be searched
* @param itemLimit How many results are returned
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
* @return
*/
@JvmOverloads
fun searchCategories(filter: String?, itemLimit: Int, offset: Int = 0):
Observable<List<String>> {
return responseToCategoryName(categoryInterface.searchCategories(filter, itemLimit, offset))
}
/**
* Searches for categories starting with the specified string.
*
* @param prefix The prefix to be searched
* @param itemLimit How many results are returned
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
* @return
*/
@JvmOverloads
fun searchCategoriesForPrefix(prefix: String?, itemLimit: Int, offset: Int = 0):
Observable<List<String>> {
return responseToCategoryName(
categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset)
)
}
/**
* The method takes categoryName as input and returns a List of Subcategories
* It uses the generator query API to get the subcategories in a category, 500 at a time.
*
* @param categoryName Category name as defined on commons
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
*/
fun getSubCategoryList(categoryName: String?): Observable<List<String>> {
return responseToCategoryName(categoryInterface.getSubCategoryList(categoryName))
}
/**
* The method takes categoryName as input and returns a List of parent categories
* It uses the generator query API to get the parent categories of a category, 500 at a time.
*
* @param categoryName Category name as defined on commons
* @return
*/
fun getParentCategoryList(categoryName: String?): Observable<List<String>> {
return responseToCategoryName(categoryInterface.getParentCategoryList(categoryName))
}
/**
* Internal function to reduce code reuse. Extracts the categories returned from MwQueryResponse.
*
* @param responseObservable The query response observable
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
*/
private fun responseToCategoryName(responseObservable: Observable<MwQueryResponse>): Observable<List<String>> {
return responseObservable
.map { it.query()?.pages() ?: emptyList() }
.map {
it.map { page -> page.title().replace("Category:", "") }
}
}
}

View file

@ -1,79 +0,0 @@
package fr.free.nrw.commons.category;
import android.os.Parcel;
import android.os.Parcelable;
public class CategoryItem implements Parcelable {
private final String name;
private boolean selected;
public static Creator<CategoryItem> CREATOR = new Creator<CategoryItem>() {
@Override
public CategoryItem createFromParcel(Parcel parcel) {
return new CategoryItem(parcel);
}
@Override
public CategoryItem[] newArray(int i) {
return new CategoryItem[0];
}
};
public CategoryItem(String name, boolean selected) {
this.name = name;
this.selected = selected;
}
private CategoryItem(Parcel in) {
name = in.readString();
selected = in.readInt() == 1;
}
public String getName() {
return name;
}
public boolean isSelected() {
return selected;
}
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel parcel, int flags) {
parcel.writeString(name);
parcel.writeInt(selected ? 1 : 0);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CategoryItem that = (CategoryItem) o;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public String toString() {
return "CategoryItem: '" + name + '\'';
}
}

View file

@ -0,0 +1,27 @@
package fr.free.nrw.commons.category
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class CategoryItem(val name: String, var isSelected: Boolean) : Parcelable {
override fun toString(): String {
return "CategoryItem: '$name'"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CategoryItem
if (name != other.name) return false
return true
}
override fun hashCode(): Int {
return name.hashCode()
}
}

View file

@ -91,13 +91,11 @@ public class SubCategoryListFragment extends CommonsDaggerSupportFragment {
compositeDisposable.add(categoryClient.getParentCategoryList("Category:"+categoryName) compositeDisposable.add(categoryClient.getParentCategoryList("Category:"+categoryName)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.collect(ArrayList<String>::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError)); .subscribe(this::handleSuccess, this::handleError));
} else { } else {
compositeDisposable.add(categoryClient.getSubCategoryList("Category:"+categoryName) compositeDisposable.add(categoryClient.getSubCategoryList("Category:"+categoryName)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.collect(ArrayList<String>::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError)); .subscribe(this::handleSuccess, this::handleError));
} }
} }

View file

@ -133,7 +133,6 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe(disposable -> saveQuery(query)) .doOnSubscribe(disposable -> saveQuery(query))
.collect(ArrayList<String>::new, ArrayList::add)
.subscribe(this::handleSuccess, this::handleError)); .subscribe(this::handleSuccess, this::handleError));
} }
@ -150,7 +149,6 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
compositeDisposable.add(categoryClient.searchCategories(query,25, queryList.size()) compositeDisposable.add(categoryClient.searchCategories(query,25, queryList.size())
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.collect(ArrayList<String>::new, ArrayList::add)
.subscribe(this::handlePaginationSuccess, this::handleError)); .subscribe(this::handlePaginationSuccess, this::handleError));
} }

View file

@ -18,7 +18,6 @@ import io.reactivex.Flowable;
import io.reactivex.Observable; import io.reactivex.Observable;
import io.reactivex.Single; import io.reactivex.Single;
import java.io.IOException; import java.io.IOException;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import javax.inject.Inject; import javax.inject.Inject;
@ -111,20 +110,10 @@ public class UploadRepository {
* @param imageTitleList * @param imageTitleList
* @return * @return
*/ */
public Observable<CategoryItem> searchAll(String query, List<String> imageTitleList) { public Observable<List<CategoryItem>> searchAll(String query, List<String> imageTitleList) {
return categoriesModel.searchAll(query, imageTitleList); return categoriesModel.searchAll(query, imageTitleList);
} }
/**
* returns the string list of categories
*
* @return
*/
public List<String> getCategoryStringList() {
return categoriesModel.getCategoryStringList();
}
/** /**
* sets the list of selected categories for the current upload * sets the list of selected categories for the current upload
* *
@ -143,16 +132,6 @@ public class UploadRepository {
categoriesModel.onCategoryItemClicked(categoryItem); categoriesModel.onCategoryItemClicked(categoryItem);
} }
/**
* returns category sorted based on similarity with query
*
* @param query
* @return
*/
public Comparator<? super CategoryItem> sortBySimilarity(String query) {
return categoriesModel.sortBySimilarity(query);
}
/** /**
* prunes the category list for irrelevant categories see #750 * prunes the category list for irrelevant categories see #750
* *

View file

@ -180,7 +180,7 @@ class FileProcessor @Inject constructor(
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.subscribe( .subscribe(
{ gpsCategoryModel.categoryList = it }, gpsCategoryModel::setCategoriesFromLocation,
{ {
Timber.e(it) Timber.e(it)
gpsCategoryModel.clear() gpsCategoryModel.clear()

View file

@ -1,36 +0,0 @@
package fr.free.nrw.commons.upload;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
@Singleton
public class GpsCategoryModel {
private Set<String> categorySet;
@Inject
public GpsCategoryModel() {
clear();
}
public void clear() {
categorySet = new HashSet<>();
}
public boolean getGpsCatExists() {
return !categorySet.isEmpty();
}
public List<String> getCategoryList() {
return new ArrayList<>(categorySet);
}
public void setCategoryList(List<String> categoryList) {
clear();
categorySet.addAll(categoryList != null ? categoryList : new ArrayList<>());
}
}

View file

@ -0,0 +1,18 @@
package fr.free.nrw.commons.upload
import io.reactivex.subjects.BehaviorSubject
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GpsCategoryModel @Inject constructor() {
val categoriesFromLocation = BehaviorSubject.createDefault(emptyList<String>())
fun clear() {
categoriesFromLocation.onNext(emptyList())
}
fun setCategoriesFromLocation(categoryList: List<String>) {
categoriesFromLocation.onNext(categoryList)
}
}

View file

@ -30,7 +30,6 @@ import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.category.CategoriesModel;
import fr.free.nrw.commons.contributions.ContributionController; import fr.free.nrw.commons.contributions.ContributionController;
import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.kvstore.JsonKvStore;
@ -63,8 +62,6 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
@Inject @Inject
UploadContract.UserActionListener presenter; UploadContract.UserActionListener presenter;
@Inject @Inject
CategoriesModel categoriesModel;
@Inject
SessionManager sessionManager; SessionManager sessionManager;
@Inject @Inject
UserClient userClient; UserClient userClient;
@ -96,7 +93,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
private CompositeDisposable compositeDisposable; private CompositeDisposable compositeDisposable;
private ProgressDialog progressDialog; private ProgressDialog progressDialog;
private UploadImageAdapter uploadImagesAdapter; private UploadImageAdapter uploadImagesAdapter;
private List<Fragment> fragments; private List<UploadBaseFragment> fragments;
private UploadCategoriesFragment uploadCategoriesFragment; private UploadCategoriesFragment uploadCategoriesFragment;
private DepictsFragment depictsFragment; private DepictsFragment depictsFragment;
private MediaLicenseFragment mediaLicenseFragment; private MediaLicenseFragment mediaLicenseFragment;
@ -416,6 +413,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
public void onNextButtonClicked(int index) { public void onNextButtonClicked(int index) {
if (index < fragments.size() - 1) { if (index < fragments.size() - 1) {
vpUpload.setCurrentItem(index + 1, false); vpUpload.setCurrentItem(index + 1, false);
fragments.get(index + 1).onBecameVisible();
} else { } else {
presenter.handleSubmit(); presenter.handleSubmit();
} }
@ -425,6 +423,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
public void onPreviousButtonClicked(int index) { public void onPreviousButtonClicked(int index) {
if (index != 0) { if (index != 0) {
vpUpload.setCurrentItem(index - 1, true); vpUpload.setCurrentItem(index - 1, true);
fragments.get(index - 1).onBecameVisible();
} }
} }
@ -433,14 +432,14 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
*/ */
private class UploadImageAdapter extends FragmentStatePagerAdapter { private class UploadImageAdapter extends FragmentStatePagerAdapter {
List<Fragment> fragments; List<UploadBaseFragment> fragments;
public UploadImageAdapter(FragmentManager fragmentManager) { public UploadImageAdapter(FragmentManager fragmentManager) {
super(fragmentManager); super(fragmentManager);
this.fragments = new ArrayList<>(); this.fragments = new ArrayList<>();
} }
public void setFragments(List<Fragment> fragments) { public void setFragments(List<UploadBaseFragment> fragments) {
this.fragments = fragments; this.fragments = fragments;
notifyDataSetChanged(); notifyDataSetChanged();
} }

View file

@ -22,6 +22,9 @@ public class UploadBaseFragment extends CommonsDaggerSupportFragment {
this.callback = callback; this.callback = callback;
} }
protected void onBecameVisible() {
}
public interface Callback { public interface Callback {
void onNextButtonClicked(int index); void onNextButtonClicked(int index);

View file

@ -22,8 +22,8 @@ public abstract class UploadModule {
presenter); presenter);
@Binds @Binds
public abstract CategoriesContract.UserActionListener bindsCategoriesPresenter(CategoriesPresenter public abstract CategoriesContract.UserActionListener bindsCategoriesPresenter(
presenter); CategoriesPresenter presenter);
@Binds @Binds
public abstract MediaLicenseContract.UserActionListener bindsMediaLicensePresenter( public abstract MediaLicenseContract.UserActionListener bindsMediaLicensePresenter(

View file

@ -1,146 +0,0 @@
package fr.free.nrw.commons.upload.categories;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
import android.text.TextUtils;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.category.CategoryItem;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.upload.UploadItem;
import io.reactivex.Observable;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import timber.log.Timber;
/**
* The presenter class for UploadCategoriesFragment
*/
@Singleton
public class CategoriesPresenter implements CategoriesContract.UserActionListener {
private static final CategoriesContract.View DUMMY = (CategoriesContract.View) Proxy
.newProxyInstance(
CategoriesContract.View.class.getClassLoader(),
new Class[]{CategoriesContract.View.class},
(proxy, method, methodArgs) -> null);
private final Scheduler ioScheduler;
private final Scheduler mainThreadScheduler;
CategoriesContract.View view = DUMMY;
private UploadRepository repository;
private CompositeDisposable compositeDisposable;
@Inject
public CategoriesPresenter(UploadRepository repository, @Named(IO_THREAD) Scheduler ioScheduler,
@Named(MAIN_THREAD) Scheduler mainThreadScheduler) {
this.repository = repository;
this.ioScheduler = ioScheduler;
this.mainThreadScheduler = mainThreadScheduler;
compositeDisposable = new CompositeDisposable();
}
@Override
public void onAttachView(CategoriesContract.View view) {
this.view = view;
}
@Override
public void onDetachView() {
this.view = DUMMY;
compositeDisposable.clear();
}
/**
* asks the repository to fetch categories for the query
* @param query
*
*/
@Override
public void searchForCategories(String query) {
List<CategoryItem> categoryItems = new ArrayList<>();
List<String> imageTitleList = getImageTitleList();
Observable<CategoryItem> distinctCategoriesObservable = Observable
.fromIterable(repository.getSelectedCategories())
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.doOnSubscribe(disposable -> {
view.showProgress(true);
view.showError(null);
view.setCategories(null);
})
.observeOn(ioScheduler)
.concatWith(
repository.searchAll(query, imageTitleList)
)
.filter(categoryItem -> !repository.containsYear(categoryItem.getName()))
.distinct();
if(!TextUtils.isEmpty(query)) {
distinctCategoriesObservable=distinctCategoriesObservable.sorted(repository.sortBySimilarity(query));
}
Disposable searchCategoriesDisposable = distinctCategoriesObservable
.observeOn(mainThreadScheduler)
.subscribe(
s -> categoryItems.add(s),
Timber::e,
() -> {
view.setCategories(categoryItems);
view.showProgress(false);
if (categoryItems.isEmpty()) {
view.showError(R.string.no_categories_found);
}
}
);
compositeDisposable.add(searchCategoriesDisposable);
}
/**
* Returns image title list from UploadItem
* @return
*/
private List<String> getImageTitleList() {
List<String> titleList = new ArrayList<>();
for (UploadItem item : repository.getUploads()) {
final String captionText = item.getUploadMediaDetails().get(0).getCaptionText();
if (!TextUtils.isEmpty(captionText)) {
titleList.add(captionText);
}
}
return titleList;
}
/**
* Verifies the number of categories selected, prompts the user if none selected
*/
@Override
public void verifyCategories() {
List<CategoryItem> selectedCategories = repository.getSelectedCategories();
if (selectedCategories != null && !selectedCategories.isEmpty()) {
repository.setSelectedCategories(repository.getCategoryStringList());
view.goToNextScreen();
} else {
view.showNoCategorySelected();
}
}
/**
* ask repository to handle category clicked
*
* @param categoryItem
*/
@Override
public void onCategoryItemClicked(CategoryItem categoryItem) {
repository.onCategoryClicked(categoryItem);
}
}

View file

@ -0,0 +1,111 @@
package fr.free.nrw.commons.upload.categories
import android.text.TextUtils
import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CategoryItem
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.depicts.proxy
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.subjects.PublishSubject
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* The presenter class for UploadCategoriesFragment
*/
@Singleton
class CategoriesPresenter @Inject constructor(
private val repository: UploadRepository,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioScheduler: Scheduler,
@param:Named(CommonsApplicationModule.MAIN_THREAD) private val mainThreadScheduler: Scheduler
) : CategoriesContract.UserActionListener {
companion object {
private val DUMMY: CategoriesContract.View = proxy()
}
var view = DUMMY
private val compositeDisposable = CompositeDisposable()
private val searchTerms = PublishSubject.create<String>()
override fun onAttachView(view: CategoriesContract.View) {
this.view = view
compositeDisposable.add(
searchTerms
.observeOn(mainThreadScheduler)
.doOnNext {
view.showProgress(true)
view.showError(null)
view.setCategories(null)
}
.switchMap(::searchResults)
.map { repository.selectedCategories + it }
.map { it.distinctBy { categoryItem -> categoryItem.name } }
.observeOn(mainThreadScheduler)
.subscribe(
{
view.setCategories(it)
view.showProgress(false)
if (it.isEmpty()) {
view.showError(R.string.no_categories_found)
}
},
Timber::e
)
)
}
private fun searchResults(term: String) =
repository.searchAll(term, getImageTitleList())
.subscribeOn(ioScheduler)
.map { it.filterNot { categoryItem -> repository.containsYear(categoryItem.name) } }
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
}
/**
* asks the repository to fetch categories for the query
* @param query
*/
override fun searchForCategories(query: String) {
searchTerms.onNext(query)
}
/**
* Returns image title list from UploadItem
* @return
*/
private fun getImageTitleList(): List<String> {
return repository.uploads
.map { it.uploadMediaDetails[0].captionText }
.filterNot { TextUtils.isEmpty(it) }
}
/**
* Verifies the number of categories selected, prompts the user if none selected
*/
override fun verifyCategories() {
val selectedCategories = repository.selectedCategories
if (selectedCategories.isNotEmpty()) {
repository.setSelectedCategories(selectedCategories.map { it.name })
view.goToNextScreen()
} else {
view.showNoCategorySelected()
}
}
/**
* ask repository to handle category clicked
*
* @param categoryItem
*/
override fun onCategoryItemClicked(categoryItem: CategoryItem) {
repository.onCategoryClicked(categoryItem)
}
}

View file

@ -1,6 +1,7 @@
package fr.free.nrw.commons.upload.categories; package fr.free.nrw.commons.upload.categories;
import android.os.Bundle; import android.os.Bundle;
import android.text.Editable;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -50,8 +51,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate
CategoriesContract.UserActionListener presenter; CategoriesContract.UserActionListener presenter;
private RVRendererAdapter<CategoryItem> adapter; private RVRendererAdapter<CategoryItem> adapter;
private Disposable subscribe; private Disposable subscribe;
private List<CategoryItem> categories;
private boolean isVisible;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
@ -78,15 +77,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate
presenter.onAttachView(this); presenter.onAttachView(this);
initRecyclerView(); initRecyclerView();
addTextChangeListenerToEtSearch(); addTextChangeListenerToEtSearch();
//get default categories for empty query
}
@Override
public void onResume() {
super.onResume();
if (presenter != null && isVisible && (categories == null || categories.isEmpty())) {
presenter.searchForCategories(null);
}
} }
private void addTextChangeListenerToEtSearch() { private void addTextChangeListenerToEtSearch() {
@ -135,7 +125,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate
public void setCategories(List<CategoryItem> categories) { public void setCategories(List<CategoryItem> categories) {
adapter.clear(); adapter.clear();
if (categories != null) { if (categories != null) {
this.categories = categories;
adapter.addAll(categories); adapter.addAll(categories);
adapter.notifyDataSetChanged(); adapter.notifyDataSetChanged();
} }
@ -174,12 +163,11 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate
} }
@Override @Override
public void setUserVisibleHint(boolean isVisibleToUser) { protected void onBecameVisible() {
super.setUserVisibleHint(isVisibleToUser); super.onBecameVisible();
isVisible = isVisibleToUser; final Editable text = etSearch.getText();
if (text != null) {
if (presenter != null && isResumed() && (categories == null || categories.isEmpty())) { presenter.searchForCategories(text.toString());
presenter.searchForCategories(null);
} }
} }
} }

View file

@ -2,14 +2,11 @@ package fr.free.nrw.commons.category
import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.upload.GpsCategoryModel import fr.free.nrw.commons.upload.GpsCategoryModel
import io.reactivex.Observable import io.reactivex.Observable
import junit.framework.Assert.assertEquals import io.reactivex.subjects.BehaviorSubject
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock import org.mockito.Mock
import org.mockito.MockitoAnnotations import org.mockito.MockitoAnnotations
@ -31,48 +28,41 @@ class CategoriesModelTest {
// Test Case for verifying that Categories search (MW api calls) are case-insensitive // Test Case for verifying that Categories search (MW api calls) are case-insensitive
@Test @Test
fun searchAllFoundCaseTest() { fun searchAllFoundCaseTest() {
val categoriesModel = CategoriesModel(categoryClient, null, null, mock()) val categoriesModel = CategoriesModel(categoryClient, mock(), mock())
whenever(categoryClient.searchCategoriesForPrefix(anyString(), eq(25))) val expectedList = listOf("Test")
.thenReturn(Observable.just("Test")) whenever(categoryClient.searchCategoriesForPrefix("tes", 25))
.thenReturn(Observable.just(expectedList))
// Checking if both return "Test" // Checking if both return "Test"
val actualCategoryName = categoriesModel.searchAll("tes", null).blockingFirst() val expectedItems = expectedList.map { CategoryItem(it, false) }
assertEquals("Test", actualCategoryName.name) categoriesModel.searchAll("tes", emptyList())
.test()
.assertValues(expectedItems)
val actualCategoryNameCaps = categoriesModel.searchAll("Tes", null).blockingFirst() categoriesModel.searchAll("Tes", emptyList())
assertEquals("Test", actualCategoryNameCaps.name) .test()
.assertValues(expectedItems)
} }
/**
* For testing the substring search algorithm for Categories search
* To be more precise it tests the In Between substring( ex: searching `atte`
* will give search suggestions: `Latte`, `Iced latte` e.t.c) which has been described
* on github repo wiki:
* https://github.com/commons-app/apps-android-commons/wiki/Category-suggestions-(readme)#user-content-3-category-search-when-typing-in-the-search-field-has-been-made-more-flexible
*/
@Test @Test
fun searchAllFoundCaseTestForSubstringSearch() { fun `searchAll with empty search terms creates results from gps, title search & recents`() {
val gpsCategoryModel: GpsCategoryModel = mock() val gpsCategoryModel: GpsCategoryModel = mock()
val kvStore: JsonKvStore = mock()
whenever(gpsCategoryModel.categoryList).thenReturn(listOf("gpsCategory")) whenever(gpsCategoryModel.categoriesFromLocation)
.thenReturn(BehaviorSubject.createDefault(listOf("gpsCategory")))
whenever(categoryClient.searchCategories("tes", 25)) whenever(categoryClient.searchCategories("tes", 25))
.thenReturn(Observable.just("tes")) .thenReturn(Observable.just(listOf("titleSearch")))
whenever(kvStore.getString("Category", "")).thenReturn("Random Value")
whenever(categoryDao.recentCategories(25)).thenReturn(listOf("recentCategories")) whenever(categoryDao.recentCategories(25)).thenReturn(listOf("recentCategories"))
CategoriesModel( CategoriesModel(categoryClient, categoryDao, gpsCategoryModel)
categoryClient, .searchAll("", listOf("tes"))
categoryDao,
kvStore,
gpsCategoryModel
).searchAll(null, listOf("tes"))
.test() .test()
.assertValues( .assertValue(
CategoryItem("gpsCategory", false), listOf(
CategoryItem("tes", false), CategoryItem("gpsCategory", false),
CategoryItem("Random Value", false), CategoryItem("titleSearch", false),
CategoryItem("recentCategories", false) CategoryItem("recentCategories", false)
)
) )
} }
} }

View file

@ -1,20 +1,26 @@
package fr.free.nrw.commons.category package fr.free.nrw.commons.category
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import io.reactivex.Observable import io.reactivex.Observable
import junit.framework.Assert.*
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.* import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyString
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.dataclient.mwapi.MwQueryPage
import org.wikipedia.dataclient.mwapi.MwQueryResponse import org.wikipedia.dataclient.mwapi.MwQueryResponse
import org.wikipedia.dataclient.mwapi.MwQueryResult import org.wikipedia.dataclient.mwapi.MwQueryResult
class CategoryClientTest { class CategoryClientTest {
@Mock @Mock
internal var categoryInterface: CategoryInterface? = null internal lateinit var categoryInterface: CategoryInterface
@InjectMocks @InjectMocks
var categoryClient: CategoryClient? = null lateinit var categoryClient: CategoryClient
@Before @Before
@Throws(Exception::class) @Throws(Exception::class)
@ -24,132 +30,111 @@ class CategoryClientTest {
@Test @Test
fun searchCategoriesFound() { fun searchCategoriesFound() {
val mwQueryPage = Mockito.mock(MwQueryPage::class.java) val mockResponse = withMockResponse("Category:Test")
Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") whenever(categoryInterface.searchCategories(anyString(), anyInt(), anyInt()))
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) categoryClient.searchCategories("tes", 10)
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .test()
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) .assertValues(listOf("Test"))
categoryClient.searchCategories("tes", 10, 10)
Mockito.`when`(categoryInterface!!.searchCategories(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) .test()
.thenReturn(Observable.just(mockResponse)) .assertValues(listOf("Test"))
val actualCategoryName = categoryClient!!.searchCategories("tes", 10).blockingFirst()
assertEquals("Test", actualCategoryName)
val actualCategoryName2 = categoryClient!!.searchCategories("tes", 10, 10).blockingFirst()
assertEquals("Test", actualCategoryName2)
} }
@Test @Test
fun searchCategoriesNull() { fun searchCategoriesNull() {
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) val mockResponse = withNullPages()
Mockito.`when`(mwQueryResult.pages()).thenReturn(null) whenever(categoryInterface.searchCategories(anyString(), anyInt(), anyInt()))
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) categoryClient.searchCategories("tes", 10)
.test()
Mockito.`when`(categoryInterface!!.searchCategories(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) .assertValues(emptyList())
.thenReturn(Observable.just(mockResponse)) categoryClient.searchCategories("tes", 10, 10)
.test()
categoryClient!!.searchCategories("tes", 10).subscribe( .assertValues(emptyList())
{ fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
categoryClient!!.searchCategories("tes", 10, 10).subscribe(
{ fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
} }
@Test @Test
fun searchCategoriesForPrefixFound() { fun searchCategoriesForPrefixFound() {
val mwQueryPage = Mockito.mock(MwQueryPage::class.java) val mockResponse = withMockResponse("Category:Test")
Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") whenever(categoryInterface.searchCategoriesForPrefix(anyString(), anyInt(), anyInt()))
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) categoryClient.searchCategoriesForPrefix("tes", 10)
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .test()
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) .assertValues(listOf("Test"))
categoryClient.searchCategoriesForPrefix("tes", 10, 10)
Mockito.`when`(categoryInterface!!.searchCategoriesForPrefix(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) .test()
.thenReturn(Observable.just(mockResponse)) .assertValues(listOf("Test"))
val actualCategoryName = categoryClient!!.searchCategoriesForPrefix("tes", 10).blockingFirst()
assertEquals("Test", actualCategoryName)
val actualCategoryName2 = categoryClient!!.searchCategoriesForPrefix("tes", 10, 10).blockingFirst()
assertEquals("Test", actualCategoryName2)
} }
@Test @Test
fun searchCategoriesForPrefixNull() { fun searchCategoriesForPrefixNull() {
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) val mockResponse = withNullPages()
Mockito.`when`(mwQueryResult.pages()).thenReturn(null) whenever(categoryInterface.searchCategoriesForPrefix(anyString(), anyInt(), anyInt()))
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) categoryClient.searchCategoriesForPrefix("tes", 10)
.test()
Mockito.`when`(categoryInterface!!.searchCategoriesForPrefix(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) .assertValues(emptyList())
.thenReturn(Observable.just(mockResponse)) categoryClient.searchCategoriesForPrefix("tes", 10, 10)
categoryClient!!.searchCategoriesForPrefix("tes", 10).subscribe( .test()
{ fail("SearchCategories returned element when it shouldn't have.") }, .assertValues(emptyList())
{ s -> throw s })
categoryClient!!.searchCategoriesForPrefix("tes", 10, 10).subscribe(
{ fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
} }
@Test @Test
fun getParentCategoryListFound() { fun getParentCategoryListFound() {
val mwQueryPage = Mockito.mock(MwQueryPage::class.java) val mockResponse = withMockResponse("Category:Test")
Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") whenever(categoryInterface.getParentCategoryList(anyString()))
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) categoryClient.getParentCategoryList("tes")
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .test()
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) .assertValues(listOf("Test"))
Mockito.`when`(categoryInterface!!.getParentCategoryList(ArgumentMatchers.anyString()))
.thenReturn(Observable.just(mockResponse))
val actualCategoryName = categoryClient!!.getParentCategoryList("tes").blockingFirst()
assertEquals("Test", actualCategoryName)
} }
@Test @Test
fun getParentCategoryListNull() { fun getParentCategoryListNull() {
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) val mockResponse = withNullPages()
Mockito.`when`(mwQueryResult.pages()).thenReturn(null) whenever(categoryInterface.getParentCategoryList(anyString()))
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) categoryClient.getParentCategoryList("tes")
.test()
Mockito.`when`(categoryInterface!!.getParentCategoryList(ArgumentMatchers.anyString())) .assertValues(emptyList())
.thenReturn(Observable.just(mockResponse))
categoryClient!!.getParentCategoryList("tes").subscribe(
{ fail("SearchCategories returned element when it shouldn't have.") },
{ s -> throw s })
} }
@Test @Test
fun getSubCategoryListFound() { fun getSubCategoryListFound() {
val mwQueryPage = Mockito.mock(MwQueryPage::class.java) val mockResponse = withMockResponse("Category:Test")
Mockito.`when`(mwQueryPage.title()).thenReturn("Category:Test") whenever(categoryInterface.getSubCategoryList("tes"))
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage)) categoryClient.getSubCategoryList("tes")
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .test()
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) .assertValues(listOf("Test"))
Mockito.`when`(categoryInterface!!.getSubCategoryList(ArgumentMatchers.anyString()))
.thenReturn(Observable.just(mockResponse))
val actualCategoryName = categoryClient!!.getSubCategoryList("tes").blockingFirst()
assertEquals("Test", actualCategoryName)
} }
@Test @Test
fun getSubCategoryListNull() { fun getSubCategoryListNull() {
val mwQueryResult = Mockito.mock(MwQueryResult::class.java) val mockResponse = withNullPages()
Mockito.`when`(mwQueryResult.pages()).thenReturn(null) whenever(categoryInterface.getSubCategoryList(anyString()))
val mockResponse = Mockito.mock(MwQueryResponse::class.java) .thenReturn(Observable.just(mockResponse))
Mockito.`when`(mockResponse.query()).thenReturn(mwQueryResult) categoryClient.getSubCategoryList("tes")
.test()
.assertValues(emptyList())
}
Mockito.`when`(categoryInterface!!.getSubCategoryList(ArgumentMatchers.anyString())) private fun withMockResponse(title: String): MwQueryResponse? {
.thenReturn(Observable.just(mockResponse)) val mwQueryPage: MwQueryPage = mock()
categoryClient!!.getSubCategoryList("tes").subscribe( whenever(mwQueryPage.title()).thenReturn(title)
{ fail("SearchCategories returned element when it shouldn't have.") }, val mwQueryResult: MwQueryResult = mock()
{ s -> throw s }) whenever(mwQueryResult.pages()).thenReturn(listOf(mwQueryPage))
val mockResponse = mock(MwQueryResponse::class.java)
whenever(mockResponse.query()).thenReturn(mwQueryResult)
return mockResponse
}
private fun withNullPages(): MwQueryResponse? {
val mwQueryResult = mock(MwQueryResult::class.java)
whenever(mwQueryResult.pages()).thenReturn(null)
val mockResponse = mock(MwQueryResponse::class.java)
whenever(mockResponse.query()).thenReturn(mwQueryResult)
return mockResponse
} }
} }

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.upload package fr.free.nrw.commons.upload
import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.*
import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CategoryItem import fr.free.nrw.commons.category.CategoryItem
import fr.free.nrw.commons.repository.UploadRepository import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.categories.CategoriesContract import fr.free.nrw.commons.upload.categories.CategoriesContract
@ -10,7 +10,6 @@ import io.reactivex.Observable
import io.reactivex.schedulers.TestScheduler import io.reactivex.schedulers.TestScheduler
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers
import org.mockito.Mock import org.mockito.Mock
import org.mockito.MockitoAnnotations import org.mockito.MockitoAnnotations
@ -20,6 +19,7 @@ import org.mockito.MockitoAnnotations
class CategoriesPresenterTest { class CategoriesPresenterTest {
@Mock @Mock
internal lateinit var repository: UploadRepository internal lateinit var repository: UploadRepository
@Mock @Mock
internal lateinit var view: CategoriesContract.View internal lateinit var view: CategoriesContract.View
@ -49,30 +49,76 @@ class CategoriesPresenterTest {
* unit test case for method CategoriesPresenter.searchForCategories * unit test case for method CategoriesPresenter.searchForCategories
*/ */
@Test @Test
fun searchForCategoriesTest() { fun `searchForCategories combines selection and search results without years distinctly`() {
whenever(repository.sortBySimilarity(ArgumentMatchers.anyString())).thenReturn(Comparator<CategoryItem> { _, _ -> 1 }) val nonEmptyCaptionUploadItem = mock<UploadItem>()
whenever(repository.selectedCategories).thenReturn(categoryItems) whenever(nonEmptyCaptionUploadItem.uploadMediaDetails)
whenever(repository.searchAll(ArgumentMatchers.anyString(), ArgumentMatchers.anyList())).thenReturn(Observable.empty()) .thenReturn(listOf(UploadMediaDetail(captionText = "nonEmpty")))
val emptyCaptionUploadItem = mock<UploadItem>()
whenever(emptyCaptionUploadItem.uploadMediaDetails)
.thenReturn(listOf(UploadMediaDetail(captionText = "")))
whenever(repository.uploads).thenReturn(
listOf(
nonEmptyCaptionUploadItem,
emptyCaptionUploadItem
)
)
whenever(repository.searchAll("test", listOf("nonEmpty")))
.thenReturn(
Observable.just(
listOf(
categoryItem("selected"),
categoryItem("doesContainYear")
)
)
)
whenever(repository.containsYear("selected")).thenReturn(false)
whenever(repository.containsYear("doesContainYear")).thenReturn(true)
whenever(repository.selectedCategories).thenReturn(listOf(categoryItem("selected", true)))
categoriesPresenter.searchForCategories("test") categoriesPresenter.searchForCategories("test")
testScheduler.triggerActions()
verify(view).showProgress(true) verify(view).showProgress(true)
verify(view).showError(null) verify(view).showError(null)
verify(view).setCategories(null) verify(view).setCategories(null)
testScheduler.triggerActions() verify(view).setCategories(listOf(categoryItem("selected", true)))
verify(view).setCategories(categoryItems)
verify(view).showProgress(false) verify(view).showProgress(false)
verifyNoMoreInteractions(view)
}
@Test
fun `searchForCategoriesTest sets Error when list is empty`() {
whenever(repository.uploads).thenReturn(listOf())
whenever(repository.searchAll(any(), any())).thenReturn(Observable.just(listOf()))
whenever(repository.selectedCategories).thenReturn(listOf())
categoriesPresenter.searchForCategories("test")
testScheduler.triggerActions()
verify(view).showProgress(true)
verify(view).showError(null)
verify(view).setCategories(null)
verify(view).setCategories(listOf())
verify(view).showProgress(false)
verify(view).showError(R.string.no_categories_found)
verifyNoMoreInteractions(view)
} }
/** /**
* unit test for method CategoriesPresenter.verifyCategories * unit test for method CategoriesPresenter.verifyCategories
*/ */
@Test @Test
fun verifyCategoriesTest() { fun `verifyCategories with non empty selection goes to next screen`() {
whenever(repository.selectedCategories).thenReturn(categoryItems) val item = categoryItem()
whenever(repository.selectedCategories).thenReturn(listOf(item))
categoriesPresenter.verifyCategories() categoriesPresenter.verifyCategories()
verify(repository).setSelectedCategories(ArgumentMatchers.anyList()) verify(repository).setSelectedCategories(listOf(item.name))
verify(view).goToNextScreen() verify(view).goToNextScreen()
} }
@Test
fun `verifyCategories with empty selection show no category selected`() {
whenever(repository.selectedCategories).thenReturn(listOf())
categoriesPresenter.verifyCategories()
verify(view).showNoCategorySelected()
}
/** /**
* Test onCategory Item clicked * Test onCategory Item clicked
*/ */
@ -81,4 +127,7 @@ class CategoriesPresenterTest {
categoriesPresenter.onCategoryItemClicked(categoryItem) categoriesPresenter.onCategoryItemClicked(categoryItem)
verify(repository).onCategoryClicked(categoryItem) verify(repository).onCategoryClicked(categoryItem)
} }
private fun categoryItem(name: String = "name", selected: Boolean = false) =
CategoryItem(name, selected)
} }

View file

@ -1,76 +1,33 @@
package fr.free.nrw.commons.upload package fr.free.nrw.commons.upload
import org.junit.Assert.*
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
class GpsCategoryModelTest { class GpsCategoryModelTest {
lateinit var gpsCategoryModel: GpsCategoryModel
private lateinit var testObject: GpsCategoryModel
@Before @Before
fun setUp() { fun setUp() {
testObject = GpsCategoryModel() gpsCategoryModel = GpsCategoryModel()
} }
@Test @Test
fun initiallyTheModelIsEmpty() { fun `intial value is empty`() {
assertFalse(testObject.gpsCatExists) gpsCategoryModel.categoriesFromLocation.test().assertValues(emptyList())
assertTrue(testObject.categoryList.isEmpty())
} }
@Test @Test
fun addingCategoriesToTheModel() { fun `setCategoriesFromLocation emits the new value`() {
testObject.categoryList = listOf("one") val expectedList = listOf("category")
assertTrue(testObject.gpsCatExists) gpsCategoryModel.categoriesFromLocation.test()
assertFalse(testObject.categoryList.isEmpty()) .also { gpsCategoryModel.setCategoriesFromLocation(expectedList) }
assertEquals(listOf("one"), testObject.categoryList) .assertValues(emptyList(), expectedList)
} }
@Test @Test
fun duplicatesAreIgnored() { fun `clear emits an empty value`() {
testObject.categoryList = listOf("one", "one") gpsCategoryModel.categoriesFromLocation.test()
assertEquals(listOf("one"), testObject.categoryList) .also { gpsCategoryModel.clear() }
} .assertValues(emptyList(), emptyList())
@Test
fun modelProtectsAgainstExternalModification() {
testObject.categoryList = listOf("one")
val list = testObject.categoryList
list.add("two")
assertEquals(listOf("one"), testObject.categoryList)
}
@Test
fun clearingTheModel() {
testObject.categoryList = listOf("one")
testObject.clear()
assertFalse(testObject.gpsCatExists)
assertTrue(testObject.categoryList.isEmpty())
testObject.categoryList = listOf("two")
assertEquals(listOf("two"), testObject.categoryList)
}
@Test
fun settingTheListHandlesNull() {
testObject.categoryList = listOf("one")
testObject.categoryList = null
assertFalse(testObject.gpsCatExists)
assertTrue(testObject.categoryList.isEmpty())
}
@Test
fun settingTheListOverwritesExistingValues() {
testObject.categoryList = listOf("one")
testObject.categoryList = listOf("two")
assertEquals(listOf("two"), testObject.categoryList)
} }
} }