@@ -0,0 +1,6 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterDetailAction {
|
||||
data object OnRetry : CharacterDetailAction
|
||||
data object OnBackClick : CharacterDetailAction
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterDetailEvent {
|
||||
/** One-time effect: the user asked to leave; the renderer pops the back stack. */
|
||||
data object NavigateBack : CharacterDetailEvent
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterDetailUi
|
||||
|
||||
/**
|
||||
* UI state for the character detail screen. Like [CharacterListState] this is Compose-free: all
|
||||
* fields are stable types, so no `@Stable` (and therefore no Compose dependency) is needed.
|
||||
*/
|
||||
data class CharacterDetailState(
|
||||
val details: CharacterDetailUi? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.architecture.core.domain.onFailure
|
||||
import com.example.architecture.core.domain.onSuccess
|
||||
import com.example.architecture.core.presentation.toUiText
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import com.example.architecture.feature.characters.presentation.model.toCharacterDetailUi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI-agnostic MVI ViewModel for the character detail screen.
|
||||
*
|
||||
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
|
||||
* field name. Reading that raw key - instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` -
|
||||
* is deliberate: it keeps this module free of any navigation/Compose dependency (the route type
|
||||
* lives in the renderer). The renderer is what reads the route via `toRoute()`.
|
||||
*/
|
||||
class CharacterDetailViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val characterRepository: CharacterRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val characterId: Int = checkNotNull(savedStateHandle.get<Int>(KEY_CHARACTER_ID)) {
|
||||
"CharacterDetailRoute.characterId missing from SavedStateHandle"
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(CharacterDetailState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<CharacterDetailEvent>()
|
||||
val events = _events.receiveAsFlow()
|
||||
|
||||
init {
|
||||
loadDetails()
|
||||
}
|
||||
|
||||
fun onAction(action: CharacterDetailAction) {
|
||||
when (action) {
|
||||
CharacterDetailAction.OnRetry -> loadDetails()
|
||||
CharacterDetailAction.OnBackClick -> viewModelScope.launch {
|
||||
_events.send(CharacterDetailEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDetails() {
|
||||
// Clear previous details too: a (re)load must never leave stale content beside a fresh error.
|
||||
_state.update { it.copy(isLoading = true, error = null, details = null) }
|
||||
viewModelScope.launch {
|
||||
characterRepository.getCharacterDetails(characterId)
|
||||
.onSuccess { details ->
|
||||
_state.update {
|
||||
it.copy(details = details.toCharacterDetailUi(), isLoading = false, error = null)
|
||||
}
|
||||
}
|
||||
.onFailure { failure ->
|
||||
_state.update { it.copy(isLoading = false, error = failure.toUiText()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_CHARACTER_ID = "characterId"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface CharacterListAction {
|
||||
data class OnCharacterClick(val characterId: Int) : CharacterListAction
|
||||
data object OnRetry : CharacterListAction
|
||||
data object OnLoadNextPage : CharacterListAction
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
|
||||
sealed interface CharacterListEvent {
|
||||
data class NavigateToDetail(val characterId: Int) : CharacterListEvent
|
||||
data class ShowSnackbar(val message: UiText) : CharacterListEvent
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
|
||||
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
|
||||
* Compose already treats as stable - so this module needs no Compose dependency. Navigation and
|
||||
* snackbars are one-time Events, never state.
|
||||
*/
|
||||
data class CharacterListState(
|
||||
val characters: ImmutableList<CharacterUi> = persistentListOf(),
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingNextPage: Boolean = false,
|
||||
val currentPage: Int = 1,
|
||||
val endReached: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.core.domain.onFailure
|
||||
import com.example.architecture.core.domain.onSuccess
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
import com.example.architecture.core.presentation.toUiText
|
||||
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI-agnostic MVI ViewModel for the characters list. Shared by BOTH the Compose and the Views
|
||||
* renderers. Updates [CharacterListState] only via `.update`, emits one-time [CharacterListEvent]s
|
||||
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
|
||||
*/
|
||||
class CharacterListViewModel(
|
||||
private val getCharactersPage: GetCharactersPageUseCase,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CharacterListState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<CharacterListEvent>()
|
||||
val events = _events.receiveAsFlow()
|
||||
|
||||
init {
|
||||
// After process death, rebuild the list up to the highest page that had been loaded.
|
||||
val restoredPage: Int = savedStateHandle[KEY_PAGE] ?: 1
|
||||
restore(restoredPage)
|
||||
}
|
||||
|
||||
fun onAction(action: CharacterListAction) {
|
||||
when (action) {
|
||||
is CharacterListAction.OnCharacterClick -> viewModelScope.launch {
|
||||
_events.send(CharacterListEvent.NavigateToDetail(action.characterId))
|
||||
}
|
||||
CharacterListAction.OnRetry -> retry()
|
||||
CharacterListAction.OnLoadNextPage -> loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restore(targetPage: Int) {
|
||||
// Flip the flag synchronously so a guard reading state sees it immediately.
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val accumulated = mutableListOf<CharacterUi>()
|
||||
var lastLoadedPage = 0
|
||||
var endReached = false
|
||||
var error: UiText? = null
|
||||
|
||||
var page = 1
|
||||
while (page <= targetPage) {
|
||||
when (val result = getCharactersPage(page)) {
|
||||
is Result.Success -> {
|
||||
accumulated += result.data.characters.map { it.toCharacterUi() }
|
||||
lastLoadedPage = page
|
||||
endReached = result.data.nextPage == null
|
||||
if (endReached) break
|
||||
}
|
||||
is Result.Error -> {
|
||||
error = result.error.toUiText()
|
||||
break
|
||||
}
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
// Always surface a failure - even a partial one where earlier pages loaded.
|
||||
if (error != null) {
|
||||
_events.send(CharacterListEvent.ShowSnackbar(error))
|
||||
}
|
||||
|
||||
if (accumulated.isEmpty()) {
|
||||
// Nothing loaded → full-screen error (or an empty list if the API simply had none).
|
||||
_state.update { it.copy(isLoading = false, error = error) }
|
||||
} else {
|
||||
val loadedPage = lastLoadedPage.coerceAtLeast(1)
|
||||
_state.update {
|
||||
it.copy(
|
||||
characters = accumulated.toImmutableList(),
|
||||
isLoading = false,
|
||||
currentPage = loadedPage,
|
||||
endReached = endReached,
|
||||
// The list is shown; the snackbar already surfaced any partial failure.
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
savedStateHandle[KEY_PAGE] = loadedPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
val current = _state.value
|
||||
if (current.isLoading || current.isLoadingNextPage || current.endReached) return
|
||||
loadPage(page = current.currentPage + 1)
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
val current = _state.value
|
||||
if (current.characters.isEmpty()) {
|
||||
restore(savedStateHandle[KEY_PAGE] ?: 1)
|
||||
} else {
|
||||
loadPage(page = current.currentPage + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPage(page: Int) {
|
||||
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
|
||||
// guarded out before its coroutine starts - otherwise the same page loads twice and items
|
||||
// get appended twice.
|
||||
_state.update { it.copy(isLoadingNextPage = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
getCharactersPage(page)
|
||||
.onSuccess { pageData ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
characters = (state.characters + pageData.characters.map { it.toCharacterUi() })
|
||||
.toImmutableList(),
|
||||
isLoadingNextPage = false,
|
||||
currentPage = page,
|
||||
endReached = pageData.nextPage == null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
savedStateHandle[KEY_PAGE] = page
|
||||
}
|
||||
.onFailure { failure ->
|
||||
val message = failure.toUiText()
|
||||
_state.update { it.copy(isLoadingNextPage = false, error = message) }
|
||||
_events.send(CharacterListEvent.ShowSnackbar(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_PAGE = "currentPage"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface ErrorDemoAction {
|
||||
/** Force a load that fails with the given [ErrorScenario]. */
|
||||
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
|
||||
|
||||
/** Force a load that succeeds - clears any current error. */
|
||||
data object OnLoadSuccess : ErrorDemoAction
|
||||
|
||||
/** Re-issue the most recent load (the design-system retry button). */
|
||||
data object OnRetry : ErrorDemoAction
|
||||
|
||||
data object OnBackClick : ErrorDemoAction
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
sealed interface ErrorDemoEvent {
|
||||
data object NavigateBack : ErrorDemoEvent
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.core.presentation.UiText
|
||||
|
||||
/**
|
||||
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
|
||||
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` - exactly what the real
|
||||
* screens hold - so the renderer resolves and shows it the same way.
|
||||
*/
|
||||
data class ErrorDemoState(
|
||||
val isLoading: Boolean = false,
|
||||
val loaded: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The failure the user asks the demo to reproduce. A presentation-local choice (not a `DataError`)
|
||||
* so the renderer stays free of domain error types; the ViewModel maps it to the real
|
||||
* `DataError.Network` case.
|
||||
*/
|
||||
enum class ErrorScenario {
|
||||
NO_INTERNET,
|
||||
NOT_FOUND,
|
||||
SERVER_ERROR,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.core.domain.onFailure
|
||||
import com.example.architecture.core.domain.onSuccess
|
||||
import com.example.architecture.core.presentation.toUiText
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI-agnostic MVI ViewModel for the **error-handling demo** - a runnable walk-through of the whole
|
||||
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
|
||||
* through the *same* steps a genuine network call uses:
|
||||
*
|
||||
* ```
|
||||
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
|
||||
* ```
|
||||
*
|
||||
* The outcome is *simulated* (no real request) only so every case - including NO_INTERNET, which you
|
||||
* can't reliably trigger on demand - is reachable deterministically. [OnRetry] re-issues the last
|
||||
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
|
||||
* success).
|
||||
*/
|
||||
class ErrorDemoViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ErrorDemoState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<ErrorDemoEvent>()
|
||||
val events = _events.receiveAsFlow()
|
||||
|
||||
// Remembered so OnRetry re-issues exactly what was last attempted.
|
||||
private var lastAttempt: Attempt = Attempt.Success
|
||||
|
||||
fun onAction(action: ErrorDemoAction) {
|
||||
when (action) {
|
||||
is ErrorDemoAction.OnForceError -> load(Attempt.Fail(action.scenario))
|
||||
ErrorDemoAction.OnLoadSuccess -> load(Attempt.Success)
|
||||
ErrorDemoAction.OnRetry -> load(lastAttempt)
|
||||
ErrorDemoAction.OnBackClick -> viewModelScope.launch {
|
||||
_events.send(ErrorDemoEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(attempt: Attempt) {
|
||||
lastAttempt = attempt
|
||||
_state.update { it.copy(isLoading = true, error = null, loaded = false) }
|
||||
viewModelScope.launch {
|
||||
delay(LOAD_DELAY_MS) // pretend a request is in flight, so the loading state is visible
|
||||
simulate(attempt)
|
||||
.onSuccess { _state.update { it.copy(isLoading = false, loaded = true, error = null) } }
|
||||
.onFailure { dataError ->
|
||||
// The crux of the demo: a DataError becomes user-facing UiText right here.
|
||||
_state.update { it.copy(isLoading = false, error = dataError.toUiText()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun simulate(attempt: Attempt): Result<Unit, DataError.Network> = when (attempt) {
|
||||
Attempt.Success -> Result.Success(Unit)
|
||||
is Attempt.Fail -> Result.Error(attempt.scenario.toDataError())
|
||||
}
|
||||
|
||||
private sealed interface Attempt {
|
||||
data object Success : Attempt
|
||||
data class Fail(val scenario: ErrorScenario) : Attempt
|
||||
}
|
||||
|
||||
private fun ErrorScenario.toDataError(): DataError.Network = when (this) {
|
||||
ErrorScenario.NO_INTERNET -> DataError.Network.NO_INTERNET
|
||||
ErrorScenario.NOT_FOUND -> DataError.Network.NOT_FOUND
|
||||
ErrorScenario.SERVER_ERROR -> DataError.Network.SERVER_ERROR
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOAD_DELAY_MS = 400L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.architecture.feature.characters.presentation.di
|
||||
|
||||
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
|
||||
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
|
||||
val charactersPresentationModule = module {
|
||||
// Stateless domain UseCase - `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
|
||||
// its CharacterRepository from charactersDataModule.
|
||||
factoryOf(::GetCharactersPageUseCase)
|
||||
viewModelOf(::CharacterListViewModel)
|
||||
viewModelOf(::CharacterDetailViewModel)
|
||||
viewModelOf(::ErrorDemoViewModel)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.example.architecture.feature.characters.presentation.model
|
||||
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/**
|
||||
* Presentation model for the character detail screen. Blank free-text API fields (notably `type`)
|
||||
* are pre-formatted to an em dash here so the renderer stays dumb.
|
||||
*/
|
||||
data class CharacterDetailUi(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: CharacterStatus,
|
||||
val species: String,
|
||||
val type: String,
|
||||
val gender: String,
|
||||
val origin: String,
|
||||
val location: String,
|
||||
val imageUrl: String,
|
||||
val episodeCount: Int,
|
||||
)
|
||||
|
||||
fun CharacterDetails.toCharacterDetailUi(): CharacterDetailUi = CharacterDetailUi(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status,
|
||||
species = species.ifBlank { DASH },
|
||||
type = type.ifBlank { DASH },
|
||||
gender = gender.ifBlank { DASH },
|
||||
origin = origin.ifBlank { DASH },
|
||||
location = location.ifBlank { DASH },
|
||||
imageUrl = imageUrl,
|
||||
episodeCount = episodeCount,
|
||||
)
|
||||
|
||||
private const val DASH = "—"
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.architecture.feature.characters.presentation.model
|
||||
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/** Presentation model for a character list item - decouples the UI from the domain [Character]. */
|
||||
data class CharacterUi(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val species: String,
|
||||
val imageUrl: String,
|
||||
val status: CharacterStatus,
|
||||
)
|
||||
|
||||
fun Character.toCharacterUi(): CharacterUi = CharacterUi(
|
||||
id = id,
|
||||
name = name,
|
||||
species = species,
|
||||
imageUrl = imageUrl,
|
||||
status = status,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
/**
|
||||
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
|
||||
* by type-safe navigation), which is constructed directly here - proving the VM needs no navigation
|
||||
* dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
|
||||
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
|
||||
* assertions use AssertK; the back event is observed with Turbine.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CharacterDetailViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val repository = mockk<CharacterRepository>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(characterId: Int = 1) =
|
||||
CharacterDetailViewModel(SavedStateHandle(mapOf("characterId" to characterId)), repository)
|
||||
|
||||
@Test
|
||||
fun `loads details on init`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.isLoading).isFalse()
|
||||
assertThat(state.error).isNull()
|
||||
assertThat(state.details).isNotNull()
|
||||
assertThat(state.details?.name).isEqualTo("Character 1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
|
||||
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.error).isNotNull()
|
||||
assertThat(state.details).isNull()
|
||||
assertThat(state.isLoading).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.NO_INTERNET)
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
assertThat(viewModel.state.value.error).isNotNull()
|
||||
|
||||
// Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
viewModel.onAction(CharacterDetailAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.error).isNull()
|
||||
assertThat(state.details).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
val viewModel = viewModel(characterId = 1)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.events.test {
|
||||
viewModel.onAction(CharacterDetailAction.OnBackClick)
|
||||
advanceUntilIdle()
|
||||
assertThat(awaitItem()).isSameInstanceAs(CharacterDetailEvent.NavigateBack)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing character id fails fast`() {
|
||||
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
|
||||
// The constructor throws before the (relaxed) repository is ever touched.
|
||||
assertThrows<IllegalStateException> {
|
||||
CharacterDetailViewModel(SavedStateHandle(), repository)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/** Minimal list-item domain fixture for presentation tests. */
|
||||
fun character(id: Int): Character = Character(
|
||||
id = id,
|
||||
name = "Character $id",
|
||||
status = CharacterStatus.ALIVE,
|
||||
species = "Human",
|
||||
imageUrl = "https://example.com/$id.png",
|
||||
)
|
||||
|
||||
/** Minimal detail domain fixture for presentation tests. */
|
||||
fun characterDetails(id: Int): CharacterDetails = CharacterDetails(
|
||||
id = id,
|
||||
name = "Character $id",
|
||||
status = CharacterStatus.ALIVE,
|
||||
species = "Human",
|
||||
type = "Genetic experiment",
|
||||
gender = "Male",
|
||||
origin = "Earth (C-137)",
|
||||
location = "Citadel of Ricks",
|
||||
imageUrl = "https://example.com/$id.png",
|
||||
episodeCount = 10,
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.example.architecture.feature.characters.presentation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.assertions.prop
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [CharacterListViewModel] - driven entirely through its public MVI surface
|
||||
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
|
||||
*
|
||||
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
|
||||
* which lets the duplicate-paging test observe the *synchronous* loading-flag guard before any
|
||||
* coroutine runs. The collaborator is a MockK mock of [CharacterRepository] (the real
|
||||
* [GetCharactersPageUseCase] wraps it), stubbed per page with `coEvery` and verified with `coVerify`;
|
||||
* `state`/`events` are observed with Turbine; assertions use AssertK.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CharacterListViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val repository = mockk<CharacterRepository>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()) =
|
||||
CharacterListViewModel(GetCharactersPageUseCase(repository), savedStateHandle)
|
||||
|
||||
@Test
|
||||
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
|
||||
|
||||
val viewModel = viewModel()
|
||||
|
||||
viewModel.state.test {
|
||||
// restore() flips isLoading synchronously during construction, before the coroutine runs.
|
||||
assertThat(awaitItem()).isEqualTo(CharacterListState(isLoading = true))
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val loaded = awaitItem()
|
||||
assertThat(loaded).prop(CharacterListState::characters).hasSize(2)
|
||||
assertThat(loaded).prop(CharacterListState::isLoading).isFalse()
|
||||
assertThat(loaded).prop(CharacterListState::currentPage).isEqualTo(1)
|
||||
assertThat(loaded).prop(CharacterListState::endReached).isFalse()
|
||||
assertThat(loaded).prop(CharacterListState::error).isNull()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial load failure emits a snackbar event and a full-screen error`() =
|
||||
runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
|
||||
|
||||
val viewModel = viewModel()
|
||||
|
||||
viewModel.events.test {
|
||||
advanceUntilIdle()
|
||||
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::error).isNotNull()
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = null)) // last page
|
||||
|
||||
val viewModel = viewModel()
|
||||
advanceUntilIdle() // init → page 1
|
||||
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle() // → page 2, end reached
|
||||
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue()
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle() // guarded by endReached → no request
|
||||
|
||||
// Page 2 was fetched exactly once and no page 3 was ever requested (a page-3 fetch would also
|
||||
// blow up the strict mock as an unstubbed call).
|
||||
coVerify(exactly = 1) { repository.getCharacters(2) }
|
||||
coVerify(exactly = 0) { repository.getCharacters(3) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
|
||||
|
||||
val viewModel = viewModel()
|
||||
advanceUntilIdle() // init → page 1
|
||||
|
||||
// Both fire before any launched coroutine runs; the second sees the synchronously-set
|
||||
// isLoadingNextPage flag and is guarded out.
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { repository.getCharacters(2) }
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores a next-page request while the initial load is in flight`() =
|
||||
runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
|
||||
val viewModel = viewModel()
|
||||
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
|
||||
// OnLoadNextPage hits the `isLoading` guard in loadNextPage() and is dropped.
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Only the single initial load ran - the guarded next-page request never fired.
|
||||
coVerify(exactly = 1) { repository.getCharacters(1) }
|
||||
coVerify(exactly = 0) { repository.getCharacters(2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
|
||||
val viewModel = viewModel()
|
||||
|
||||
viewModel.events.test {
|
||||
advanceUntilIdle()
|
||||
// The initial-load failure surfaces as a snackbar; consuming it is also how the
|
||||
// rendezvous-Channel send in restore() completes so state can settle.
|
||||
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
|
||||
|
||||
// Empty branch of retry(): the repository recovers (the later, more specific stub for
|
||||
// page 1 wins over the `any()` failure), OnRetry rebuilds from page 1.
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
|
||||
viewModel.onAction(CharacterListAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
val viewModel = viewModel()
|
||||
advanceUntilIdle() // page 1 loaded (no event)
|
||||
|
||||
viewModel.events.test {
|
||||
// Page 2 fails on the first attempt → list keeps page 1, shows an error.
|
||||
coEvery { repository.getCharacters(2) } returns Result.Error(DataError.Network.NOT_FOUND)
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle()
|
||||
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(1)
|
||||
|
||||
// Non-empty branch of retry(): with page 2 now available, OnRetry re-requests page 2 and
|
||||
// appends it (currentPage stayed 1 because loadPage only advances on success).
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = null))
|
||||
viewModel.onAction(CharacterListAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
|
||||
// Page 2 was requested twice: the failed first load and the successful retry.
|
||||
coVerify(exactly = 2) { repository.getCharacters(2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
|
||||
coEvery { repository.getCharacters(1) } returns
|
||||
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
|
||||
coEvery { repository.getCharacters(2) } returns
|
||||
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
|
||||
// Navigation/SavedStateHandle persisted the last loaded page across process death.
|
||||
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
|
||||
|
||||
val viewModel = viewModel(savedStateHandle)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Both pages are rebuilt (1 then 2), and currentPage is restored.
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
|
||||
coVerifyOrder {
|
||||
repository.getCharacters(1)
|
||||
repository.getCharacters(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user