Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
plugins {
alias(libs.plugins.architecture.android.library)
alias(libs.plugins.architecture.koin)
alias(libs.plugins.architecture.android.unit.test)
}
// UI-agnostic presentation: the MVI ViewModel + State/Action/Event live here and are shared by
// BOTH the Compose and the Views renderers. No Compose, no Views dependencies on purpose.
android {
namespace = "com.example.architecture.feature.characters.presentation"
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:characters:domain"))
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.android)
// Stable collection for state - makes the list Compose-stable WITHOUT a Compose dependency,
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
api(libs.kotlinx.collections.immutable)
}

View File

@@ -0,0 +1,6 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterDetailAction {
data object OnRetry : CharacterDetailAction
data object OnBackClick : CharacterDetailAction
}

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
package com.example.architecture.feature.characters.presentation
sealed interface ErrorDemoEvent {
data object NavigateBack : ErrorDemoEvent
}

View File

@@ -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,
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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 = ""

View File

@@ -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,
)

View File

@@ -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)
}
}
}

View File

@@ -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,
)

View File

@@ -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)
}
}
}