From 0542d4dc1db8488ff1bde5a22764ff432de37fcb Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:17 +0200 Subject: [PATCH 1/6] REDI-94: GetCharactersPageUseCase + inject into list ViewModel Add a domain UseCase (operator invoke) in :feature:characters:domain delegating to CharacterRepository, and have CharacterListViewModel depend on it instead of the repository directly. The UseCase is a deliberate thin pass-through that documents the 'when to add a UseCase' convention (real logic / multi-source composition vs. a single forwarded call). --- .../usecase/GetCharactersPageUseCase.kt | 27 +++++++++++++++++++ .../presentation/CharacterListViewModel.kt | 8 +++--- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCase.kt diff --git a/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCase.kt b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCase.kt new file mode 100644 index 0000000..fb87121 --- /dev/null +++ b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCase.kt @@ -0,0 +1,27 @@ +package com.example.architecture.feature.characters.domain.usecase + +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 + +/** + * Loads one page of characters. + * + * **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business + * logic that does NOT belong in the ViewModel — non-trivial rules, or *composition* of several + * repositories/sources into one domain operation. When the ViewModel would merely forward a single + * repository call, skipping the UseCase and injecting the repository directly is perfectly fine. + * + * This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic + * beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the + * convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would + * grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) — or + * delete it and let the ViewModel call the repository. + */ +class GetCharactersPageUseCase( + private val characterRepository: CharacterRepository, +) { + suspend operator fun invoke(page: Int): Result = + characterRepository.getCharacters(page) +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt index c131025..e70c400 100644 --- a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt @@ -8,7 +8,7 @@ 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.CharacterRepository +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 @@ -25,7 +25,7 @@ import kotlinx.coroutines.launch * via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle]. */ class CharacterListViewModel( - private val characterRepository: CharacterRepository, + private val getCharactersPage: GetCharactersPageUseCase, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -62,7 +62,7 @@ class CharacterListViewModel( var page = 1 while (page <= targetPage) { - when (val result = characterRepository.getCharacters(page)) { + when (val result = getCharactersPage(page)) { is Result.Success -> { accumulated += result.data.characters.map { it.toCharacterUi() } lastLoadedPage = page @@ -123,7 +123,7 @@ class CharacterListViewModel( // get appended twice. _state.update { it.copy(isLoadingNextPage = true, error = null) } viewModelScope.launch { - characterRepository.getCharacters(page) + getCharactersPage(page) .onSuccess { pageData -> _state.update { state -> state.copy( From cf63095acc7c81ff7d2c6550515358b29287f14c Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:27 +0200 Subject: [PATCH 2/6] REDI-98: error-handling demo screen (DataError -> UiText pipeline) A runnable MVI screen (reached from the list overflow menu) that forces a real DataError.Network case and routes it through the same pipeline a genuine call uses: Result.Error -> onFailure -> DataError.toUiText() -> design-system ErrorState. Three distinct cases (NO_INTERNET, NOT_FOUND, SERVER_ERROR) each render their mapped message; Retry re-issues the last attempt via an Action; a successful load clears the error. Wired as intra-feature navigation (ErrorDemoRoute) and registered in Koin (incl. the UseCase factoryOf). --- .../compose/CharacterListScreen.kt | 25 ++- .../compose/CharactersNavigation.kt | 14 +- .../presentation/compose/ErrorDemoScreen.kt | 156 ++++++++++++++++++ .../src/main/res/values/strings.xml | 11 ++ .../presentation/ErrorDemoAction.kt | 14 ++ .../characters/presentation/ErrorDemoEvent.kt | 5 + .../characters/presentation/ErrorDemoState.kt | 25 +++ .../presentation/ErrorDemoViewModel.kt | 87 ++++++++++ .../di/CharactersPresentationModule.kt | 7 + 9 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt index b342f25..594519e 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt @@ -66,14 +66,15 @@ import org.koin.androidx.compose.koinViewModel * Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up. * The snackbar is resolved with the Context-based [asString] because it runs outside composition. * - * [onOpenAbout] and [onOpenViewsList] are renderer-only chrome (a Compose overflow menu), so they - * are plain callbacks rather than going through the shared, UI-agnostic ViewModel. + * [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] are renderer-only chrome (a Compose overflow + * menu), so they are plain callbacks rather than going through the shared, UI-agnostic ViewModel. */ @Composable fun CharacterListRoot( onCharacterClick: (Int) -> Unit, onOpenAbout: () -> Unit, onOpenViewsList: () -> Unit, + onOpenErrorDemo: () -> Unit, viewModel: CharacterListViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -95,6 +96,7 @@ fun CharacterListRoot( onAction = viewModel::onAction, onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList, + onOpenErrorDemo = onOpenErrorDemo, snackbarHostState = snackbarHostState, ) } @@ -107,13 +109,20 @@ fun CharacterListScreen( onAction: (CharacterListAction) -> Unit, onOpenAbout: () -> Unit, onOpenViewsList: () -> Unit, + onOpenErrorDemo: () -> Unit, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { AppScaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.characters_title)) }, - actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) }, + actions = { + CharacterListOverflowMenu( + onOpenAbout = onOpenAbout, + onOpenViewsList = onOpenViewsList, + onOpenErrorDemo = onOpenErrorDemo, + ) + }, ) }, ) { innerPadding -> @@ -149,6 +158,7 @@ fun CharacterListScreen( private fun CharacterListOverflowMenu( onOpenAbout: () -> Unit, onOpenViewsList: () -> Unit, + onOpenErrorDemo: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } IconButton(onClick = { expanded = true }) { @@ -165,6 +175,13 @@ private fun CharacterListOverflowMenu( onOpenViewsList() }, ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_error_demo)) }, + onClick = { + expanded = false + onOpenErrorDemo() + }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.menu_about)) }, onClick = { @@ -284,6 +301,7 @@ private fun CharacterListScreenLoadedPreview() { onAction = {}, onOpenAbout = {}, onOpenViewsList = {}, + onOpenErrorDemo = {}, ) } } @@ -301,6 +319,7 @@ private fun CharacterListScreenErrorPreview() { onAction = {}, onOpenAbout = {}, onOpenViewsList = {}, + onOpenErrorDemo = {}, ) } } diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt index 2e6c5a3..8098697 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt @@ -13,10 +13,14 @@ data object CharacterListRoute @Serializable data class CharacterDetailRoute(val characterId: Int) +/** Type-safe route for the error-handling demo screen. */ +@Serializable +data object ErrorDemoRoute + /** - * The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the - * [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted - * by `:app`) stay decoupled as callbacks supplied by `:app`. + * The characters feature nav graph. List→detail and list→error-demo are intra-feature navigation, so + * they are driven by the [navController] passed in. Cross-boundary destinations (the About screen, + * the Views renderer hosted by `:app`) stay decoupled as callbacks supplied by `:app`. */ fun NavGraphBuilder.charactersGraph( navController: NavController, @@ -30,6 +34,7 @@ fun NavGraphBuilder.charactersGraph( }, onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList, + onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) }, ) } composable { @@ -38,4 +43,7 @@ fun NavGraphBuilder.charactersGraph( // CharacterDetailViewModel reads it (keeping that module free of any navigation dependency). CharacterDetailRoot(onNavigateBack = { navController.popBackStack() }) } + composable { + ErrorDemoRoot(onNavigateBack = { navController.popBackStack() }) + } } diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt new file mode 100644 index 0000000..cf20c44 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/ErrorDemoScreen.kt @@ -0,0 +1,156 @@ +package com.example.architecture.feature.characters.presentation.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.architecture.core.design.system.component.AppScaffold +import com.example.architecture.core.design.system.component.ErrorState +import com.example.architecture.core.design.system.component.LoadingIndicator +import com.example.architecture.core.design.system.theme.AppTheme +import com.example.architecture.core.presentation.ObserveAsEvents +import com.example.architecture.core.presentation.asString +import com.example.architecture.feature.characters.presentation.ErrorDemoAction +import com.example.architecture.feature.characters.presentation.ErrorDemoEvent +import com.example.architecture.feature.characters.presentation.ErrorDemoState +import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel +import com.example.architecture.feature.characters.presentation.ErrorScenario +import org.koin.androidx.compose.koinViewModel + +/** + * Root: owns the demo ViewModel (Koin) and forwards the one-time NavigateBack event up the stack. + */ +@Composable +fun ErrorDemoRoot( + onNavigateBack: () -> Unit, + viewModel: ErrorDemoViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + ErrorDemoEvent.NavigateBack -> onNavigateBack() + } + } + + ErrorDemoScreen(state = state, onAction = viewModel::onAction) +} + +/** Pure, stateless screen — previewable without a ViewModel. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ErrorDemoScreen( + state: ErrorDemoState, + onAction: (ErrorDemoAction) -> Unit, +) { + AppScaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.error_demo_title)) }, + navigationIcon = { + IconButton(onClick = { onAction(ErrorDemoAction.OnBackClick) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.error_demo_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NO_INTERNET)) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.error_demo_force_no_internet)) } + OutlinedButton( + onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NOT_FOUND)) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.error_demo_force_not_found)) } + OutlinedButton( + onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.SERVER_ERROR)) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.error_demo_force_server)) } + Button( + onClick = { onAction(ErrorDemoAction.OnLoadSuccess) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.error_demo_load_success)) } + + // Result area: loading → mapped error (with retry) → success → idle hint. + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val error = state.error + when { + state.isLoading -> LoadingIndicator() + error != null -> ErrorState( + message = error.asString(), + onRetry = { onAction(ErrorDemoAction.OnRetry) }, + ) + state.loaded -> Text( + text = stringResource(R.string.error_demo_success), + style = MaterialTheme.typography.titleMedium, + ) + else -> Text( + text = stringResource(R.string.error_demo_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Preview +@Composable +private fun ErrorDemoScreenIdlePreview() { + AppTheme { ErrorDemoScreen(state = ErrorDemoState(), onAction = {}) } +} + +@Preview +@Composable +private fun ErrorDemoScreenErrorPreview() { + AppTheme { + ErrorDemoScreen( + state = ErrorDemoState( + error = com.example.architecture.core.presentation.UiText.DynamicString( + "No internet connection. Check your network and try again.", + ), + ), + onAction = {}, + ) + } +} diff --git a/feature/characters/presentation-compose/src/main/res/values/strings.xml b/feature/characters/presentation-compose/src/main/res/values/strings.xml index c4c5132..b676518 100644 --- a/feature/characters/presentation-compose/src/main/res/values/strings.xml +++ b/feature/characters/presentation-compose/src/main/res/values/strings.xml @@ -10,6 +10,17 @@ More options About Open as Views + Error handling demo + + + Error handling demo + Force a network failure to watch it flow through the pipeline: DataError.Network → toUiText() → the shared ErrorState. Retry re-issues the same request; a successful load clears the error. + Force: No internet + Force: Not found + Force: Server error + Load (success) + Loaded successfully ✓ + Pick an action above to see the result here. Character diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt new file mode 100644 index 0000000..ad5a90c --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoAction.kt @@ -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 +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt new file mode 100644 index 0000000..b124b76 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoEvent.kt @@ -0,0 +1,5 @@ +package com.example.architecture.feature.characters.presentation + +sealed interface ErrorDemoEvent { + data object NavigateBack : ErrorDemoEvent +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt new file mode 100644 index 0000000..d9069bc --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoState.kt @@ -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, +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt new file mode 100644 index 0000000..a3764ec --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt @@ -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). See android-error-handling. + */ +class ErrorDemoViewModel : ViewModel() { + + private val _state = MutableStateFlow(ErrorDemoState()) + val state = _state.asStateFlow() + + private val _events = Channel() + 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 = 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 + } +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt index e15ec8d..084191d 100644 --- a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt @@ -1,12 +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. See koin-constructor-dsl. + factoryOf(::GetCharactersPageUseCase) viewModelOf(::CharacterListViewModel) viewModelOf(::CharacterDetailViewModel) + viewModelOf(::ErrorDemoViewModel) } From 7a7ab45a6680c6863ffd0d9c1f4b56f007353ccf Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:37 +0200 Subject: [PATCH 3/6] test infra: JUnit5 unit tests on Android modules + Compose UI test wiring Add an architecture.android.unit.test convention plugin that runs local unit tests on the JUnit5 platform via useJUnitPlatform() (AndroidUnitTest extends Gradle's Test) + the unit-test bundle. Deliberately NOT using the de.mannodermaus plugin (targets AGP 8.x; we're on AGP 9). Add junit-platform-launcher (Gradle 9 dropped the bundled launcher); set the instrumentation runner; add a compose-ui-test bundle pinning espresso/runner to current versions (transitive espresso 3.5.0 calls the removed InputManager.getInstance() on API 34+). CI now runs ./gradlew test and compiles the instrumented tests. Drop unused testing catalog entries. --- .github/workflows/ci.yml | 17 ++++++++-- build-logic/convention/build.gradle.kts | 4 +++ .../AndroidLibraryConventionPlugin.kt | 3 ++ .../AndroidUnitTestConventionPlugin.kt | 32 +++++++++++++++++++ .../DomainModuleConventionPlugin.kt | 2 ++ feature/characters/data/build.gradle.kts | 6 ++++ .../presentation-compose/build.gradle.kts | 5 +++ .../characters/presentation/build.gradle.kts | 1 + gradle/libs.versions.toml | 23 +++++++------ 9 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidUnitTestConventionPlugin.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8d918f..44b857f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,18 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 - - name: Assemble (debug) - run: ./gradlew assembleDebug --no-daemon --stacktrace + - name: Unit tests (JUnit 5) + run: ./gradlew test --no-daemon --stacktrace + + - name: Assemble (debug) + compile instrumented tests + # assembleDebugAndroidTest compiles the Compose UI test; it runs on a device via + # connectedDebugAndroidTest (locally / on an emulator runner), not in this build job. + run: ./gradlew assembleDebug assembleDebugAndroidTest --no-daemon --stacktrace + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: '**/build/reports/tests/' + if-no-files-found: ignore diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 57110d6..d1109af 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -47,6 +47,10 @@ gradlePlugin { id = "architecture.domain.module" implementationClass = "com.example.architecture.convention.DomainModuleConventionPlugin" } + register("androidUnitTest") { + id = "architecture.android.unit.test" + implementationClass = "com.example.architecture.convention.AndroidUnitTestConventionPlugin" + } register("compose") { id = "architecture.compose" implementationClass = "com.example.architecture.convention.ComposeConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidLibraryConventionPlugin.kt index 0376d9d..2ce162a 100644 --- a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidLibraryConventionPlugin.kt @@ -19,6 +19,9 @@ class AndroidLibraryConventionPlugin : Plugin { defaultConfig { minSdk = MIN_SDK + // Used by instrumented (androidTest) tests, e.g. the Compose UI test in + // :feature:characters:presentation-compose. Harmless for modules without androidTest. + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { diff --git a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidUnitTestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidUnitTestConventionPlugin.kt new file mode 100644 index 0000000..47afb90 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/AndroidUnitTestConventionPlugin.kt @@ -0,0 +1,32 @@ +package com.example.architecture.convention + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType + +/** + * Runs an Android library module's local unit tests (`src/test`) on the **JUnit 5 platform** with the + * shared `unit-test` toolset (JUnit Jupiter, kotlinx-coroutines-test, Turbine, AssertK). + * + * Deliberately does NOT use the `de.mannodermaus.android-junit5` Gradle plugin: its 1.11.x line + * targets AGP 8.x and we build on AGP 9.0. It isn't needed for *local* unit tests anyway — + * `com.android.build.gradle.tasks.factory.AndroidUnitTest` extends Gradle's [Test] task, so calling + * `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the + * same for pure-JVM modules). + */ +class AndroidUnitTestConventionPlugin : Plugin { + override fun apply(target: Project) = with(target) { + dependencies { + add("testImplementation", libs.findBundle("unit-test").get()) + add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get()) + // Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it. + add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get()) + } + + tasks.withType().configureEach { + useJUnitPlatform() + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/DomainModuleConventionPlugin.kt b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/DomainModuleConventionPlugin.kt index 82bc8bd..ebe663b 100644 --- a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/DomainModuleConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/DomainModuleConventionPlugin.kt @@ -21,6 +21,8 @@ class DomainModuleConventionPlugin : Plugin { add("testImplementation", libs.findLibrary("junit-jupiter-api").get()) add("testImplementation", libs.findLibrary("assertk").get()) add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get()) + // Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it. + add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get()) } tasks.withType().configureEach { diff --git a/feature/characters/data/build.gradle.kts b/feature/characters/data/build.gradle.kts index 6dd3fa9..509fa9c 100644 --- a/feature/characters/data/build.gradle.kts +++ b/feature/characters/data/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.architecture.android.library) alias(libs.plugins.architecture.koin) alias(libs.plugins.architecture.kotlinx.serialization) + alias(libs.plugins.architecture.android.unit.test) } android { @@ -12,4 +13,9 @@ dependencies { implementation(project(":core:domain")) implementation(project(":core:data")) implementation(project(":feature:characters:domain")) + + // Swap a Ktor MockEngine into HttpClientFactory.create(...) for the repository test. + testImplementation(libs.ktor.client.mock) + testImplementation(libs.ktor.client.content.negotiation) + testImplementation(libs.ktor.serialization.kotlinx.json) } diff --git a/feature/characters/presentation-compose/build.gradle.kts b/feature/characters/presentation-compose/build.gradle.kts index 016b9c0..98dd3b8 100644 --- a/feature/characters/presentation-compose/build.gradle.kts +++ b/feature/characters/presentation-compose/build.gradle.kts @@ -13,4 +13,9 @@ dependencies { implementation(project(":core:design-system")) implementation(project(":feature:characters:domain")) implementation(project(":feature:characters:presentation")) + + // Instrumented Compose UI test (robot pattern). The Compose convention already adds the BOM to + // androidTestImplementation; ui-test-manifest provides the empty Activity ComposeTestRule hosts in. + androidTestImplementation(libs.bundles.compose.ui.test) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/feature/characters/presentation/build.gradle.kts b/feature/characters/presentation/build.gradle.kts index e8e8a28..b443042 100644 --- a/feature/characters/presentation/build.gradle.kts +++ b/feature/characters/presentation/build.gradle.kts @@ -1,6 +1,7 @@ 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9f9db4..d2143e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,12 +36,10 @@ timber = "5.0.1" material = "1.12.0" # Testing -junit4 = "4.13.2" junitJupiter = "5.11.4" -androidJunit5 = "1.11.4" +junitPlatform = "1.11.4" turbine = "1.2.0" assertk = "0.28.1" -androidxTest = "1.7.0" androidxTestExt = "1.3.0" androidxTestRunner = "1.7.0" androidxEspresso = "3.7.0" @@ -97,7 +95,6 @@ koin-core = { module = "io.insert-koin:koin-core" } koin-android = { module = "io.insert-koin:koin-android" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" } koin-test = { module = "io.insert-koin:koin-test" } -koin-test-junit5 = { module = "io.insert-koin:koin-test-junit5" } # --- Ktor --- ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } @@ -116,13 +113,12 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } # --- Testing --- -junit4 = { module = "junit:junit", version.ref = "junit4" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" } -junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junitJupiter" } +# Gradle 9 no longer bundles the launcher — it must be on the test runtime classpath explicitly. +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } -androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" } @@ -147,6 +143,16 @@ ktor = [ lifecycle-compose = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"] views = ["androidx-appcompat", "material", "androidx-recyclerview", "androidx-fragment-ktx"] unit-test = ["junit-jupiter-api", "kotlinx-coroutines-test", "turbine", "assertk"] +# Instrumented Compose UI test (androidTest): ComposeTestRule + AndroidJUnit4 runner. +# espresso-core/runner are pinned to current versions: Compose's test rule drives Espresso's +# onIdle, and the transitive espresso 3.5.0 calls InputManager.getInstance() (removed on API 34+), +# which crashes on modern devices. 3.7.0 fixes that reflection. +compose-ui-test = [ + "androidx-compose-ui-test-junit4", + "androidx-test-ext-junit", + "androidx-test-espresso-core", + "androidx-test-runner", +] [plugins] # Upstream plugins @@ -155,8 +161,6 @@ android-library = { id = "com.android.library", version.ref = "agp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -# Declared for milestone 5 (ViewModel/Compose tests on Android); wired when tests land. -android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" } # Convention plugins (defined in :build-logic, resolved from the included build) architecture-android-application = { id = "architecture.android.application" } @@ -164,6 +168,7 @@ architecture-android-library = { id = "architecture.android.library" } architecture-android-feature = { id = "architecture.android.feature" } architecture-android-feature-views = { id = "architecture.android.feature.views" } architecture-domain-module = { id = "architecture.domain.module" } +architecture-android-unit-test = { id = "architecture.android.unit.test" } architecture-compose = { id = "architecture.compose" } architecture-koin = { id = "architecture.koin" } architecture-ktor = { id = "architecture.ktor" } From 3f9cf9621614a68c02603393cac5600a4c029ef2 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:45 +0200 Subject: [PATCH 4/6] REDI-95: ViewModel unit tests (JUnit5 + Turbine + AssertK + fakes) Test CharacterListViewModel and CharacterDetailViewModel entirely through their MVI surface with a FakeCharacterRepository (a fake, not a mock) and a directly constructed SavedStateHandle, on StandardTestDispatcher. Coverage: happy path, error -> UiText + snackbar Event, pagination end-reached, the in-flight and duplicate next-page guards, process-death restore, and both branches of OnRetry. Also a domain test for GetCharactersPageUseCase (delegation + error propagation). --- .../usecase/GetCharactersPageUseCaseTest.kt | 79 +++++++ .../CharacterDetailViewModelTest.kt | 115 ++++++++++ .../CharacterListViewModelTest.kt | 215 ++++++++++++++++++ .../presentation/FakeCharacterRepository.kt | 74 ++++++ 4 files changed, 483 insertions(+) create mode 100644 feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt create mode 100644 feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt create mode 100644 feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt create mode 100644 feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt diff --git a/feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt b/feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt new file mode 100644 index 0000000..637ceab --- /dev/null +++ b/feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt @@ -0,0 +1,79 @@ +package com.example.architecture.feature.characters.domain.usecase + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +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.Character +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.domain.model.CharactersPage +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +/** + * Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to + * the repository and return its result verbatim — success and error alike. Pure JVM test on the + * JUnit 5 platform (see DomainModuleConventionPlugin); collaborator is a hand-written fake. + */ +class GetCharactersPageUseCaseTest { + + @Test + fun `returns the repository page on success`() = runBlocking { + val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2) + val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page))) + + val result = useCase(page = 1) + + assertThat(result).isEqualTo(Result.Success(page)) + } + + @Test + fun `propagates the repository error`() = runBlocking { + val useCase = GetCharactersPageUseCase( + FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)), + ) + + val result = useCase(page = 1) + + assertThat(result).isInstanceOf(Result.Error::class) + assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR) + } + + @Test + fun `forwards the requested page number`() = runBlocking { + val fake = FakeCharacterRepository( + pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)), + ) + val useCase = GetCharactersPageUseCase(fake) + + useCase(page = 7) + + assertThat(fake.lastRequestedPage).isEqualTo(7) + } + + private class FakeCharacterRepository( + private val pageResult: Result, + ) : CharacterRepository { + var lastRequestedPage: Int? = null + private set + + override suspend fun getCharacters(page: Int): Result { + lastRequestedPage = page + return pageResult + } + + override suspend fun getCharacterDetails(id: Int): Result = + Result.Error(DataError.Network.NOT_FOUND) + } + + private fun domainCharacter(id: Int) = Character( + id = id, + name = "Character $id", + status = CharacterStatus.ALIVE, + species = "Human", + imageUrl = "https://example.com/$id.png", + ) +} diff --git a/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt new file mode 100644 index 0000000..aefc690 --- /dev/null +++ b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt @@ -0,0 +1,115 @@ +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 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. Collaborator is a [FakeCharacterRepository]; assertions use AssertK; the back event is + * observed with Turbine. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CharacterDetailViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeCharacterRepository() + + @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) { + repository.setDetails(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) { + repository.failWith = 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) { + repository.failWith = DataError.Network.NO_INTERNET + val viewModel = viewModel(characterId = 1) + advanceUntilIdle() + assertThat(viewModel.state.value.error).isNotNull() + + // The next attempt will succeed. + repository.failWith = null + repository.setDetails(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) { + repository.setDetails(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. + assertThrows { + CharacterDetailViewModel(SavedStateHandle(), repository) + } + } +} diff --git a/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt new file mode 100644 index 0000000..cecd9aa --- /dev/null +++ b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt @@ -0,0 +1,215 @@ +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.feature.characters.domain.usecase.GetCharactersPageUseCase +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. Collaborator is a [FakeCharacterRepository] (a fake, not a mock); `state`/`events` + * are observed with Turbine; assertions use AssertK. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CharacterListViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeCharacterRepository() + + @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) { + repository.setPage(page = 1, characters = 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) { + repository.failWith = 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) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + repository.setPage(page = 2, characters = 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) + + val callsBefore = repository.getCharactersCallCount + viewModel.onAction(CharacterListAction.OnLoadNextPage) + advanceUntilIdle() // guarded by endReached → no request + + assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore) + } + + @Test + fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3) + + val viewModel = viewModel() + advanceUntilIdle() // init → page 1 + val callsBefore = repository.getCharactersCallCount + + // 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() + + assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1) + 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) { + repository.setPage(page = 1, characters = 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. + assertThat(repository.getCharactersCallCount).isEqualTo(1) + } + + @Test + fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) { + repository.failWith = 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, OnRetry rebuilds from page 1. + repository.failWith = null + repository.setPage(page = 1, characters = 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) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + val viewModel = viewModel() + advanceUntilIdle() // page 1 loaded (no event) + + viewModel.events.test { + // Page 2 isn't configured yet → next-page load fails; list keeps page 1, shows an error. + 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). + repository.setPage(page = 2, characters = 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() + } + + @Test + fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + repository.setPage(page = 2, characters = 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) + } +} diff --git a/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt new file mode 100644 index 0000000..683a352 --- /dev/null +++ b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt @@ -0,0 +1,74 @@ +package com.example.architecture.feature.characters.presentation + +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.Character +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.domain.model.CharactersPage + +/** + * In-memory [CharacterRepository] for ViewModel tests — a **fake**, not a mock: it has real behaviour + * (returns configured pages/details, counts calls, can be flipped to fail) so tests assert against a + * working collaborator instead of recording interactions. Configure pages via [setPage]/[setDetails]; + * set [failWith] to make every call fail with a specific [DataError]. + */ +class FakeCharacterRepository : CharacterRepository { + + /** When non-null, every call fails with this error (overrides any configured data). */ + var failWith: DataError? = null + + var getCharactersCallCount = 0 + private set + var getCharacterDetailsCallCount = 0 + private set + + private val pages = mutableMapOf() + private val details = mutableMapOf() + + fun setPage(page: Int, characters: List, nextPage: Int?) { + pages[page] = CharactersPage(characters = characters, nextPage = nextPage) + } + + fun setDetails(value: CharacterDetails) { + details[value.id] = value + } + + override suspend fun getCharacters(page: Int): Result { + getCharactersCallCount++ + failWith?.let { return Result.Error(it) } + val pageData = pages[page] ?: return Result.Error(DataError.Network.NOT_FOUND) + return Result.Success(pageData) + } + + override suspend fun getCharacterDetails(id: Int): Result { + getCharacterDetailsCallCount++ + failWith?.let { return Result.Error(it) } + val value = details[id] ?: return Result.Error(DataError.Network.NOT_FOUND) + return Result.Success(value) + } +} + +/** Minimal list-item domain fixture. */ +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. */ +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, +) From d232757eb47614ae9d1d63f59419f2bc4aa1e66a Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:54 +0200 Subject: [PATCH 5/6] REDI-96: repository MockEngine test + Compose robot UI test + serialization fix NetworkCharacterRepositoryTest swaps a Ktor MockEngine into HttpClientFactory and covers success mapping (incl. request URL/page-param construction), 404 -> NOT_FOUND, 500 -> SERVER_ERROR, and malformed body -> SERIALIZATION. That last case exposed a real bug: Ktor wraps the kotlinx SerializationException in its own ContentConvertException, so safeCall mapped it to UNKNOWN; safeCall now scans the cause chain and maps it to SERIALIZATION. Adds an instrumented Compose UI test (CharacterListScreen) using the chaining CharacterListRobot: rendered items, empty/error states, and tap -> Action. --- .../core/data/network/HttpClientExt.kt | 12 +- .../data/NetworkCharacterRepositoryTest.kt | 162 ++++++++++++++++++ .../compose/CharacterListRobot.kt | 81 +++++++++ .../compose/CharacterListScreenTest.kt | 77 +++++++++ 4 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt create mode 100644 feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt create mode 100644 feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt index b14caa1..af4a3c8 100644 --- a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -75,8 +75,16 @@ suspend inline fun safeCall( Result.Error(DataError.Network.SERIALIZATION) } catch (e: Exception) { if (e is CancellationException) throw e - logNetworkError(e, "Unknown network failure") - Result.Error(DataError.Network.UNKNOWN) + // Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body) + // in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a + // bad payload still maps to SERIALIZATION instead of the generic UNKNOWN. + if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) { + logNetworkError(e, "Serialization failure (wrapped)") + Result.Error(DataError.Network.SERIALIZATION) + } else { + logNetworkError(e, "Unknown network failure") + Result.Error(DataError.Network.UNKNOWN) + } } } diff --git a/feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt b/feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt new file mode 100644 index 0000000..46de1f7 --- /dev/null +++ b/feature/characters/data/src/test/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepositoryTest.kt @@ -0,0 +1,162 @@ +package com.example.architecture.feature.characters.data + +import assertk.assertThat +import assertk.assertions.endsWith +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import com.example.architecture.core.data.network.HttpClientFactory +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real + * [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) — so the + * full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping → + * domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a + * malformed-body → SERIALIZATION case. + */ +class NetworkCharacterRepositoryTest { + + private fun repository( + handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData, + ): NetworkCharacterRepository { + val engine = MockEngine { request -> handler(request) } + val httpClient = HttpClientFactory.create(engine) + return NetworkCharacterRepository(KtorCharacterDataSource(httpClient)) + } + + private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json") + + @Test + fun `getCharacters maps a successful response to a domain page`() = runTest { + var requestedPath: String? = null + var requestedPage: String? = null + val repository = repository { request -> + requestedPath = request.url.encodedPath + requestedPage = request.url.parameters["page"] + respond(content = CHARACTERS_PAGE_JSON, status = HttpStatusCode.OK, headers = jsonHeaders()) + } + + val result = repository.getCharacters(page = 3) + + // Request construction: correct endpoint and the page forwarded as a query param. + assertThat(requestedPath).isNotNull().endsWith("/character") + assertThat(requestedPage).isEqualTo("3") + + assertThat(result).isInstanceOf(Result.Success::class) + val page = (result as Result.Success).data + assertThat(page.characters.size).isEqualTo(2) + assertThat(page.characters.first().name).isEqualTo("Rick Sanchez") + // `next` URL ".../character?page=2" is parsed to a page number. + assertThat(page.nextPage).isEqualTo(2) + } + + @Test + fun `getCharacters maps 404 to NOT_FOUND`() = runTest { + val repository = repository { + respond(content = "", status = HttpStatusCode.NotFound) + } + + val result = repository.getCharacters(page = 1) + + assertThat(result).isEqualTo(Result.Error(DataError.Network.NOT_FOUND)) + } + + @Test + fun `getCharacters maps 500 to SERVER_ERROR`() = runTest { + val repository = repository { + respond(content = "", status = HttpStatusCode.InternalServerError) + } + + val result = repository.getCharacters(page = 1) + + assertThat(result).isEqualTo(Result.Error(DataError.Network.SERVER_ERROR)) + } + + @Test + fun `getCharacters maps a malformed body to SERIALIZATION`() = runTest { + val repository = repository { + respond(content = "{ this is not valid json", status = HttpStatusCode.OK, headers = jsonHeaders()) + } + + val result = repository.getCharacters(page = 1) + + assertThat(result).isEqualTo(Result.Error(DataError.Network.SERIALIZATION)) + } + + @Test + fun `getCharacterDetails maps a successful response to domain details`() = runTest { + var requestedPath: String? = null + val repository = repository { request -> + requestedPath = request.url.encodedPath + respond(content = CHARACTER_JSON, status = HttpStatusCode.OK, headers = jsonHeaders()) + } + + val result = repository.getCharacterDetails(id = 1) + + // Request construction: the id is placed in the path. + assertThat(requestedPath).isNotNull().endsWith("/character/1") + + assertThat(result).isInstanceOf(Result.Success::class) + val details = (result as Result.Success).data + assertThat(details.name).isEqualTo("Rick Sanchez") + assertThat(details.origin).isEqualTo("Earth (C-137)") + assertThat(details.episodeCount).isEqualTo(3) + } + + private companion object { + val CHARACTER_JSON = """ + { + "id": 1, + "name": "Rick Sanchez", + "status": "Alive", + "species": "Human", + "type": "", + "gender": "Male", + "origin": { "name": "Earth (C-137)", "url": "" }, + "location": { "name": "Citadel of Ricks", "url": "" }, + "image": "https://example.com/1.png", + "episode": ["e1", "e2", "e3"] + } + """.trimIndent() + + val CHARACTERS_PAGE_JSON = """ + { + "info": { + "count": 2, + "pages": 1, + "next": "https://rickandmortyapi.com/api/character?page=2", + "prev": null + }, + "results": [ + { + "id": 1, "name": "Rick Sanchez", "status": "Alive", "species": "Human", + "type": "", "gender": "Male", + "origin": { "name": "Earth (C-137)", "url": "" }, + "location": { "name": "Citadel of Ricks", "url": "" }, + "image": "https://example.com/1.png", "episode": ["e1", "e2"] + }, + { + "id": 2, "name": "Morty Smith", "status": "Alive", "species": "Human", + "type": "", "gender": "Male", + "origin": { "name": "Earth (C-137)", "url": "" }, + "location": { "name": "Citadel of Ricks", "url": "" }, + "image": "https://example.com/2.png", "episode": ["e1"] + } + ] + } + """.trimIndent() + } +} diff --git a/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt new file mode 100644 index 0000000..a6e907c --- /dev/null +++ b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt @@ -0,0 +1,81 @@ +package com.example.architecture.feature.characters.presentation.compose + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.example.architecture.core.design.system.theme.AppTheme +import com.example.architecture.feature.characters.presentation.CharacterListAction +import com.example.architecture.feature.characters.presentation.CharacterListState +import org.junit.Assert.assertTrue + +/** + * Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent + * scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns + * the interaction vocabulary; the test owns the assertions' intent — keeping tests readable and + * resilient to UI structure changes. See android-testing. + */ +class CharacterListRobot( + private val composeRule: ComposeContentTestRule, + private val context: Context, +) { + private val recordedActions = mutableListOf() + + fun setContent(state: CharacterListState): CharacterListRobot { + composeRule.setContent { + AppTheme { + CharacterListScreen( + state = state, + onAction = { recordedActions += it }, + onOpenAbout = {}, + onOpenViewsList = {}, + onOpenErrorDemo = {}, + ) + } + } + return this + } + + fun assertCharacterShown(name: String): CharacterListRobot { + composeRule.onNodeWithText(name).assertIsDisplayed() + return this + } + + fun assertEmptyStateShown(): CharacterListRobot { + composeRule.onNodeWithText(context.getString(R.string.characters_empty)).assertIsDisplayed() + return this + } + + fun assertErrorShown(message: String): CharacterListRobot { + composeRule.onNodeWithText(message).assertIsDisplayed() + return this + } + + fun assertRetryShown(): CharacterListRobot { + composeRule.onNodeWithText(retryLabel).assertIsDisplayed() + return this + } + + fun clickCharacter(name: String): CharacterListRobot { + composeRule.onNodeWithText(name).performClick() + return this + } + + fun clickRetry(): CharacterListRobot { + composeRule.onNodeWithText(retryLabel).performClick() + return this + } + + fun assertActionRecorded(action: CharacterListAction): CharacterListRobot { + assertTrue( + "Expected $action to be recorded, but got $recordedActions", + recordedActions.contains(action), + ) + return this + } + + // The retry label lives in the design-system module; reference its R directly (non-transitive R). + private val retryLabel: String + get() = context.getString(com.example.architecture.core.design.system.R.string.designsystem_retry) +} diff --git a/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt new file mode 100644 index 0000000..426d28f --- /dev/null +++ b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreenTest.kt @@ -0,0 +1,77 @@ +package com.example.architecture.feature.characters.presentation.compose + +import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.architecture.core.presentation.UiText +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.presentation.CharacterListAction +import com.example.architecture.feature.characters.presentation.CharacterListState +import com.example.architecture.feature.characters.presentation.model.CharacterUi +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented Compose UI test for [CharacterListScreen] using [CharacterListRobot]. Runs on a + * device/emulator (`connectedDebugAndroidTest`); CI assembles it. Asserts rendered items, the + * empty + error states, and that user gestures fire the right MVI [CharacterListAction]s. + */ +@RunWith(AndroidJUnit4::class) +class CharacterListScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + private fun robot() = CharacterListRobot(composeRule, context) + + private val loadedState = CharacterListState( + characters = persistentListOf( + CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE), + CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE), + ), + ) + + @Test + fun rendersCharacterItems() { + robot() + .setContent(loadedState) + .assertCharacterShown("Rick Sanchez") + .assertCharacterShown("Morty Smith") + } + + @Test + fun showsEmptyState() { + robot() + .setContent(CharacterListState()) + .assertEmptyStateShown() + } + + @Test + fun showsErrorStateWithRetry() { + robot() + .setContent(CharacterListState(error = UiText.DynamicString("Boom"))) + .assertErrorShown("Boom") + .assertRetryShown() + } + + @Test + fun tappingAnItemFiresOnCharacterClick() { + robot() + .setContent(loadedState) + .clickCharacter("Rick Sanchez") + .assertActionRecorded(CharacterListAction.OnCharacterClick(1)) + } + + @Test + fun tappingRetryFiresOnRetry() { + robot() + .setContent(CharacterListState(error = UiText.DynamicString("Boom"))) + .clickRetry() + .assertActionRecorded(CharacterListAction.OnRetry) + } +} From 77105e943ebe66c747e1b348033b22e6f4ea9e9c Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 15:00:59 +0200 Subject: [PATCH 6/6] REDI-97: comprehensive architecture README Document module structure + dependency rules, the full data->UI flow (DTO -> mapper -> domain -> UseCase -> VM -> UiModel), MVI vs MVVM, one-ViewModel-two- renderers (Compose vs Views + interop + the Material3-XML-theme gotcha), Result/DataError/UiText with the error-demo walkthrough, navigation, Koin constructor DSL, the testing approach (incl. the JUnit5-on-AGP9 and Espresso notes), build/run via the android CLI, and the optional Room stretch. Each section cites its convention skill. --- README.md | 400 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 352 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index febd59f..fe2b166 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,388 @@ # Android Architecture Showcase -A single runnable **Android-only (Jetpack Compose)** reference app that demonstrates good -architecture conventions — each in its own module/example. Teaching repo: every module is meant to -be minimal but complete and idiomatic. +A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete, +idiomatic multi-module architecture — each convention shown in its own minimal-but-complete module. +It is a teaching repo: the goal is not features but *how the pieces fit together*. + +Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists +characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has +a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**. > **Status:** built milestone-by-milestone from the > [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c). -> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and -> **Breadth & Contrast** (character detail, the MVVM About screen, the Views renderer, and -> Compose↔View interop) are complete and the project assembles green. Full architecture docs land -> with the *Quality & Docs* milestone. +> Foundation, Core Infrastructure, the flagship MVI feature, Breadth & Contrast, and Quality & Docs +> are complete; the project assembles green and ships unit + UI tests. The only optional item left is +> the Room offline-cache stretch (see [Optional: Room stretch](#optional-room-stretch)). + +--- + +## Table of contents + +- [Stack](#stack) +- [Module structure & dependency rules](#module-structure--dependency-rules) +- [The data → UI flow](#the-data--ui-flow) +- [Presentation patterns: MVI vs MVVM](#presentation-patterns-mvi-vs-mvvm) +- [One ViewModel, two renderers (Compose vs Views)](#one-viewmodel-two-renderers-compose-vs-views) +- [Errors: `Result`, `DataError`, `UiText`](#errors-result-dataerror-uitext) +- [Navigation](#navigation) +- [Dependency injection (Koin)](#dependency-injection-koin) +- [Testing](#testing) +- [Build & run (`android` CLI)](#build--run-android-cli) +- [Optional: Room stretch](#optional-room-stretch) +- [Convention skills index](#convention-skills-index) + +--- ## Stack -Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor · -KotlinX Serialization · Coil · Timber · type-safe Compose Navigation. Data comes from the no-key -[Rick & Morty API](https://rickandmortyapi.com/). +| Concern | Choice | +|---|---| +| Build | Multi-module Gradle + `:build-logic` **convention plugins**; a single **version catalog** (`gradle/libs.versions.toml`) is the only place versions live | +| Toolchain | AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1, `compileSdk`/`targetSdk` 36, `minSdk` 24, Java 17 | +| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer | +| DI | Koin 4.1 (constructor DSL) | +| Networking | Ktor (OkHttp engine) + KotlinX Serialization | +| Images | Coil 3 | +| Navigation | type-safe Compose Navigation (`@Serializable` routes) | +| Logging | Timber | +| Async | Coroutines + Flow | +| Testing | JUnit 5, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test | -What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature), -an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers** — -Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is -UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below. +> **AGP 9 gotcha:** AGP 9.0 has **built-in Kotlin**. Applying `com.android.application`/`library` +> auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply +> `org.jetbrains.kotlin.android` themselves. Source lives in `src/main/kotlin`. -## Module structure +--- + +## Module structure & dependency rules + +Modularized **by feature first, then by layer** (Clean Architecture: `presentation → domain ← data`). +Features never depend on each other; anything shared moves to a `core` module; `:app` wires the graph. ``` -:app → wires everything; single Activity, Compose host -:build-logic → Gradle convention plugins (the only place versions/config live) -:core:domain → Result/error types, shared domain models (pure Kotlin) -:core:data → Ktor HttpClient factory, safe-call helpers -:core:presentation → UiText, ObserveAsEvents, DataError → UiText -:core:design-system → AppTheme + reusable composables -:feature:characters:domain → models + repository interface (pure Kotlin) -:feature:characters:data → DTOs, mappers, data source, repository impl -:feature:characters:presentation → MVI ViewModel/State/Action/Event (UI-agnostic: no Compose, no Views) -:feature:characters:presentation-compose → Compose renderer -:feature:characters:presentation-views → Views/XML renderer (same ViewModel) -:feature:about:presentation → MVVM contrast screen +:app → wires everything; single Activity, Compose host, Koin start +:build-logic → Gradle convention plugins (the only place build config lives) + +:core:domain → Result / Error / DataError, shared contracts (pure Kotlin) +:core:data → Ktor HttpClient factory + safe-call helpers (BuildConfig.BASE_URL) +:core:presentation → UiText, ObserveAsEvents, DataError → UiText +:core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …) + +:feature:characters:domain → models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin) +:feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository +:feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views) +:feature:characters:presentation-compose → Compose renderer (list, detail, error demo, nav graph) +:feature:characters:presentation-views → Views/XML renderer of the list (same ViewModel) + +:feature:about:presentation → MVVM contrast screen ``` -**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`; -features never depend on other features; `:app` wires the graph. +**Dependency rules** (enforced by what each convention plugin exposes): -## Presentation patterns (MVI vs MVVM) +| Layer | May depend on | +|---|---| +| `presentation` | own `domain`, `core:domain`, `core:presentation`, `core:design-system` | +| `data` | own `domain`, `core:domain`, `core:data` | +| `domain` | `core:domain` only — never `data` or `presentation` | +| `:app` | everything | -Both patterns live side by side so the trade-off is concrete, not theoretical. +A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the `compose` convention +uses `implementation` (not `api`), so the UI-agnostic `:feature:characters:presentation` never gets +Compose on its classpath — which is what lets two different renderers share one ViewModel. + +See **android-module-structure**. + +--- + +## The data → UI flow + +One request flows through every layer, each with one job: + +``` +Rick & Morty API + │ JSON + ▼ +CharacterDto / CharactersResponseDto (:data/dto) – serialization shape + │ CharacterMapper.toDomain() (:data/mappers) – DTO → domain, never the reverse leaks up + ▼ +Character / CharactersPage (:domain/model) – pure Kotlin domain model + │ CharacterRepository.getCharacters() (:domain contract, :data impl) + │ GetCharactersPageUseCase(page) (:domain/usecase) – domain operation (see note) + ▼ +CharacterListViewModel (:presentation) – holds State, processes Action, emits Event + │ Character.toCharacterUi() (:presentation/model)– domain → UI model (display shaping) + ▼ +CharacterUi in CharacterListState (:presentation) – immutable UI state + ▼ +CharacterListScreen / CharacterListFragment (:presentation-compose / -views) – dumb renderers +``` + +- **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities. + Mappers are pure extension functions in a `mappers/` package (`toDomain()`). See + **android-data-layer**, **android-data-layer-mappers**. +- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields + pre-formatted to an em dash). See **android-presentation-mvi**. + +### Note — when to add a UseCase + +`GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The +rule it illustrates: + +> Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** — real +> rules, or **composition of several repositories/sources** into one operation. When the ViewModel +> would merely forward a single repository call, injecting the repository directly is fine. + +Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly — both are +correct, and the contrast is the point. See **android-module-structure**, **android-di-koin**. + +--- + +## Presentation patterns: MVI vs MVVM + +Both patterns live side by side so the trade-off is concrete. | | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) | |---|---|---| | State | one immutable `State` data class | one immutable `State` data class | | User input | a single `onAction(Action)` funnel + sealed `Action` | plain public methods on the `ViewModel` | -| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none — the screen calls a method / uses `LocalUriHandler` directly | +| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none — the screen calls a method / uses `LocalUriHandler` | | Best when | state is complex and interacting; effects matter | the screen is small and mostly static | -The flagship characters list is MVI because its state is genuinely complex — pagination, loading -vs. next-page loading, error surfacing, and `SavedStateHandle` restore after process death — and it -emits navigation/snackbar effects. The About screen is deliberately MVVM: a `StateFlow` plus a couple -of public methods, with **no `Action` and no `Event` types at all**, because that ceremony would be -pure overhead for static content. Rule of thumb: **reach for MVI when state is complex and side -effects matter; reach for MVVM when the screen is simple.** +The flagship list is **MVI** because its state is genuinely complex — pagination, loading vs. +next-page loading, error surfacing, `SavedStateHandle` restore after process death — and it emits +navigation/snackbar effects. *About* is deliberately **MVVM**: a `StateFlow` plus a couple of public +methods, with **no `Action` and no `Event` types at all**, because that ceremony would be pure +overhead for static content. -### One ViewModel, two renderers +### Note — Events vs State -`:feature:characters:presentation` is **UI-toolkit-agnostic** — it has no Compose *and* no Views -dependency (state stays Compose-stable via `kotlinx-collections-immutable` rather than `@Stable`). -The exact same `CharacterListViewModel` (State/Action/Event/UI-model) is rendered twice: +State is what the screen **is** (re-rendered on every change, survives recomposition/rotation). +Events are things that happen **once** — navigate, show a snackbar. Modeling a one-time effect as +state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them +separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the +Views side with `repeatOnLifecycle`. + +### Note — when MVVM is acceptable + +Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen +is small, mostly static, and has no real side effects — the *About* screen is the canonical case. + +See **android-presentation-mvi**, **android-compose-ui**. + +--- + +## One ViewModel, two renderers (Compose vs Views) + +`:feature:characters:presentation` is **UI-toolkit-agnostic** — no Compose *and* no Views dependency. +State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the +`@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel` +(State/Action/Event/UI-model) is rendered twice: - `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`). -- `:feature:characters:presentation-views` — `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`. +- `:feature:characters:presentation-views` — `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`, + resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`. `:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View -interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other. +interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and +from navigation. -## Build & run +> **Material3-XML-theme gotcha:** the host Activity (`MainActivity`) extends **`FragmentActivity`** +> (so `AndroidFragment` has a `FragmentManager`) and uses a **Material Components XML theme**, which +> the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain +> `ComponentActivity` or a non-Material theme breaks the Fragment renderer. + +See **android-compose-ui**, **android-module-structure**. + +--- + +## Errors: `Result`, `DataError`, `UiText` + +Expected failures are **values, not exceptions**. The whole app speaks one typed result: + +```kotlin +sealed interface Result { Success(data) ; Error(error) } // :core:domain +sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERVER_ERROR, SERIALIZATION, … } ; enum Local { … } } +``` + +- The **data layer** catches transport/parse exceptions at the boundary (`safeCall` in `:core:data`) + and converts them to `Result.Error(DataError.Network.*)` — HTTP status → typed error, and a + malformed body → `SERIALIZATION` (the cause chain is unwrapped because Ktor wraps the kotlinx + `SerializationException`). Upper layers never see raw exceptions. +- The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()` + (`:core:presentation`). `UiText` is itself Compose-free (a `StringResource`/`DynamicString`), so a + UI-agnostic ViewModel can hold `UiText?` in state; the renderer resolves it (`asString()` in + Compose, `asString(context)` in Views). + +### The error-handling demo (overflow menu → "Error handling demo") + +A runnable walk-through of the whole pipeline. Pick a failure to force; the ViewModel produces the +real `DataError.Network`, routes it through the **same** steps a genuine call uses, and the shared +design-system `ErrorState` renders it: + +``` +[Force: No internet] → Result.Error(DataError.Network.NO_INTERNET) + → onFailure { … } + → DataError.toUiText() = UiText.StringResource(R.string.error_no_internet) + → ErrorState(message = uiText.asString(), onRetry = { onAction(OnRetry) }) +``` + +Three distinct cases (`NO_INTERNET`, `NOT_FOUND`, `SERVER_ERROR`) each render their mapped message; +**Retry** re-issues the last request as an Action; a successful load **clears** the error. The same +`ErrorState` + retry Action is what the real list and detail screens use. + +See **android-error-handling**, **android-presentation-mvi**. + +--- + +## Navigation + +Type-safe Compose Navigation with `@Serializable` route objects, one nav graph per feature, assembled +in `:app`. + +```kotlin +@Serializable data object CharacterListRoute +@Serializable data class CharacterDetailRoute(val characterId: Int) +@Serializable data object ErrorDemoRoute +``` + +- **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController` + passed into `charactersGraph(navController, …)`. +- **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda + callbacks** supplied by `:app` — a feature never imports another feature's route. +- **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId` + into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`. + `CharacterDetailViewModel` reads `savedStateHandle.get("characterId")` by field name — so the + UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle` + also persists the list's loaded page across process death. + +See **android-navigation**. + +--- + +## Dependency injection (Koin) + +One Koin module per feature layer (only if it has something to provide), all assembled in +`ArchitectureApp` — never inside feature modules. Prefer the **constructor DSL**: + +```kotlin +// :feature:characters:data +val charactersDataModule = module { + singleOf(::KtorCharacterDataSource) + singleOf(::NetworkCharacterRepository) { bind() } +} + +// :feature:characters:presentation +val charactersPresentationModule = module { + factoryOf(::GetCharactersPageUseCase) // stateless UseCase + viewModelOf(::CharacterListViewModel) // same VM used by both renderers + viewModelOf(::CharacterDetailViewModel) + viewModelOf(::ErrorDemoViewModel) +} +``` + +The lambda form (`single { … }`) appears only where a constructor reference can't express the binding +— e.g. `single { HttpClientFactory.create(OkHttp.create()) }` in `coreDataModule` (a factory call, +not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` — +both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`. + +See **android-di-koin**, **koin-constructor-dsl**. + +--- + +## Testing + +Tests prove the architecture, not just the code. Stack: **JUnit 5**, **Turbine** (Flow), **AssertK**, +`kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test. + +| What | Where | Kind | +|---|---|---| +| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 | +| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, fakes + Turbine + `SavedStateHandle` | +| `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` | +| `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI | + +Conventions demonstrated: + +- **Fakes, not mocks.** `FakeCharacterRepository` is a real in-memory implementation with a + `failWith` toggle and call counts — tests assert against working behaviour, not recorded calls. +- **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed + `SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path, + error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the + rapid-duplicate-paging guard (which is why these use `StandardTestDispatcher`). +- **Repository tested over a real Ktor client** with a swapped `MockEngine` + (`HttpClientFactory.create(engine)`): success mapping, `404 → NOT_FOUND`, `500 → SERVER_ERROR`, + malformed body `→ SERIALIZATION`. +- **Robot pattern** for the Compose UI test: `CharacterListRobot` methods `return this` so a test + reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the + right `Action`. + +> **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this +> repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test` +> convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle — including the +> `junit-platform-launcher`, which Gradle 9 no longer bundles. + +> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso +> 3.5.0 calls the removed `InputManager.getInstance()`. The catalog pins espresso/runner to current +> versions in the `compose-ui-test` bundle to fix that. + +What runs where: `./gradlew test` (all JVM unit tests) runs in **CI**; the instrumented Compose test +runs on a device/emulator via `./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest` +(CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is +possible but intentionally omitted (the VM logic is already covered by the shared unit tests). + +See **android-testing**. + +--- + +## Build & run (`android` CLI) ```bash -./gradlew assembleDebug # build the APK -./gradlew projects # print the module tree -./gradlew check # tests + lint (added in the Quality & Docs milestone) +# Build +./gradlew assembleDebug # build the debug APK +./gradlew projects # print the module tree +./gradlew test # all JVM unit tests (JUnit 5) +./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device) +``` + +Using the `android` CLI for an emulator + run: + +```bash +android emulator list # list AVDs +android emulator start # boot an emulator (returns when ready) +android run # build & deploy the app +android screenshot -o screen.png # capture the current screen +android layout --pretty # dump the UI tree (faster than a screenshot for debugging) +android docs search "" # search authoritative Android docs ``` Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK (`compileSdk 36`, `minSdk 24`). + +--- + +## Optional: Room stretch + +Out of core scope and **not implemented** (tracked as the optional REDI-99). It would add a `room` +convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO + +`@Database` (prefer `autoMigrations`), then convert the repository to **offline-first** +(`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the +DB, never the network response). The current `CharacterRepository` returning the `DataError` +supertype already anticipates a multi-source implementation. See **android-data-layer**. + +--- + +## Convention skills index + +This repo is a narrative index of these conventions: + +| Skill | Where it shows up | +|---|---| +| android-module-structure | module graph, dependency rules, convention plugins | +| android-presentation-mvi | characters list/detail/error-demo (State/Action/Event/VM) | +| android-compose-ui | Compose renderers, design-system, previews, stability | +| android-navigation | type-safe routes, per-feature graphs, callback decoupling | +| android-di-koin / koin-constructor-dsl | feature Koin modules, `*Of` constructor DSL | +| android-data-layer / android-data-layer-mappers | data sources, repository, DTOs, mappers | +| android-error-handling | `Result`/`DataError`/`UiText`, `safeCall`, the error demo | +| android-testing | unit tests, fakes, `MockEngine`, the robot UI test | +| android-cli | build/run/emulator steps above |