diff --git a/anilist/build.gradle b/anilist/build.gradle index ede4488..360854a 100644 --- a/anilist/build.gradle +++ b/anilist/build.gradle @@ -42,7 +42,6 @@ android { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.5.0' implementation 'androidx.appcompat:appcompat:1.3.0' diff --git a/anilist/src/main/java/com/huchihaitachi/anilist/di/moule/AnilistModule.kt b/anilist/src/main/java/com/huchihaitachi/anilist/di/moule/AnilistModule.kt index 517b887..8b2abf0 100644 --- a/anilist/src/main/java/com/huchihaitachi/anilist/di/moule/AnilistModule.kt +++ b/anilist/src/main/java/com/huchihaitachi/anilist/di/moule/AnilistModule.kt @@ -2,7 +2,7 @@ package com.huchihaitachi.anilist.di.moule import com.huchihaitachi.anilist.di.scope.AnilistScope import com.huchihaitachi.anilist.presentation.AnilistViewState -import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.NOT_LOADING +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.VIEW_CONTENT import com.huchihaitachi.anilist.presentation.AnilistViewState.PageState import dagger.Module import dagger.Provides @@ -14,7 +14,7 @@ interface AnilistModule { @AnilistScope @Provides fun provideAnilistState(): AnilistViewState = AnilistViewState( - NOT_LOADING, + VIEW_CONTENT, null, PageState(currentPage = 0, hasNextPage = true), null diff --git a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistController.kt b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistController.kt index 1559b7a..f127bf7 100644 --- a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistController.kt +++ b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistController.kt @@ -2,9 +2,7 @@ package com.huchihaitachi.anilist.presentation import android.os.Bundle import android.view.LayoutInflater -import android.view.MotionEvent import android.view.View -import android.view.View.OnTouchListener import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -17,7 +15,7 @@ import com.huchihaitachi.anilist.R import com.huchihaitachi.anilist.databinding.ControllerAnilistBinding import com.huchihaitachi.anilist.di.AnilistSubcomponentProvider import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.PAGE -import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.RELOAD +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.REFRESH import com.huchihaitachi.anilist.presentation.animeList.AnimeEpoxyController import com.huchihaitachi.base.domain.localized import com.huchihaitachi.base.domain.stringRes @@ -36,11 +34,11 @@ class AnilistController : Controller(), AnilistView { private val binding: ControllerAnilistBinding get() = _binding!! private val _loadAnimePage: PublishSubject = PublishSubject.create() - override val loadAnimePage: Observable + override val loadPage: Observable get() = _loadAnimePage - private val _reload: PublishSubject = PublishSubject.create() - override val reload: Observable - get() = _reload + private val _refresh: PublishSubject = PublishSubject.create() + override val refresh: Observable + get() = _refresh private val _showDetails: PublishSubject = PublishSubject.create() override val showDetails: Observable get() = _showDetails @@ -55,7 +53,7 @@ class AnilistController : Controller(), AnilistView { binding.animeListL.pageLoadingPb.visible = state.loading == PAGE binding.animeListL.totalFooterTv.visible = state.loading != PAGE binding.animeListL.totalFooterTv.text = resources?.getString(R.string.total, state.pageState?.anime?.size) - binding.animeListL.animeSrl.isRefreshing = state.loading == RELOAD + binding.animeListL.animeSrl.isRefreshing = state.loading == REFRESH binding.animeListL.errorFooterTv.apply { visible = state.error != null state.error?.let { text = it } @@ -102,7 +100,7 @@ class AnilistController : Controller(), AnilistView { private fun setupSwipeToRefresh() { binding.animeListL.animeSrl.setOnRefreshListener { - _reload.onNext(Unit) + _refresh.onNext(Unit) } } diff --git a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPartialState.kt b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPartialState.kt index f465184..c138668 100644 --- a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPartialState.kt +++ b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPartialState.kt @@ -1,12 +1,12 @@ package com.huchihaitachi.anilist.presentation import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType -import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.NOT_LOADING +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.VIEW_CONTENT import com.huchihaitachi.anilist.presentation.AnilistViewState.PageState import com.huchihaitachi.domain.Anime data class AnilistPartialState ( - val loading: LoadingType = NOT_LOADING, + val loading: LoadingType = VIEW_CONTENT, val details: Anime? = null, val pageState: PageState? = null, val error: String? = null, diff --git a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPresenter.kt b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPresenter.kt index ca65f03..364b42e 100644 --- a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPresenter.kt +++ b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistPresenter.kt @@ -3,9 +3,9 @@ package com.huchihaitachi.anilist.presentation import com.apollographql.apollo.exception.ApolloNetworkException import com.huchihaitachi.anilist.R import com.huchihaitachi.anilist.di.scope.AnilistScope -import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.NOT_LOADING +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.VIEW_CONTENT import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.PAGE -import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.RELOAD +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.REFRESH import com.huchihaitachi.anilist.presentation.AnilistViewState.PageState import com.huchihaitachi.base.BasePresenter import com.huchihaitachi.base.RxSchedulers @@ -26,26 +26,26 @@ class AnilistPresenter @Inject constructor( private val refreshPageUseCase: RefreshPageUseCase, private val loadAnimeUseCase: LoadAnimeUseCase, private val getStringResourceUseCase: GetStringResourceUseCase, - anilistViewState: AnilistViewState, - rxSchedulers: RxSchedulers, -) : BasePresenter(anilistViewState, rxSchedulers) { + initialViewState: AnilistViewState, + rxSchedulers: RxSchedulers +) : BasePresenter(initialViewState, rxSchedulers) { override fun bindIntents() { view?.let { view -> // load page - val loadPageIntent = view.loadAnimePage + val loadPageIntent = view.loadPage .observeOn(rxSchedulers.io) .filter { _ -> state.pageState?.hasNextPage == true - && state.loading != RELOAD + && state.loading != REFRESH && state.loading != PAGE && state.loadingEnabled } .flatMap { _ -> loadPage(state.pageState?.currentPage!! + 1) } - //reload - val reloadIntent = view.reload + //refresh + val refreshIntent = view.refresh .observeOn(rxSchedulers.io) - .filter { _ -> state.loading != RELOAD } + .filter { _ -> state.loading != REFRESH } .flatMap { unit -> refreshPage() } //details val detailsIntent = view.showDetails @@ -53,13 +53,9 @@ class AnilistPresenter @Inject constructor( .switchMap { id -> loadAnimeUseCase(id) .toObservable() - .map { details -> - AnilistPartialState( - details = details - ) - } + .map { details -> state.copy(details = details) } .onErrorReturn { throwable -> - AnilistPartialState( + state.copy( error = when (throwable) { is ApolloNetworkException -> getStringResourceUseCase(R.string.no_connection) else -> throwable.message @@ -67,32 +63,38 @@ class AnilistPresenter @Inject constructor( ) } } + //hide details val hideDetailsIntent = view.hideDetails - .filter { state.loading == NOT_LOADING } - .map { - AnilistPartialState(error = state.error) - } - val intents = Observable.merge(loadPageIntent, reloadIntent, detailsIntent, hideDetailsIntent) - intents.scan(state, ::animeStateReducer) - .subscribe { s -> - state = s - } + .filter { state.loading == VIEW_CONTENT } + .map { state.copy(details = null) } + Observable.merge(loadPageIntent, refreshIntent, detailsIntent, hideDetailsIntent) + .subscribe { s -> state = s } .let(disposables::add) } } - private fun loadPage(pageNum: Int): Observable = + private fun loadPage(pageNum: Int): Observable = loadPageUseCase(pageNum, PER_PAGE) .toObservable() .map { page -> - AnilistPartialState( - pageState = PageState(page.anime, page.currentPage, page.hasNextPage) + state.copy( + loading = VIEW_CONTENT, + pageState = PageState( + mutableListOf().apply { + state.pageState?.anime?.let(::addAll) + page.anime?.let(::addAll) + }, + page.currentPage, + page.hasNextPage + ), + loadingEnabled = page.hasNextPage ?: true, + backoff = 0 ) } - .startWith(AnilistPartialState(loading = PAGE)) + .startWith(state.copy(loading = PAGE)) .onErrorResumeNext(::loadPageErrorHandler) - private fun loadPageErrorHandler(throwable: Throwable): ObservableSource = + private fun loadPageErrorHandler(throwable: Throwable): ObservableSource = when (throwable) { is ApolloNetworkException -> Observable.timer( @@ -101,30 +103,42 @@ class AnilistPresenter @Inject constructor( } else { MAX_BACKOFF }, - TimeUnit.MILLISECONDS + TimeUnit.MILLISECONDS, + rxSchedulers.computation ) - .map { AnilistPartialState() } + .map { state.copy( + error = null, + loadingEnabled = true + ) } .startWith( - AnilistPartialState( + state.copy( + loading = VIEW_CONTENT, error = getStringResourceUseCase(R.string.no_connection), loadingEnabled = false, backoff = state.backoff + 1 ) ) - else -> Observable.just(AnilistPartialState(error = throwable.message)) + else -> Observable.just(state.copy( + loading = VIEW_CONTENT, + error = throwable.message) + ) } - private fun refreshPage(): Observable = + private fun refreshPage(): Observable = refreshPageUseCase(PER_PAGE) .toObservable() .map { page -> - AnilistPartialState( - pageState = PageState(page.anime, page.currentPage, page.hasNextPage) + state.copy( + loading = VIEW_CONTENT, + pageState = PageState(page.anime, page.currentPage, page.hasNextPage), + loadingEnabled = page.hasNextPage ?: true, + backoff = 0 ) } - .startWith(AnilistPartialState(loading = RELOAD)) + .startWith(state.copy(loading = REFRESH)) .onErrorReturn { throwable -> - AnilistPartialState( + state.copy( + loading = VIEW_CONTENT, error = when(throwable) { is ApolloNetworkException -> getStringResourceUseCase(R.string.no_connection) else -> throwable.message @@ -132,51 +146,6 @@ class AnilistPresenter @Inject constructor( ) } - private fun animeStateReducer(previousState: AnilistViewState, changes: AnilistPartialState) = - if (changes.error == null) { - when (previousState.loading) { - PAGE -> previousState.copy( - changes.loading, - changes.details, - changes.pageState?.copy( - mutableListOf().apply { - previousState.pageState?.anime?.let(::addAll) - changes.pageState.anime?.let(::addAll) - } - ), - changes.error, - changes.loadingEnabled, - changes.backoff - ) - RELOAD -> - previousState.copy( - changes.loading, - changes.details, - changes.pageState?.copy(), - changes.error, - changes.loadingEnabled, - changes.backoff - ) - NOT_LOADING -> previousState.copy( - changes.loading, - changes.details, - previousState.pageState?.copy(), - changes.error, - changes.loadingEnabled, - previousState.backoff - ) - } - } else { - previousState.copy( - changes.loading, - changes.details, - previousState.pageState?.copy(), - changes.error, - changes.loadingEnabled, - changes.backoff - ) - } - companion object { const val PER_PAGE = 8 const val MAX_BACKOFF = 16000L diff --git a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistView.kt b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistView.kt index 91fb2c8..e8dc18d 100644 --- a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistView.kt +++ b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistView.kt @@ -1,13 +1,11 @@ package com.huchihaitachi.anilist.presentation import com.huchihaitachi.base.BaseView -import com.huchihaitachi.domain.Anime import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject interface AnilistView: BaseView { - val loadAnimePage: Observable - val reload: Observable + val loadPage: Observable + val refresh: Observable val showDetails: Observable val hideDetails: Observable } \ No newline at end of file diff --git a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistViewState.kt b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistViewState.kt index a22c541..29fd56c 100644 --- a/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistViewState.kt +++ b/anilist/src/main/java/com/huchihaitachi/anilist/presentation/AnilistViewState.kt @@ -1,11 +1,11 @@ package com.huchihaitachi.anilist.presentation -import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.NOT_LOADING +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.VIEW_CONTENT import com.huchihaitachi.base.BaseViewState import com.huchihaitachi.domain.Anime data class AnilistViewState( - val loading: LoadingType = NOT_LOADING, + val loading: LoadingType = VIEW_CONTENT, val details: Anime? = null, val pageState: PageState? = null, val error: String? = null, @@ -21,7 +21,7 @@ data class AnilistViewState( enum class LoadingType { PAGE, - RELOAD, - NOT_LOADING + REFRESH, + VIEW_CONTENT } } \ No newline at end of file diff --git a/anilist/src/test/java/com/huchihaitachi/anilist/AnilistPresenterTest.kt b/anilist/src/test/java/com/huchihaitachi/anilist/AnilistPresenterTest.kt index b46cee4..60edec4 100644 --- a/anilist/src/test/java/com/huchihaitachi/anilist/AnilistPresenterTest.kt +++ b/anilist/src/test/java/com/huchihaitachi/anilist/AnilistPresenterTest.kt @@ -1,80 +1,328 @@ package com.huchihaitachi.anilist +import com.apollographql.apollo.exception.ApolloNetworkException import com.huchihaitachi.anilist.presentation.AnilistPresenter import com.huchihaitachi.anilist.presentation.AnilistView import com.huchihaitachi.anilist.presentation.AnilistViewState +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.VIEW_CONTENT import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.PAGE +import com.huchihaitachi.anilist.presentation.AnilistViewState.LoadingType.REFRESH import com.huchihaitachi.anilist.presentation.AnilistViewState.PageState import com.huchihaitachi.base.RxSchedulers import com.huchihaitachi.domain.Anime import com.huchihaitachi.domain.Page +import com.huchihaitachi.domain.Season.FALL +import com.huchihaitachi.domain.Type import com.huchihaitachi.usecase.GetStringResourceUseCase import com.huchihaitachi.usecase.LoadAnimeUseCase import com.huchihaitachi.usecase.LoadPageUseCase +import com.huchihaitachi.usecase.RefreshPageUseCase import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.justRun +import io.mockk.just +import io.mockk.runs import io.mockk.verify -import io.mockk.verifyAll -import io.mockk.verifySequence +import io.mockk.verifyOrder +import io.reactivex.Scheduler import io.reactivex.Single import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.TestScheduler import io.reactivex.subjects.PublishSubject import org.junit.Before import org.junit.Test -import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.MILLISECONDS class AnilistPresenterTest { - @MockK private lateinit var loadPageUseCase: LoadPageUseCase - @MockK private lateinit var loadAnimeUseCase: LoadAnimeUseCase - @MockK private lateinit var getStringResourceUseCase: GetStringResourceUseCase - @MockK private lateinit var anilistView: AnilistView - @MockK private lateinit var rxSchedulers: RxSchedulers + @MockK private lateinit var loadPage: LoadPageUseCase + @MockK private lateinit var refreshPage: RefreshPageUseCase + @MockK private lateinit var loadAnime: LoadAnimeUseCase + @MockK private lateinit var getStringResource: GetStringResourceUseCase + @MockK private lateinit var view: AnilistView + @MockK private lateinit var rxSchedulers: RxSchedulers + private lateinit var anime: List + private lateinit var trampoline: Scheduler + private lateinit var testScheduler: TestScheduler @Before fun setup() { MockKAnnotations.init(this) - mockIntents() + setupView() + anime = listOf(Anime(1), Anime(2), Anime(3), Anime(4), Anime(5), Anime(6)) } @Test fun `load first page test`() { - val initialState = AnilistViewState() - val anilistPresenter = AnilistPresenter( - loadPageUseCase, - loadAnimeUseCase, - getStringResourceUseCase, - AnilistViewState(), - rxSchedulers - ) - val anime = listOf( - Anime(1), Anime(2), Anime(3), Anime(4), Anime(1), Anime(2), Anime(3), Anime(4) - ) - val testScheduler = TestScheduler() - every { rxSchedulers.io } returns testScheduler - every { rxSchedulers.ui } returns testScheduler - anilistPresenter.bind(anilistView) - val loadAnimePageMock: PublishSubject = PublishSubject.create() - every { anilistView.loadAnimePage } returns loadAnimePageMock - every { loadPageUseCase(any(), any()) } returns Single.just(Page(8, 1, true, anime)) - every { anilistView.render(any()) } returns testScheduler.advanceTimeBy(100L, MILLISECONDS) - val expectedResult = AnilistViewState(PAGE, null, PageState(anime, 1, true), null) - anilistPresenter.bindIntents() - testScheduler.advanceTimeBy(10000L, TimeUnit.MILLISECONDS) - loadAnimePageMock.onNext(Unit) - verifySequence { - anilistView.render(initialState) - anilistView.render(expectedResult) + setupTrampoline() + val initialState = AnilistViewState(pageState = PageState(null, 0, true)) + val presenter = AnilistPresenter( + loadPage, refreshPage, loadAnime, getStringResource, initialState, rxSchedulers + ) + val loadPageIntentMock: PublishSubject = PublishSubject.create() + mockIntents(loadPageIntent = loadPageIntentMock) + every { loadPage(any(), any()) } returns Single.just( + Page(1, anime.size, true, anime, 0, 0) + ) + presenter.bind(view) + presenter.bindIntents() + val expectedInitial = AnilistViewState(pageState = PageState(null, 0, true)) + val expectedLoading = expectedInitial.copy( + loading = PAGE, + pageState = PageState(null, 0, true), + ) + val expectedResult = expectedLoading.copy( + loading = VIEW_CONTENT, + pageState = PageState(anime, 1, true) + ) + loadPageIntentMock.onNext(Unit) + verifyOrder { + view.render(expectedInitial) + view.render(expectedLoading) + view.render(expectedResult) + } + } + + @Test + fun `refresh test`() { + setupTrampoline() + val initialState = AnilistViewState(pageState = PageState(anime, 1, true)) + val presenter = AnilistPresenter( + loadPage, refreshPage, loadAnime, getStringResource, initialState, rxSchedulers + ) + val refreshIntentMock: PublishSubject = PublishSubject.create() + mockIntents(refreshIntent = refreshIntentMock) + val freshAnime = listOf( + Anime(11), Anime(21), Anime(31), Anime(41), Anime(51), Anime(61) + ) + every { refreshPage(any()) } returns Single.just( + Page(1, freshAnime.size, true, freshAnime, 0, 0) + ) + presenter.bind(view) + presenter.bindIntents() + val expectedInitial = AnilistViewState(pageState = PageState(anime, 1, true)) + val expectedLoading = AnilistViewState( + loading = REFRESH, + pageState = PageState(anime, 1, true), + ) + val expectedResult = AnilistViewState(pageState = PageState(freshAnime, 1, true)) + refreshIntentMock.onNext(Unit) + verifyOrder { + view.render(expectedInitial) + view.render(expectedLoading) + view.render(expectedResult) + } + } + + @Test + fun `show details while refreshing test`() { + setupTrampoline() + val initialState = AnilistViewState( + loading = REFRESH, + pageState = PageState(anime, 1, true) + ) + val presenter = AnilistPresenter( + loadPage, refreshPage, loadAnime, getStringResource, initialState, rxSchedulers + ) + val showDetailsIntentMock: PublishSubject = PublishSubject.create() + mockIntents(showDetailsIntent = showDetailsIntentMock) + val animeDetails = Anime( + 1, + "title", + Type.ANIME, + "test description", + FALL, + 1998, + 22, + 17, + "image/test/url", + "banner/test/url", + 0, + 0 + ) + val expectedDetails = animeDetails.copy() + every { loadAnime(any()) } returns Single.just(animeDetails) + presenter.bind(view) + presenter.bindIntents() + showDetailsIntentMock.onNext(1) + val expectedInitial = AnilistViewState( + loading = REFRESH, + pageState = PageState(anime, 1, true) + ) + val expectedResult = AnilistViewState( + REFRESH, + expectedDetails, + PageState(anime, 1, true) + ) + verifyOrder { + view.render(expectedInitial) + view.render(expectedResult) + } + } + + @Test + fun `show details while loading page after error test`() { + setupTrampoline() + val initialState = AnilistViewState( + loading = VIEW_CONTENT, + pageState = PageState(anime, 1, true) + ) + every { getStringResource(R.string.no_connection) } returns "No connection" + val presenter = AnilistPresenter( + loadPage, refreshPage, loadAnime, getStringResource, initialState, rxSchedulers + ) + val loadPageIntentMock: PublishSubject = PublishSubject.create() + val showDetailsIntentMock: PublishSubject = PublishSubject.create() + val hideDetailsIntent: PublishSubject = PublishSubject.create() + mockIntents( + loadPageIntent = loadPageIntentMock, + showDetailsIntent = showDetailsIntentMock, + hideDetailsIntent = hideDetailsIntent + ) + val details = Anime( + 1, + "title", + Type.ANIME, + "test description", + FALL, + 1998, + 22, + 17, + "image/test/url", + "banner/test/url", + 0, + 0 + ) + val expectedDetails = details.copy() + every { loadPage(any(), any()) } returns Single.error(ApolloNetworkException("no connection")) + every { loadAnime(any()) } returns Single.just(details) + presenter.bind(view) + presenter.bindIntents() + loadPageIntentMock.onNext(Unit) + showDetailsIntentMock.onNext(1) + hideDetailsIntent.onNext(Unit) + loadPageIntentMock.onNext(Unit) + + val expectedInitial = AnilistViewState( + loading = VIEW_CONTENT, + pageState = PageState(anime, 1, true) + ) + //loading + val expectedSecondState = expectedInitial.copy(loading = PAGE) + //error + val expectedThirdState = expectedSecondState.copy( + loading = VIEW_CONTENT, + error = "No connection", + loadingEnabled = false, + backoff = 1 + ) + //view details while error + val expectedFourthState = expectedThirdState.copy(details = expectedDetails) + //hide details while error + val expectedFifthState = expectedFourthState.copy(details = null) + verifyOrder { + view.render(expectedInitial) + view.render(expectedSecondState) + view.render(expectedThirdState) + view.render(expectedFourthState) + view.render(expectedFifthState) } + verify(exactly = 5) { view.render(any()) } + } + + @Test + fun `load after backoff`() { + setupTestScheduler() + val initialState = AnilistViewState( + loading = VIEW_CONTENT, + pageState = PageState(anime, 1, true) + ) + every { getStringResource(R.string.no_connection) } returns "No connection" + val presenter = AnilistPresenter( + loadPage, refreshPage, loadAnime, getStringResource, initialState, rxSchedulers + ) + val loadPageIntentMock: PublishSubject = PublishSubject.create() + mockIntents(loadPageIntent = loadPageIntentMock) + every { loadPage(any(), any()) } returns Single.error(ApolloNetworkException("no connection")) + presenter.bind(view) + presenter.bindIntents() + loadPageIntentMock.onNext(Unit) + testScheduler.advanceTimeBy(3000L, MILLISECONDS) + val moreAnime = listOf( + Anime(11), Anime(21), Anime(31), Anime(41), Anime(51), Anime(61) + ) + every { loadPage(any(), any()) } returns Single.just( + Page(2, moreAnime.size, true, moreAnime, 0,0) + ) + loadPageIntentMock.onNext(Unit) + testScheduler.triggerActions() + val expectedFourthState = AnilistViewState( + pageState = PageState(anime, 1, true), + backoff = 1 + ) + val expectedFifthState = AnilistViewState( + PAGE, + pageState = PageState(anime, 1, true), + backoff = 1 + ) + val expectedAnime = listOf( + Anime(1), Anime(2), Anime(3), Anime(4), Anime(5), Anime(6), Anime(11), + Anime(21), Anime(31), Anime(41), Anime(51), Anime(61) + ) + val expectedSixthState = AnilistViewState( + pageState = PageState(expectedAnime, 2, true), + ) + val expectedInitial = AnilistViewState( + loading = VIEW_CONTENT, + pageState = PageState(anime, 1, true) + ) + val expectedSecondState = AnilistViewState( + loading = PAGE, + pageState = PageState(anime, 1, true) + ) + val expectedThirdState = AnilistViewState( + pageState = PageState(anime, 1, true), + error = "No connection", + loadingEnabled = false, + backoff = 1 + ) + verifyOrder { + view.render(expectedInitial) + view.render(expectedSecondState) + view.render(expectedThirdState) + view.render(expectedFourthState) + view.render(expectedFifthState) + view.render(expectedSixthState) + } + + } + + private fun mockIntents( + loadPageIntent: PublishSubject = PublishSubject.create(), + refreshIntent: PublishSubject = PublishSubject.create(), + showDetailsIntent: PublishSubject = PublishSubject.create(), + hideDetailsIntent: PublishSubject = PublishSubject.create() + ) { + every { view.loadPage } returns loadPageIntent + every { view.refresh } returns refreshIntent + every { view.showDetails } returns showDetailsIntent + every { view.hideDetails } returns hideDetailsIntent + } + + private fun setupTrampoline() { + trampoline = Schedulers.trampoline() + every { rxSchedulers.io } answers { trampoline } + every { rxSchedulers.computation } answers { TestScheduler() } + every { rxSchedulers.ui } answers { trampoline } + } + + private fun setupTestScheduler() { + testScheduler = TestScheduler() + every { rxSchedulers.io } answers { testScheduler } + every { rxSchedulers.computation } answers { testScheduler } + every { rxSchedulers.ui } answers { testScheduler } } - private fun mockIntents() { - every { anilistView.loadAnimePage } returns PublishSubject.create() - every { anilistView.reload } returns PublishSubject.create() - every { anilistView.showDetails } returns PublishSubject.create() - every { anilistView.hideDetails } returns PublishSubject.create() + private fun setupView() { + every { view.render(any()) } just runs } } \ No newline at end of file diff --git a/base/src/main/java/com/huchihaitachi/base/BasePresenter.kt b/base/src/main/java/com/huchihaitachi/base/BasePresenter.kt index 1be2f27..cefbf65 100644 --- a/base/src/main/java/com/huchihaitachi/base/BasePresenter.kt +++ b/base/src/main/java/com/huchihaitachi/base/BasePresenter.kt @@ -7,14 +7,14 @@ abstract class BasePresenter, S : BaseViewState>( initialState: S, protected val rxSchedulers: RxSchedulers ) { - protected var state: S = initialState + private val observableState: BehaviorSubject = BehaviorSubject.create() + protected var state: S = initialState.also(observableState::onNext) set(value) { field = value observableState.onNext(value) } protected val disposables: CompositeDisposable = CompositeDisposable() protected var view: V? = null - private val observableState: BehaviorSubject = BehaviorSubject.create() fun bind(view: V) { this.view = view diff --git a/base/src/main/java/com/huchihaitachi/base/RxSchedulers.kt b/base/src/main/java/com/huchihaitachi/base/RxSchedulers.kt index 2ac4797..8b4b1aa 100644 --- a/base/src/main/java/com/huchihaitachi/base/RxSchedulers.kt +++ b/base/src/main/java/com/huchihaitachi/base/RxSchedulers.kt @@ -12,6 +12,9 @@ class RxSchedulers @Inject constructor() { val io: Scheduler get() = Schedulers.io() + val computation: Scheduler + get() = Schedulers.computation() + val ui: Scheduler get() = AndroidSchedulers.mainThread() } \ No newline at end of file diff --git a/domain/src/main/java/com/huchihaitachi/domain/Anime.kt b/domain/src/main/java/com/huchihaitachi/domain/Anime.kt index 5e9444a..c558b44 100644 --- a/domain/src/main/java/com/huchihaitachi/domain/Anime.kt +++ b/domain/src/main/java/com/huchihaitachi/domain/Anime.kt @@ -11,6 +11,65 @@ open class Anime( val duration: Int? = null, //minutes in general val coverImage: String? = null, val bannerImage: String? = null, - override val timeOfBirth: Long, - override val timeToStale: Long -) : Dirtyable \ No newline at end of file + override val timeOfBirth: Long = 0, + override val timeToStale: Long = 0 +) : Dirtyable { + + override fun equals(other: Any?) = + this === other || (other is Anime) + && id == other.id + && title == other.title + && type == other.type + && description == other.description + && season == other.season + && seasonYear == other.seasonYear + && episodes == other.episodes + && duration == other.duration + && coverImage == other.coverImage + && bannerImage == other.bannerImage + && timeOfBirth == other.timeOfBirth + && timeToStale == other.timeToStale + + override fun hashCode(): Int { + return id * 31 + + (title?.hashCode() ?: 0) * 31 + + (type?.hashCode() ?: 0) * 31 + + (description?.hashCode() ?: 0) * 31 + + (season?.hashCode() ?: 0) * 31 + + (seasonYear ?: 0) * 31 + + (episodes ?: 0) * 31 + + (duration ?: 0) * 31 + + (coverImage?.hashCode() ?: 0) * 31 + + (bannerImage?.hashCode() ?: 0) * 31 + + timeOfBirth.hashCode() * 31 + + timeToStale.hashCode() * 31 + } + + fun copy( + id: Int = this.id, + title: String? = this.title, + type: Type? = this.type, + description: String? = this.description, + season: Season? = this.season, + seasonYear: Int? = this.seasonYear, + episodes: Int? = this.episodes, + duration: Int? = this.duration, //minutes in general + coverImage: String? = this.coverImage, + bannerImage: String? = this.bannerImage, + timeOfBirth: Long = this.timeOfBirth, + timeToStale: Long = this.timeToStale + ) = Anime( + id, + title, + type, + description, + season, + seasonYear, + episodes, + duration, + coverImage, + bannerImage, + timeOfBirth, + timeToStale + ) +} \ No newline at end of file