diff --git a/README.md b/README.md index 499ebf071..39dc91ce6 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,12 @@ We utilize different build variants (flavours) to build various different versio - [Retrofit](http://square.github.io/retrofit/) - Retrofit turns your REST API into a Java interface - [OkHttp](https://github.com/square/okhttp) - An HTTP+SPDY client for Android and Java applications - [Butterknife](http://jakewharton.github.io/butterknife/) - View "injection" library for Android -- [Mockito](https://github.com/mockito/mockito) - Most popular Mocking framework for unit tests written in Java -- [Guava](https://github.com/google/guava) - Collections, caching, primitives support, concurrency libraries, common annotations, string processing, I/O, and so forth. -- [Apache](https://github.com/apache/commons-io) - The Apache Commons IO library contains utility classes, stream implementations, file filters, file comparators, endian transformation classes, and much more. +- [Mockito](https://github.com/mockito/mockito) - Most popular Mocking framework for unit tests written in Java - [RxJava](https://github.com/ReactiveX/RxJava) - Reactive Extensions for the JVM – a library for composing asynchronous and event-based programs using observable sequences for the Java VM. - - +- [ObjectBox] (https://github.com/objectbox/objectbox-java) - Reactive NoSQL Databse to replace SquiDb +- [MockK] (https://github.com/mockk/mockk) - Kotlin mocking library that allows mocking of final classes by default. +- [JUnit5] (https://github.com/junit-team/junit5/) - The next generation of JUnit +- [AssertJ] (https://github.com/joel-costigliola/assertj-core) - Fluent assertions for test code ## Contributing diff --git a/app/build.gradle b/app/build.gradle index f7fbf6b5f..072207892 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -110,11 +110,9 @@ dependencies { implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" // JUnit - testImplementation 'junit:junit:4.12' androidTestImplementation 'junit:junit:4.12' // Mockito - testImplementation "org.mockito:mockito-core:2.24.5" androidTestImplementation "org.mockito:mockito-android:2.24.5" // Leak canary @@ -136,6 +134,14 @@ dependencies { implementation "android.arch.lifecycle:extensions:1.1.1" implementation "io.objectbox:objectbox-kotlin:$objectboxVersion" implementation "io.objectbox:objectbox-rxjava:$objectboxVersion" + + testImplementation "org.junit.jupiter:junit-jupiter:5.4.2" + testImplementation "io.mockk:mockk:1.9" + testImplementation "org.assertj:assertj-core:3.11.1" + //update this with androidx + testImplementation 'com.jraska.livedata:testing-ktx:0.2.1' + testImplementation 'android.arch.core:core-testing:1.1.1' + } // Set custom app import directory @@ -364,6 +370,9 @@ android { androidExtensions { experimental = true } + testOptions { + unitTests.returnDefaultValues = true + } } // Testdroid deployment configuration diff --git a/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java b/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java index 794afc49b..6c91b50ac 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java +++ b/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java @@ -25,6 +25,7 @@ import dagger.Module; import dagger.Provides; import dagger.android.AndroidInjectionModule; import javax.inject.Singleton; +import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter; import org.kiwix.kiwixmobile.utils.BookUtils; @Module(includes = { @@ -52,4 +53,9 @@ public class ApplicationModule { BookUtils provideBookUtils() { return new BookUtils(); } + + @Provides @Singleton + UriToFileConverter provideUriToFIleCOnverter() { + return new UriToFileConverter.Impl(); + } } diff --git a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt index a5076d04a..147ec3676 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt @@ -6,8 +6,8 @@ import java.io.File data class BookOnDisk( val databaseId: Long? = null, - val book: Book, - val file: File + val book: Book = Book().apply { id = "" }, + val file: File = File("") ) { constructor(bookOnDiskEntity: BookOnDiskEntity) : this( bookOnDiskEntity.id, diff --git a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt index 8fdc34594..dc0fa25ab 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt @@ -17,12 +17,11 @@ */ package org.kiwix.kiwixmobile.downloader.model -import android.view.WindowId -import org.kiwix.kiwixmobile.database.newdb.entities.DownloadEntity import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity +import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book data class DownloadModel( val databaseId: Long? = null, - val downloadId: Long, - val book: LibraryNetworkEntity.Book + val downloadId: Long = 0, + val book: LibraryNetworkEntity.Book = Book().apply { id = "" } ) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt index f2c83d8e8..9636b35a9 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt @@ -55,21 +55,22 @@ import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book import java.io.File class DownloadStatus( - val downloadId: Long, - val title: String, - val description: String, - val state: DownloadState, - val bytesDownloadedSoFar: Long, - val totalSizeBytes: Long, - val lastModified: String, - val localUri: String?, - val mediaProviderUri: String?, - val mediaType: String?, - val uri: String?, - val book: Book + val downloadId: Long = 0L, + val title: String = "", + val description: String = "", + val state: DownloadState = DownloadState.Pending, + val bytesDownloadedSoFar: Long = 0, + val totalSizeBytes: Long = 0, + val lastModified: String = "", + val localUri: String? = null, + val mediaProviderUri: String? = null, + val mediaType: String? = null, + val uri: String? = null, + val book: Book = Book().apply { id = "" } ) { - fun toBookOnDisk() = BookOnDisk(book = book, file = File(Uri.parse(localUri).path)) + fun toBookOnDisk(uriToFileConverter:UriToFileConverter) = + BookOnDisk(book = book, file = uriToFileConverter.convert(localUri)) constructor( cursor: Cursor, @@ -90,6 +91,11 @@ class DownloadStatus( ) } +interface UriToFileConverter { + fun convert(uriString: String?) = File(Uri.parse(uriString).path) + class Impl:UriToFileConverter{} +} + sealed class DownloadState(val stringId: Int) { companion object { fun from( diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt index ff8c9ab5a..a24280c91 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt @@ -3,8 +3,8 @@ package org.kiwix.kiwixmobile.zim_manager import android.content.Context import android.content.Intent import android.net.ConnectivityManager +import io.reactivex.Flowable import io.reactivex.processors.BehaviorProcessor -import io.reactivex.processors.PublishProcessor import org.kiwix.kiwixmobile.extensions.networkState import javax.inject.Inject @@ -13,14 +13,14 @@ class ConnectivityBroadcastReceiver @Inject constructor(private val connectivity override val action: String = ConnectivityManager.CONNECTIVITY_ACTION - val networkStates = - BehaviorProcessor.createDefault(connectivityManager.networkState) + private val _networkStates = BehaviorProcessor.createDefault(connectivityManager.networkState) + val networkStates: Flowable = _networkStates override fun onIntentWithActionReceived( context: Context, intent: Intent ) { - networkStates.onNext(connectivityManager.networkState) + _networkStates.onNext(connectivityManager.networkState) } } \ No newline at end of file diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/DefaultLanguageProvider.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/DefaultLanguageProvider.kt new file mode 100644 index 000000000..cc7cd23ae --- /dev/null +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/DefaultLanguageProvider.kt @@ -0,0 +1,13 @@ +package org.kiwix.kiwixmobile.zim_manager + +import android.content.Context +import org.kiwix.kiwixmobile.zim_manager.library_view.adapter.Language +import javax.inject.Inject + +class DefaultLanguageProvider @Inject constructor(private val context: Context) { + fun provide() = Language( + context.resources.configuration.locale.isO3Language, + true, + 1 + ) +} diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt index 22997ee78..1b300beef 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt @@ -21,6 +21,7 @@ package org.kiwix.kiwixmobile.zim_manager import android.app.Application import android.arch.lifecycle.MutableLiveData import android.arch.lifecycle.ViewModel +import android.support.annotation.VisibleForTesting import io.reactivex.Flowable import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable @@ -39,6 +40,7 @@ import org.kiwix.kiwixmobile.downloader.model.DownloadItem import org.kiwix.kiwixmobile.downloader.model.DownloadModel import org.kiwix.kiwixmobile.downloader.model.DownloadState.Successful import org.kiwix.kiwixmobile.downloader.model.DownloadStatus +import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter import org.kiwix.kiwixmobile.extensions.calculateSearchMatches import org.kiwix.kiwixmobile.extensions.registerReceiver import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity @@ -71,7 +73,9 @@ class ZimManageViewModel @Inject constructor( private val context: Application, private val connectivityBroadcastReceiver: ConnectivityBroadcastReceiver, private val bookUtils: BookUtils, - private val fat32Checker: Fat32Checker + private val fat32Checker: Fat32Checker, + private val uriToFileConverter: UriToFileConverter, + private val defaultLanguageProvider: DefaultLanguageProvider ) : ViewModel() { val libraryItems: MutableLiveData> = MutableLiveData() @@ -94,6 +98,11 @@ class ZimManageViewModel @Inject constructor( context.registerReceiver(connectivityBroadcastReceiver) } + @VisibleForTesting + fun onClearedExposed() { + onCleared() + } + override fun onCleared() { compositeDisposable.clear() context.unregisterReceiver(connectivityBroadcastReceiver) @@ -105,16 +114,17 @@ class ZimManageViewModel @Inject constructor( val downloadStatuses = downloadStatuses(downloads) val booksFromDao = books() val networkLibrary = PublishProcessor.create() + val languages = languageDao.languages() return arrayOf( updateDownloadItems(downloadStatuses), removeCompletedDownloadsFromDb(downloadStatuses), removeNonExistingDownloadsFromDb(downloadStatuses, downloads), updateBookItems(booksFromDao), checkFileSystemForBooksOnRequest(booksFromDao), - updateLibraryItems(booksFromDao, downloads, networkLibrary), - updateLanguagesInDao(networkLibrary), + updateLibraryItems(booksFromDao, downloads, networkLibrary, languages), + updateLanguagesInDao(networkLibrary, languages), updateNetworkStates(), - updateLanguageItemsForDialog(), + updateLanguageItemsForDialog(languages), requestsAndConnectivtyChangesToLibraryRequests(networkLibrary) ) } @@ -155,6 +165,7 @@ class ZimManageViewModel @Inject constructor( ) .buffer(3, SECONDS) .map(this::downloadIdsWithNoStatusesOverBufferPeriod) + .filter { it.isNotEmpty() } .subscribe( { downloadDao.delete(*it.toLongArray()) @@ -184,13 +195,15 @@ class ZimManageViewModel @Inject constructor( ) } - private fun updateLanguageItemsForDialog() = requestLanguagesDialog - .withLatestFrom(languageDao.languages(), - BiFunction, List> { _, languages -> languages }) - .subscribe( - languageItems::postValue, - Throwable::printStackTrace - ) + private fun updateLanguageItemsForDialog(languages: Flowable>) = + requestLanguagesDialog + .withLatestFrom( + languages, + BiFunction, List> { _, languages -> languages }) + .subscribe( + languageItems::postValue, + Throwable::printStackTrace + ) private fun updateNetworkStates() = connectivityBroadcastReceiver.networkStates.subscribe( @@ -200,11 +213,12 @@ class ZimManageViewModel @Inject constructor( private fun updateLibraryItems( booksFromDao: Flowable>, downloads: Flowable>, - library: Flowable + library: Flowable, + languages: Flowable> ) = Flowable.combineLatest( booksFromDao, downloads, - languageDao.languages().filter { it.isNotEmpty() }, + languages.filter { it.isNotEmpty() }, library, requestFiltering .doOnNext { libraryListIsRefreshing.postValue(true) } @@ -221,15 +235,17 @@ class ZimManageViewModel @Inject constructor( ) private fun updateLanguagesInDao( - library: Flowable + library: Flowable, + languages: Flowable> ) = library .subscribeOn(Schedulers.io()) .map { it.books } .withLatestFrom( - languageDao.languages(), + languages, BiFunction(this::combineToLanguageList) ) .map { it.sortedBy(Language::language) } + .filter { it.isNotEmpty() } .subscribe( languageDao::insert, Throwable::printStackTrace @@ -278,11 +294,7 @@ class ZimManageViewModel @Inject constructor( private fun defaultLanguage() = listOf( - Language( - context.resources.configuration.locale.isO3Language, - true, - 1 - ) + defaultLanguageProvider.provide() ) private fun languageIsActive( @@ -411,7 +423,8 @@ class ZimManageViewModel @Inject constructor( .filter { it.isNotEmpty() } .subscribe( { - bookDao.insert(it.map { downloadStatus -> downloadStatus.toBookOnDisk() }) + bookDao.insert( + it.map { downloadStatus -> downloadStatus.toBookOnDisk(uriToFileConverter) }) downloadDao.delete( *it.map { status -> status.downloadId }.toLongArray() ) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/fileselect_view/StorageObserver.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/fileselect_view/StorageObserver.kt index 9fd69e5c8..2adf0e09c 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/fileselect_view/StorageObserver.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/fileselect_view/StorageObserver.kt @@ -18,7 +18,7 @@ class StorageObserver @Inject constructor( private val downloadDao: NewDownloadDao ) { - private val _booksOnFileSystem = PublishProcessor.create>() + private val _booksOnFileSystem = PublishProcessor.create>() val booksOnFileSystem = _booksOnFileSystem.distinctUntilChanged() .doOnSubscribe { downloadDao.downloads() @@ -37,7 +37,7 @@ class StorageObserver @Inject constructor( } override fun onScanCompleted() { - _booksOnFileSystem.onNext(foundBooks) + _booksOnFileSystem.onNext(foundBooks.toList()) } }).scan(sharedPreferenceUtil.prefStorage) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/library_view/adapter/Language.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/library_view/adapter/Language.kt index b48a28ec5..9191daca0 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/library_view/adapter/Language.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/library_view/adapter/Language.kt @@ -2,22 +2,32 @@ package org.kiwix.kiwixmobile.zim_manager.library_view.adapter import java.util.Locale -class Language constructor( - locale: Locale, +data class Language constructor( var active: Boolean, var occurencesOfLanguage: Int, - var language: String = locale.displayLanguage, - var languageLocalized: String = locale.getDisplayLanguage(locale), - var languageCode: String = locale.isO3Language, - var languageCodeISO2: String = locale.language + var language: String, + var languageLocalized: String, + var languageCode: String, + var languageCodeISO2: String ) { + constructor( + locale: Locale, + active: Boolean, + occurrencesOfLanguage: Int + ) : this( + active, + occurrencesOfLanguage, + locale.displayLanguage, + locale.getDisplayLanguage(locale), + locale.isO3Language, + locale.language + ) constructor( languageCode: String, active: Boolean, occurrencesOfLanguage: Int - ) : this(Locale(languageCode), active, occurrencesOfLanguage) { - } + ) : this(Locale(languageCode), active, occurrencesOfLanguage) override fun equals(other: Any?): Boolean { return (other as Language).language == language && other.active == active diff --git a/app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt b/app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt new file mode 100644 index 000000000..cfd015dc4 --- /dev/null +++ b/app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt @@ -0,0 +1,44 @@ +/* + * Kiwix Android + * Copyright (C) 2018 Kiwix + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package org.kiwix.kiwixmobile + +import android.arch.core.executor.ArchTaskExecutor +import android.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance() + .setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } + +} \ No newline at end of file diff --git a/app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt b/app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt new file mode 100644 index 000000000..fe1a80939 --- /dev/null +++ b/app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt @@ -0,0 +1,503 @@ +/* + * Kiwix Android + * Copyright (C) 2018 Kiwix + * + * 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. + * + * This program 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 this program. If not, see . + */ + +package org.kiwix.kiwixmobile.zim_manager + +import android.app.Application +import com.jraska.livedata.test +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.reactivex.Scheduler +import io.reactivex.Single +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.processors.PublishProcessor +import io.reactivex.schedulers.Schedulers +import io.reactivex.schedulers.TestScheduler +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.kiwix.kiwixmobile.InstantExecutorExtension +import org.kiwix.kiwixmobile.R +import org.kiwix.kiwixmobile.database.newdb.dao.NewBookDao +import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao +import org.kiwix.kiwixmobile.database.newdb.dao.NewLanguagesDao +import org.kiwix.kiwixmobile.downloader.Downloader +import org.kiwix.kiwixmobile.downloader.model.BookOnDisk +import org.kiwix.kiwixmobile.downloader.model.DownloadItem +import org.kiwix.kiwixmobile.downloader.model.DownloadModel +import org.kiwix.kiwixmobile.downloader.model.DownloadState +import org.kiwix.kiwixmobile.downloader.model.DownloadStatus +import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter +import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity +import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book +import org.kiwix.kiwixmobile.network.KiwixService +import org.kiwix.kiwixmobile.utils.BookUtils +import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState +import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.CanWrite4GbFile +import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState.CannotWrite4GbFile +import org.kiwix.kiwixmobile.zim_manager.NetworkState.CONNECTED +import org.kiwix.kiwixmobile.zim_manager.NetworkState.NOT_CONNECTED +import org.kiwix.kiwixmobile.zim_manager.fileselect_view.StorageObserver +import org.kiwix.kiwixmobile.zim_manager.library_view.adapter.Language +import org.kiwix.kiwixmobile.zim_manager.library_view.adapter.LibraryListItem +import java.io.File +import java.util.LinkedList +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS + +@ExtendWith(InstantExecutorExtension::class) +class ZimManageViewModelTest { + + private val newDownloadDao: NewDownloadDao = mockk() + private val newBookDao: NewBookDao = mockk() + private val newLanguagesDao: NewLanguagesDao = mockk() + private val downloader: Downloader = mockk() + private val storageObserver: StorageObserver = mockk() + private val kiwixService: KiwixService = mockk() + private val application: Application = mockk() + private val connectivityBroadcastReceiver: ConnectivityBroadcastReceiver = mockk() + private val bookUtils: BookUtils = mockk() + private val fat32Checker: Fat32Checker = mockk() + private val uriToFileConverter: UriToFileConverter = mockk() + lateinit var viewModel: ZimManageViewModel + + private val downloads: PublishProcessor> = PublishProcessor.create() + private val booksOnFileSystem: PublishProcessor> = PublishProcessor.create() + private val books: PublishProcessor> = PublishProcessor.create() + private val languages: PublishProcessor> = PublishProcessor.create() + private val fileSystemStates: PublishProcessor = PublishProcessor.create() + private val networkStates: PublishProcessor = PublishProcessor.create() + + private val testScheduler = TestScheduler() + + init { + setScheduler(testScheduler) + } + + private fun setScheduler(replacementScheduler: Scheduler) { + RxJavaPlugins.setIoSchedulerHandler { scheduler -> replacementScheduler } + RxJavaPlugins.setComputationSchedulerHandler { scheduler -> replacementScheduler } + RxJavaPlugins.setNewThreadSchedulerHandler { scheduler -> replacementScheduler } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { scheduler -> Schedulers.trampoline() } + } + + @AfterAll + fun teardown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset(); + } + + private val defaultLanguageProvider: DefaultLanguageProvider = mockk() + + @BeforeEach + fun init() { + clearMocks( + newDownloadDao, newBookDao, newLanguagesDao, downloader, + storageObserver, kiwixService, application, connectivityBroadcastReceiver, bookUtils, + fat32Checker, uriToFileConverter, defaultLanguageProvider + ) + every { connectivityBroadcastReceiver.action } returns "test" + every { newDownloadDao.downloads() } returns downloads + every { newBookDao.books() } returns books + every { storageObserver.booksOnFileSystem } returns booksOnFileSystem + every { newLanguagesDao.languages() } returns languages + every { fat32Checker.fileSystemStates } returns fileSystemStates + every { connectivityBroadcastReceiver.networkStates } returns networkStates + every { application.registerReceiver(any(), any()) } returns mockk() + viewModel = ZimManageViewModel( + newDownloadDao, newBookDao, newLanguagesDao, downloader, + storageObserver, kiwixService, application, connectivityBroadcastReceiver, bookUtils, + fat32Checker, uriToFileConverter, defaultLanguageProvider + ) + testScheduler.triggerActions() + } + + @Nested + inner class Context { + @Test + fun `registers broadcastReceiver in init`() { + verify { + application.registerReceiver(connectivityBroadcastReceiver, any()) + } + } + + @Test + fun `unregisters broadcastReceiver in onCleared`() { + every { application.unregisterReceiver(any()) } returns mockk() + viewModel.onClearedExposed() + verify { + application.unregisterReceiver(connectivityBroadcastReceiver) + } + } + } + + @Nested + inner class Downloads { + @Test + fun `on emission from database query and render downloads`() { + val expectedStatus = DownloadStatus() + expectStatusWith(listOf(expectedStatus)) + viewModel.downloadItems + .test() + .assertValue(listOf(DownloadItem(expectedStatus))) + .dispose() + } + + @Test + fun `on emission of successful status create a book and delete the download`() { + every { uriToFileConverter.convert(any()) } returns File("test") + val expectedStatus = DownloadStatus( + downloadId = 10L, + state = DownloadState.Successful + ) + expectStatusWith(listOf(expectedStatus)) + val element = expectedStatus.toBookOnDisk(uriToFileConverter) + verify { + newBookDao.insert(listOf(element)) + newDownloadDao.delete(10L) + } + } + + @Test + fun `if statuses don't have a matching Id for download in db over 3 secs then delete`() { + expectStatusWith( + listOf(DownloadStatus(downloadId = 1)), + listOf(DownloadModel(downloadId = 1), DownloadModel(downloadId = 3)) + ) + testScheduler.advanceTimeBy(3, SECONDS) + testScheduler.triggerActions() + verify { + newDownloadDao.delete(3) + } + } + + @Test + fun `if statuses do have a matching Id for download in db over 3 secs then don't delete`() { + expectStatusWith( + listOf(DownloadStatus(downloadId = 1)), + listOf(DownloadModel(downloadId = 1)) + ) + testScheduler.advanceTimeBy(3, SECONDS) + testScheduler.triggerActions() + verify(exactly = 0) { + newDownloadDao.delete(any()) + } + } + + private fun expectStatusWith( + expectedStatuses: List, + expectedDownloads: List = listOf( + DownloadModel() + ) + ) { + val downloadList = expectedDownloads + every { downloader.queryStatus(downloadList) } returns expectedStatuses + downloads.offer(downloadList) + testScheduler.triggerActions() + testScheduler.advanceTimeBy(1, SECONDS) + testScheduler.triggerActions() + } + } + + @Nested + inner class Books { + @Test + fun `emissions from DB sorted by title and observed`() { + books.onNext( + listOf( + BookOnDisk().apply { book.title = "z" }, + BookOnDisk().apply { book.title = "a" } + ) + ) + testScheduler.triggerActions() + viewModel.bookItems.test() + .assertValue( + listOf( + BookOnDisk().apply { book.title = "a" }, + BookOnDisk().apply { book.title = "z" } + ) + ) + .dispose() + } + + @Test +// @Disabled("WithLatestFrom is not calling BiFunction removeBooksAlreadyInDao - revisit") + fun `books found on filesystem are filtered by books already in db`() { + val expectedBook = BookOnDisk().apply { book.id = "1" } + val bookToRemove = BookOnDisk().apply { book.id = "2" } + testScheduler.triggerActions() + viewModel.requestFileSystemCheck.onNext(Unit) + testScheduler.triggerActions() + books.onNext(listOf(bookToRemove)) + testScheduler.triggerActions() + booksOnFileSystem.onNext( + listOf( + expectedBook, + expectedBook, + bookToRemove + ) + ) + testScheduler.triggerActions() + verify { + newBookDao.insert(listOf(expectedBook)) + } + } + } + + @Nested + inner class Lanuages { + + @Test + fun `a network request with no result and an empty language db triggers an activation of the default locale`() { + val expectedLanguage = Language( + active = true, + occurencesOfLanguage = 1, + language = "eng", + languageLocalized = "englocal", + languageCode = "ENG", + languageCodeISO2 = "en" + ) + expectNetworkDbAndDefault( + listOf(), + listOf(), + expectedLanguage + ) + verify { newLanguagesDao.insert(listOf(expectedLanguage)) } + } + + @Test + fun `a network request with no result and a non empty language db does not trigger anything`() { + expectNetworkDbAndDefault( + listOf(), + listOf( + Language( + active = true, + occurencesOfLanguage = 1, + language = "eng", + languageLocalized = "englocal", + languageCode = "ENG", + languageCodeISO2 = "en" + ) + ), + Language(true, 1, "", "", "", "") + ) + verify(exactly = 0) { newLanguagesDao.insert(any()) } + } + + @Test + fun `a network request with a result and an empty language db triggers an activation of the default locale with the result of the web request`() { + val defaultLanguage = Language( + active = true, + occurencesOfLanguage = 1, + language = "English", + languageLocalized = "English", + languageCode = "eng", + languageCodeISO2 = "eng" + ) + expectNetworkDbAndDefault( + listOf( + Book().apply { language = "eng" }, + Book().apply { language = "eng" }, + Book().apply { language = "fra" } + ), + listOf(), + defaultLanguage) + verify { + newLanguagesDao.insert( + listOf( + defaultLanguage.copy(occurencesOfLanguage = 2), + Language( + active = false, + occurencesOfLanguage = 1, + language = "fra", + languageLocalized = "", + languageCode = "", + languageCodeISO2 = "" + ) + ) + ) + } + } + + @Test + fun `a network request with a result and a non empty language db triggers an activation of the result of the web request with the db`() { + val dbLanguage = Language( + active = true, + occurencesOfLanguage = 1, + language = "English", + languageLocalized = "English", + languageCode = "eng", + languageCodeISO2 = "eng" + ) + expectNetworkDbAndDefault( + listOf( + Book().apply { language = "eng" }, + Book().apply { language = "eng" }, + Book().apply { language = "fra" } + ), + listOf(dbLanguage), + Language(true, 1, "", "", "", "") + ) + verify { + newLanguagesDao.insert( + listOf( + dbLanguage.copy(occurencesOfLanguage = 2), + Language( + active = false, + occurencesOfLanguage = 1, + language = "fra", + languageLocalized = "", + languageCode = "", + languageCodeISO2 = "" + ) + ) + ) + } + } + + private fun expectNetworkDbAndDefault( + networkBooks: List, + dbBooks: List, + defaultLanguage: Language + ) { + every { kiwixService.library } returns Single.just( + LibraryNetworkEntity().apply { + book = LinkedList(networkBooks) + }) + val defaultLanguage = defaultLanguage + every { defaultLanguageProvider.provide() } returns defaultLanguage + languages.onNext(dbBooks) + testScheduler.triggerActions() + networkStates.onNext(CONNECTED) + testScheduler.triggerActions() + } + } + + @Test + fun `network states observed`() { + networkStates.offer(NOT_CONNECTED) + viewModel.networkStates.test() + .assertValue(NOT_CONNECTED) + .dispose() + } + + @Test + fun `language items for dialog observed`() { + val expectedValue = listOf(Language(true, 1, "e", "e", "e", "e")) + testScheduler.triggerActions() + languages.onNext(expectedValue) + testScheduler.triggerActions() + viewModel.requestLanguagesDialog.onNext(Unit) + testScheduler.triggerActions() + viewModel.languageItems.test() + .assertValue(expectedValue) + .dispose() + } + + @Test + fun `library update removes from sources`() { + every { application.getString(R.string.your_languages) } returns "1" + every { application.getString(R.string.other_languages) } returns "2" + val bookAlreadyOnDisk = Book().apply { + id = "0" + url = "" + } + val bookDownloading = Book().apply { + id = "1" + url = "" + } + val bookWithStackExchange = Book().apply { + id = "2" + url = "blahblah/stack_exchange/" + } + val bookWithActiveLanguage = Book().apply { + id = "3" + language = "activeLanguage" + url = "" + } + val bookWithInactiveLanguage = Book().apply { + id = "3" + language = "inactiveLanguage" + url = "" + } + every { kiwixService.library } returns Single.just( + LibraryNetworkEntity().apply { + book = LinkedList( + listOf( + bookAlreadyOnDisk, + bookDownloading, + bookWithStackExchange, + bookWithActiveLanguage, + bookWithInactiveLanguage + ) + ) + } + ) + networkStates.onNext(CONNECTED) + downloads.onNext(listOf(DownloadModel(book = bookDownloading))) + books.onNext(listOf(BookOnDisk(book = bookAlreadyOnDisk))) + languages.onNext( + listOf( + Language(true, 1, "", "", "activeLanguage", ""), + Language(false, 1, "", "", "inactiveLanguage", "") + ) + ) + fileSystemStates.onNext(CanWrite4GbFile) + testScheduler.advanceTimeBy(500, MILLISECONDS) + testScheduler.triggerActions() + viewModel.libraryItems.test() + .assertValue( + listOf( + LibraryListItem.DividerItem(Long.MAX_VALUE, "1"), + LibraryListItem.BookItem(bookWithActiveLanguage), + LibraryListItem.DividerItem(Long.MIN_VALUE, "2"), + LibraryListItem.BookItem(bookWithInactiveLanguage) + ) + ) + .dispose() + } + + @Test + fun `library filters out files over 4GB if file system state says to`() { + val bookOver4Gb = Book().apply { + id = "0" + url = "" + size = "${Fat32Checker.FOUR_GIGABYTES_IN_KILOBYTES + 1}" + } + every { kiwixService.library } returns Single.just( + LibraryNetworkEntity().apply { + book = LinkedList(listOf(bookOver4Gb)) + } + ) + networkStates.onNext(CONNECTED) + downloads.onNext(listOf()) + books.onNext(listOf()) + languages.onNext(listOf(Language(true, 1, "", "", "activeLanguage", ""))) + fileSystemStates.onNext(CannotWrite4GbFile) + testScheduler.advanceTimeBy(500, MILLISECONDS) + testScheduler.triggerActions() + viewModel.libraryItems.test() + .assertValue(listOf()) + .dispose() + } +} \ No newline at end of file diff --git a/app/src/test/resources/io/mockk/settings.properties b/app/src/test/resources/io/mockk/settings.properties new file mode 100644 index 000000000..1530bfedc --- /dev/null +++ b/app/src/test/resources/io/mockk/settings.properties @@ -0,0 +1 @@ +relaxUnitFun=true \ No newline at end of file diff --git a/app/src/test/resources/junit-platform.properties b/app/src/test/resources/junit-platform.properties new file mode 100644 index 000000000..e6d55f8bd --- /dev/null +++ b/app/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testinstance.lifecycle.default = per_class \ No newline at end of file