Compare commits

...

10 Commits

Author SHA1 Message Date
Adrian Kuta
04e1dc03e5 chore(deps): update all libraries and Gradle to latest stable versions
Bump the version catalog, Gradle wrapper, and convention plugins to the
latest stable releases. Verified with `./gradlew assembleDebug test`
(BUILD SUCCESSFUL, 21 unit tests pass).

Toolchain:
- Gradle 9.1.0 -> 9.5.1
- AGP 9.0.1 -> 9.2.1
- Kotlin 2.3.20 -> 2.4.0

Libraries:
- androidx-core 1.18.0 -> 1.19.0, appcompat 1.7.0 -> 1.7.1,
  fragment 1.8.5 -> 1.8.9, navigation 2.9.0 -> 2.9.8
- compose-bom 2026.03.01 -> 2026.05.01, material 1.12.0 -> 1.14.0
- coroutines 1.10.2 -> 1.11.0, serialization 1.8.1 -> 1.11.0,
  collections-immutable 0.3.8 -> 0.5.0
- koin 4.1.0 -> 4.2.1, ktor 3.1.3 -> 3.5.0, coil 3.1.0 -> 3.5.0
- JUnit 5.11.4 -> 6.1.0, turbine 1.2.0 -> 1.2.1, mockk 1.14.3 -> 1.14.11

Required side-effect:
- compileSdk 36 -> 37 (mandated by androidx.core 1.19.0); targetSdk
  left at 36.

Also refresh stale JUnit 5 / AGP 9.0 / compileSdk 36 references in the
README and convention-plugin docs.
2026-06-12 14:53:26 +02:00
Adrian Kuta
9ae6e5935a Migrate GetCharactersPageUseCaseTest to runTest and add kotlinx-coroutines-test dependency to domain module. 2026-06-11 10:47:08 +02:00
Adrian Kuta
f6f81991a8 Merge pull request #6 from AdrianKuta/feat/scrub-attribution
REDI-101: Remove AI/tooling attribution from docs & project
2026-06-10 16:59:47 +02:00
Adrian Kuta
8f79608f5d REDI-101: replace em/en dashes with hyphens in prose & comments
Em dashes are a common AI-writing tell; swap them (and en dashes) for plain
hyphens across the README and all KDoc/comment prose so the repo reads as
hand-authored. Byte-level replace of U+2014/U+2013 -> '-'; arrows and the
ellipsis are left untouched.

The two functional em dashes are intentionally kept: the `DASH = "—"`
blank-field UI placeholder in CharacterDetailUi and the preview sample that
mirrors it -- those are deliberate UX, not prose.
2026-06-10 16:54:02 +02:00
Adrian Kuta
2ae94e473d REDI-101: remove AI/tooling attribution from docs & source comments
Make the repo read as a hand-authored reference project by dropping
references to the authoring tooling — the internal convention "skills", the
Linear backlog, and the REDI issue ids — from the README and source comments.

- README: remove the "Convention skills index" section and its TOC entry,
  strip every `See **android-...**` / koin-constructor-dsl citation, drop the
  Linear backlog link, and reword the REDI-99 Room reference to "an optional
  stretch".
- Source comments: neutralize the four skill citations in HttpClientExt,
  CharactersPresentationModule, CharacterListRobot, ErrorDemoViewModel.
- Also correct the testing section that the MockK migration left stale: it
  described "Fakes, not mocks" / FakeCharacterRepository (now deleted) — now
  describes MockK, and adds MockK to the stack lists.

Verified: git grep finds no AI/Claude/Anthropic/Linear/skill references in any
tracked file; assembleDebug + assembleDebugAndroidTest green.
2026-06-10 16:19:36 +02:00
Adrian Kuta
e44dd9896f Merge pull request #5 from AdrianKuta/feat/mockk-tests
REDI-100: Adopt MockK and rewrite tests to use it
2026-06-10 16:11:56 +02:00
Adrian Kuta
1cbf00c02c REDI-100: adopt MockK and rewrite unit tests to use it
Replace the hand-written CharacterRepository fakes in the ViewModel and
UseCase unit tests with MockK mocks (coEvery / coVerify). This is a
deliberate showcase of MockK and intentionally diverges from the repo's
"prefer fakes over mocks" guidance.

- Add io.mockk:mockk 1.14.3 to the version catalog and the unit-test bundle;
  add it explicitly to DomainModuleConventionPlugin (domain does not consume
  the bundle).
- CharacterListViewModelTest: strict mockk, per-page coEvery stubs; the
  paging/in-flight guards are expressed via coVerify(exactly = ...) and
  coVerifyOrder instead of fake call counters.
- CharacterDetailViewModelTest: relaxed mockk so "missing id" needs no
  stubbing; explicit coEvery elsewhere.
- GetCharactersPageUseCaseTest: mockk + coVerify replaces the inline fake.
- Move character()/characterDetails() fixtures to CharacterFixtures.kt and
  delete FakeCharacterRepository.kt.
- NetworkCharacterRepositoryTest stays on Ktor MockEngine (MockK is for
  Kotlin collaborator interfaces, not the HTTP transport).
2026-06-10 15:53:31 +02:00
Adrian Kuta
06de5f37d5 Merge pull request #4 from AdrianKuta/feat/quality-docs
Quality & Docs (REDI-94…98): UseCase, tests, error demo, architecture README
2026-06-10 15:13:44 +02:00
Adrian Kuta
77105e943e 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.
2026-06-10 15:00:59 +02:00
Adrian Kuta
d232757eb4 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.
2026-06-10 15:00:54 +02:00
42 changed files with 840 additions and 263 deletions

2
.gitignore vendored
View File

@@ -16,5 +16,5 @@
# Local config / secrets
local.properties
# IDE (JetBrains / Android Studio) fully ignored to avoid machine-specific churn
# IDE (JetBrains / Android Studio) - fully ignored to avoid machine-specific churn
/.idea/

373
README.md
View File

@@ -1,84 +1,353 @@
# 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*.
> **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.
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. 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)
---
## 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.2.1, Kotlin 2.4.0, Gradle 9.5.1, `compileSdk` 37 / `targetSdk` 36, `minSdk` 24, Java 17 |
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
| DI | Koin 4.2 (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 6, MockK, 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.2 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:dataKtor HttpClient factory, safe-call helpers
:core:presentationUiText, 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.
---
## 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()`).
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
pre-formatted to an em dash).
### 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.
---
## 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`.
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
- `:feature:characters:presentation-views``Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`.
### 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.
---
## 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`,
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.
---
## Errors: `Result`, `DataError`, `UiText`
Expected failures are **values, not exceptions**. The whole app speaks one typed result:
```kotlin
sealed interface Result<out D, out E : Error> { 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.
---
## 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<Int>("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.
---
## 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<CharacterRepository>() }
}
// :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`.
---
## Testing
Tests prove the architecture, not just the code. Stack: **JUnit 6**, **MockK**, **Turbine** (Flow),
**AssertK**, `kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test.
| What | Where | Kind |
|---|---|---|
| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 6 |
| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, MockK + 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:
- **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface
with MockK - `coEvery` scripts the suspend calls, `coVerify` asserts the paging/retry interactions.
- **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 6 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).
---
## 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 6)
./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 <avd-name> # 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 "<topic>" # search authoritative Android docs
```
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
(`compileSdk 36`, `minSdk 24`).
(`compileSdk 37`, `minSdk 24`).
---
## Optional: Room stretch
Out of core scope and **not implemented** (an optional stretch). 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.

View File

@@ -35,9 +35,9 @@ dependencies {
implementation(libs.androidx.navigation.compose)
// Compose↔View interop: hosts a Fragment inside the Compose NavHost.
implementation(libs.androidx.fragment.compose)
// Material Components required for the Material3 XML Activity theme.
// Material Components - required for the Material3 XML Activity theme.
implementation(libs.material)
// Logging the DebugTree is planted here; other modules log via Timber's static API.
// Logging - the DebugTree is planted here; other modules log via Timber's static API.
implementation(libs.timber)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View File

@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
/**
* Route for the characters list rendered with the classic **Views** toolkit. It lives in `:app`
* because `:app` owns Compose↔View interop the `:feature:characters:presentation-views` module
* because `:app` owns Compose↔View interop - the `:feature:characters:presentation-views` module
* stays navigation-agnostic (it knows nothing about Compose Navigation or this route).
*/
@Serializable

View File

@@ -7,11 +7,11 @@ 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
* Runs an Android library module's local unit tests (`src/test`) on the **JUnit 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
* targets AGP 8.x and we build on AGP 9.2. 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).
@@ -21,7 +21,7 @@ class AndroidUnitTestConventionPlugin : Plugin<Project> {
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.
// Gradle 9 dropped the bundled launcher; the JUnit Platform won't start without it.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}

View File

@@ -27,7 +27,7 @@ class ComposeConventionPlugin : Plugin<Project> {
dependencies {
// `implementation` (not api): every Compose consumer applies this convention itself, so
// Compose must NOT leak transitively that keeps the UI-agnostic presentation module
// Compose must NOT leak transitively - that keeps the UI-agnostic presentation module
// (which depends on core:presentation) free of Compose.
val bom = platform(libs.findLibrary("androidx-compose-bom").get())
add("implementation", bom)

View File

@@ -8,7 +8,7 @@ import org.gradle.kotlin.dsl.withType
/**
* Pure-Kotlin (JVM) module for the domain layer: no Android dependencies. Adds Coroutines (for
* `Flow`-returning repository interfaces) and runs unit tests on the JUnit 5 platform.
* `Flow`-returning repository interfaces) and runs unit tests on the JUnit Platform.
*/
class DomainModuleConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
@@ -20,8 +20,12 @@ class DomainModuleConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("kotlinx-coroutines-core").get())
add("testImplementation", libs.findLibrary("junit-jupiter-api").get())
add("testImplementation", libs.findLibrary("assertk").get())
// Domain doesn't consume the `unit-test` bundle, so MockK and the coroutines
// test artifact are added explicitly here.
add("testImplementation", libs.findLibrary("mockk").get())
add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get())
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
// Gradle 9 dropped the bundled launcher; the JUnit Platform won't start without it.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}

View File

@@ -7,7 +7,7 @@ import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
internal const val COMPILE_SDK = 36
internal const val COMPILE_SDK = 37
internal const val MIN_SDK = 24
internal const val TARGET_SDK = 36
internal const val JVM_TARGET = 17

View File

@@ -6,7 +6,7 @@ import io.ktor.client.engine.okhttp.OkHttp
import org.koin.dsl.module
/**
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding -
* HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the
* constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
*/

View File

@@ -75,8 +75,16 @@ suspend inline fun <reified T> 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)
}
}
}
@@ -90,7 +98,7 @@ internal fun logNetworkError(throwable: Throwable, message: String) {
Timber.tag("HttpClient").e(throwable, message)
}
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */
/** Maps HTTP status codes to typed [DataError.Network] (covering 400/403/404 as well). */
suspend inline fun <reified T> responseToResult(
response: HttpResponse,
): Result<T, DataError.Network> {

View File

@@ -4,7 +4,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
// Brand palette seeded from the Android green used by the project.
// Brand palette - seeded from the Android green used by the project.
private val Green10 = Color(0xFF00210B)
private val Green20 = Color(0xFF003918)
private val Green40 = Color(0xFF1E6C36)

View File

@@ -13,7 +13,7 @@ sealed interface Result<out D, out E : Error> {
) : Result<Nothing, E>
}
/** A [Result] that carries no success payload for operations that either succeed or fail. */
/** A [Result] that carries no success payload - for operations that either succeed or fail. */
typealias EmptyResult<E> = Result<Unit, E>
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> {

View File

@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
data object AboutRoute
/**
* The About feature nav graph. It only needs a "go back" callback `:app` wires it to the shared
* The About feature nav graph. It only needs a "go back" callback - `:app` wires it to the shared
* NavController, keeping this feature decoupled from how it is reached.
*/
fun NavGraphBuilder.aboutGraph(

View File

@@ -30,7 +30,7 @@ import com.example.architecture.feature.about.presentation.model.AboutLink
/**
* Root for the MVVM About screen. Note how different the wiring is from an MVI Root: it collects
* [AboutState] and passes the ViewModel's **method reference** straight through there is no
* [AboutState] and passes the ViewModel's **method reference** straight through - there is no
* `onAction` funnel and no event observation, because this screen has neither.
*/
@Composable
@@ -88,7 +88,7 @@ fun AboutScreen(
Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium)
}
// The expandable card is driven entirely by the VM's plain method the MVVM contrast.
// The expandable card is driven entirely by the VM's plain method - the MVVM contrast.
AppCard(
onClick = onToggleMvvmNote,
header = {

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* **MVVM the deliberate contrast to the app's MVI screens.**
* **MVVM - the deliberate contrast to the app's MVI screens.**
*
* There is no `Action` sealed type and no `Event`/effect `Channel`. The screen reads [state] and
* invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
@@ -37,7 +37,7 @@ class AboutViewModel : ViewModel() {
),
mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " +
"and emits one-time effects (navigation, snackbars) through an Event channel. That " +
"structure pays off when state is complex and interacting like the paginated, " +
"structure pays off when state is complex and interacting - like the paginated, " +
"process-death-restorable characters list. This screen is intentionally MVVM instead: " +
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " +
"no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " +
@@ -56,7 +56,7 @@ class AboutViewModel : ViewModel() {
)
val state: StateFlow<AboutState> = _state.asStateFlow()
/** MVVM: a plain public method mutates state directly no Action object, no reducer funnel. */
/** MVVM: a plain public method mutates state directly - no Action object, no reducer funnel. */
fun onToggleMvvmNote() {
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
}

View File

@@ -8,7 +8,7 @@ import com.example.architecture.feature.characters.data.dto.CharactersResponseDt
import io.ktor.client.HttpClient
/**
* Remote data source for characters. Returns raw DTOs (no mapping here the repository maps via
* Remote data source for characters. Returns raw DTOs (no mapping here - the repository maps via
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
*/
internal class KtorCharacterDataSource(

View File

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

View File

@@ -9,14 +9,14 @@ 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
* 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
* 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(

View File

@@ -7,23 +7,29 @@ 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 io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
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.
* the repository and return its result verbatim - success and error alike. Pure JVM test on the
* JUnit Platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
*/
class GetCharactersPageUseCaseTest {
private val repository = mockk<CharacterRepository>()
private val useCase = GetCharactersPageUseCase(repository)
@Test
fun `returns the repository page on success`() = runBlocking {
fun `returns the repository page on success`() = runTest {
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page)))
coEvery { repository.getCharacters(1) } returns Result.Success(page)
val result = useCase(page = 1)
@@ -31,10 +37,8 @@ class GetCharactersPageUseCaseTest {
}
@Test
fun `propagates the repository error`() = runBlocking {
val useCase = GetCharactersPageUseCase(
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
)
fun `propagates the repository error`() = runTest {
coEvery { repository.getCharacters(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
val result = useCase(page = 1)
@@ -43,30 +47,13 @@ class GetCharactersPageUseCaseTest {
}
@Test
fun `forwards the requested page number`() = runBlocking {
val fake = FakeCharacterRepository(
pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)),
)
val useCase = GetCharactersPageUseCase(fake)
fun `forwards the requested page number`() = runTest {
coEvery { repository.getCharacters(any()) } returns
Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
useCase(page = 7)
assertThat(fake.lastRequestedPage).isEqualTo(7)
}
private class FakeCharacterRepository(
private val pageResult: Result<CharactersPage, DataError>,
) : CharacterRepository {
var lastRequestedPage: Int? = null
private set
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> {
lastRequestedPage = page
return pageResult
}
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
Result.Error(DataError.Network.NOT_FOUND)
coVerify(exactly = 1) { repository.getCharacters(7) }
}
private fun domainCharacter(id: Int) = Character(

View File

@@ -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.
*/
class CharacterListRobot(
private val composeRule: ComposeContentTestRule,
private val context: Context,
) {
private val recordedActions = mutableListOf<CharacterListAction>()
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)
}

View File

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

View File

@@ -68,7 +68,7 @@ fun CharacterDetailRoot(
CharacterDetailScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen previewable without a ViewModel. */
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterDetailScreen(

View File

@@ -101,7 +101,7 @@ fun CharacterListRoot(
)
}
/** Pure, stateless screen previewable without a ViewModel. */
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterListScreen(

View File

@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
@Serializable
data object CharacterListRoute
/** Type-safe route for the character detail screen carries only the typed id, never an object. */
/** Type-safe route for the character detail screen - carries only the typed id, never an object. */
@Serializable
data class CharacterDetailRoute(val characterId: Int)
@@ -39,7 +39,7 @@ fun NavGraphBuilder.charactersGraph(
}
composable<CharacterDetailRoute> {
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments,
// which Navigation copies into the ViewModel's SavedStateHandle that is where
// which Navigation copies into the ViewModel's SavedStateHandle - that is where
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
}

View File

@@ -57,7 +57,7 @@ fun ErrorDemoRoot(
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen previewable without a ViewModel. */
/** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDemoScreen(

View File

@@ -23,7 +23,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as
* the Compose screen proving the presentation logic (State/Action/Event/UI-model) is truly
* the Compose screen - proving the presentation logic (State/Action/Event/UI-model) is truly
* UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
*
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches

View File

@@ -6,7 +6,7 @@ import com.example.architecture.feature.characters.domain.model.CharacterStatus
/**
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose
* renderer's helpers but return platform types (a string-res id and an ARGB Int) each renderer
* renderer's helpers but return platform types (a string-res id and an ARGB Int) - each renderer
* owns its own resources, so the small label duplication across modules is expected.
*/
@StringRes

View File

@@ -18,7 +18,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.android)
// Stable collection for state makes the list Compose-stable WITHOUT a Compose dependency,
// Stable collection for state - makes the list Compose-stable WITHOUT a Compose dependency,
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
api(libs.kotlinx.collections.immutable)

View File

@@ -19,7 +19,7 @@ import kotlinx.coroutines.launch
* UI-agnostic MVI ViewModel for the character detail screen.
*
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
* field name. Reading that raw key instead of `savedStateHandle.toRoute<CharacterDetailRoute>()`
* field name. Reading that raw key - instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` -
* is deliberate: it keeps this module free of any navigation/Compose dependency (the route type
* lives in the renderer). The renderer is what reads the route via `toRoute()`.
*/

View File

@@ -8,7 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
/**
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
* Compose already treats as stable so this module needs no Compose dependency. Navigation and
* Compose already treats as stable - so this module needs no Compose dependency. Navigation and
* snackbars are one-time Events, never state.
*/
data class CharacterListState(

View File

@@ -77,7 +77,7 @@ class CharacterListViewModel(
page++
}
// Always surface a failure even a partial one where earlier pages loaded.
// Always surface a failure - even a partial one where earlier pages loaded.
if (error != null) {
_events.send(CharacterListEvent.ShowSnackbar(error))
}
@@ -119,7 +119,7 @@ class CharacterListViewModel(
private fun loadPage(page: Int) {
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
// guarded out before its coroutine starts otherwise the same page loads twice and items
// guarded out before its coroutine starts - otherwise the same page loads twice and items
// get appended twice.
_state.update { it.copy(isLoadingNextPage = true, error = null) }
viewModelScope.launch {

View File

@@ -4,7 +4,7 @@ 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. */
/** Force a load that succeeds - clears any current error. */
data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */

View File

@@ -4,8 +4,8 @@ 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.
* [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,

View File

@@ -16,7 +16,7 @@ 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
* 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:
*
@@ -24,10 +24,10 @@ import kotlinx.coroutines.launch
* 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
* 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.
* success).
*/
class ErrorDemoViewModel : ViewModel() {

View File

@@ -10,8 +10,8 @@ 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.
// Stateless domain UseCase - `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
// its CharacterRepository from charactersDataModule.
factoryOf(::GetCharactersPageUseCase)
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)

View File

@@ -3,7 +3,7 @@ package com.example.architecture.feature.characters.presentation.model
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Presentation model for a character list item decouples the UI from the domain [Character]. */
/** Presentation model for a character list item - decouples the UI from the domain [Character]. */
data class CharacterUi(
val id: Int,
val name: String,

View File

@@ -9,6 +9,10 @@ import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -23,15 +27,16 @@ 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.
* by type-safe navigation), which is constructed directly here - proving the VM needs no navigation
* dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
* assertions use AssertK; the back event is observed with Turbine.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository()
private val repository = mockk<CharacterRepository>(relaxed = true)
@BeforeEach
fun setUp() {
@@ -48,7 +53,7 @@ class CharacterDetailViewModelTest {
@Test
fun `loads details on init`() = runTest(dispatcher.scheduler) {
repository.setDetails(characterDetails(1))
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
@@ -62,7 +67,7 @@ class CharacterDetailViewModelTest {
@Test
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.SERVER_ERROR
coEvery { repository.getCharacterDetails(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
@@ -75,14 +80,13 @@ class CharacterDetailViewModelTest {
@Test
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
coEvery { repository.getCharacterDetails(1) } returns Result.Error(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))
// Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle()
@@ -93,7 +97,7 @@ class CharacterDetailViewModelTest {
@Test
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
repository.setDetails(characterDetails(1))
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
val viewModel = viewModel(characterId = 1)
advanceUntilIdle()
@@ -108,6 +112,7 @@ class CharacterDetailViewModelTest {
@Test
fun `missing character id fails fast`() {
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
// The constructor throws before the (relaxed) repository is ever touched.
assertThrows<IllegalStateException> {
CharacterDetailViewModel(SavedStateHandle(), repository)
}

View File

@@ -0,0 +1,28 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Minimal list-item domain fixture for presentation tests. */
fun character(id: Int): Character = Character(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
imageUrl = "https://example.com/$id.png",
)
/** Minimal detail domain fixture for presentation tests. */
fun characterDetails(id: Int): CharacterDetails = CharacterDetails(
id = id,
name = "Character $id",
status = CharacterStatus.ALIVE,
species = "Human",
type = "Genetic experiment",
gender = "Male",
origin = "Earth (C-137)",
location = "Citadel of Ricks",
imageUrl = "https://example.com/$id.png",
episodeCount = 10,
)

View File

@@ -13,7 +13,14 @@ import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.domain.model.CharactersPage
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -26,19 +33,20 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Unit tests for [CharacterListViewModel] driven entirely through its public MVI surface
* 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.
* coroutine runs. The collaborator is a MockK mock of [CharacterRepository] (the real
* [GetCharactersPageUseCase] wraps it), stubbed per page with `coEvery` and verified with `coVerify`;
* `state`/`events` are observed with Turbine; assertions use AssertK.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CharacterListViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository()
private val repository = mockk<CharacterRepository>()
@BeforeEach
fun setUp() {
@@ -55,7 +63,8 @@ class CharacterListViewModelTest {
@Test
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
val viewModel = viewModel()
@@ -78,7 +87,7 @@ class CharacterListViewModelTest {
@Test
fun `initial load failure emits a snackbar event and a full-screen error`() =
runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
val viewModel = viewModel()
@@ -95,8 +104,10 @@ class CharacterListViewModelTest {
@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
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = null)) // last page
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
@@ -107,21 +118,24 @@ class CharacterListViewModelTest {
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)
// Page 2 was fetched exactly once and no page 3 was ever requested (a page-3 fetch would also
// blow up the strict mock as an unstubbed call).
coVerify(exactly = 1) { repository.getCharacters(2) }
coVerify(exactly = 0) { repository.getCharacters(3) }
}
@Test
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
val viewModel = viewModel()
advanceUntilIdle() // init → page 1
val callsBefore = repository.getCharactersCallCount
// Both fire before any launched coroutine runs; the second sees the synchronously-set
// isLoadingNextPage flag and is guarded out.
@@ -129,14 +143,15 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1)
coVerify(exactly = 1) { repository.getCharacters(2) }
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
}
@Test
fun `ignores a next-page request while the initial load is in flight`() =
runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
val viewModel = viewModel()
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
@@ -144,13 +159,14 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
// Only the single initial load ran the guarded next-page request never fired.
assertThat(repository.getCharactersCallCount).isEqualTo(1)
// Only the single initial load ran - the guarded next-page request never fired.
coVerify(exactly = 1) { repository.getCharacters(1) }
coVerify(exactly = 0) { repository.getCharacters(2) }
}
@Test
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET
coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
val viewModel = viewModel()
viewModel.events.test {
@@ -160,9 +176,10 @@ class CharacterListViewModelTest {
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)
// Empty branch of retry(): the repository recovers (the later, more specific stub for
// page 1 wins over the `any()` failure), OnRetry rebuilds from page 1.
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1), character(2)), nextPage = 2))
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
@@ -174,12 +191,14 @@ class CharacterListViewModelTest {
@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)
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
val viewModel = viewModel()
advanceUntilIdle() // page 1 loaded (no event)
viewModel.events.test {
// Page 2 isn't configured yet → next-page load fails; list keeps page 1, shows an error.
// Page 2 fails on the first attempt → list keeps page 1, shows an error.
coEvery { repository.getCharacters(2) } returns Result.Error(DataError.Network.NOT_FOUND)
viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
@@ -187,7 +206,8 @@ class CharacterListViewModelTest {
// 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)
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = null))
viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
@@ -196,12 +216,16 @@ class CharacterListViewModelTest {
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
// Page 2 was requested twice: the failed first load and the successful retry.
coVerify(exactly = 2) { repository.getCharacters(2) }
}
@Test
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
coEvery { repository.getCharacters(1) } returns
Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
// Navigation/SavedStateHandle persisted the last loaded page across process death.
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
@@ -211,5 +235,9 @@ class CharacterListViewModelTest {
// Both pages are rebuilt (1 then 2), and currentPage is restored.
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
coVerifyOrder {
repository.getCharacters(1)
repository.getCharacters(2)
}
}
}

View File

@@ -1,74 +0,0 @@
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<Int, CharactersPage>()
private val details = mutableMapOf<Int, CharacterDetails>()
fun setPage(page: Int, characters: List<Character>, nextPage: Int?) {
pages[page] = CharactersPage(characters = characters, nextPage = nextPage)
}
fun setDetails(value: CharacterDetails) {
details[value.id] = value
}
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> {
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<CharacterDetails, DataError> {
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,
)

View File

@@ -1,45 +1,46 @@
[versions]
# Build / language
agp = "9.0.1"
kotlin = "2.3.20"
agp = "9.2.1"
kotlin = "2.4.0"
# AndroidX core / lifecycle / activity / views
androidxCore = "1.18.0"
# AndroidX - core / lifecycle / activity / views
androidxCore = "1.19.0"
androidxLifecycle = "2.10.0"
androidxActivity = "1.13.0"
androidxAppcompat = "1.7.0"
androidxFragment = "1.8.5"
androidxAppcompat = "1.7.1"
androidxFragment = "1.8.9"
androidxRecyclerview = "1.4.0"
androidxNavigation = "2.9.0"
androidxNavigation = "2.9.8"
# Compose (BOM-managed)
composeBom = "2026.03.01"
composeBom = "2026.05.01"
# Async / serialization
coroutines = "1.10.2"
kotlinxSerialization = "1.8.1"
kotlinxCollectionsImmutable = "0.3.8"
coroutines = "1.11.0"
kotlinxSerialization = "1.11.0"
kotlinxCollectionsImmutable = "0.5.0"
# DI
koin = "4.1.0"
koin = "4.2.1"
# Networking
ktor = "3.1.3"
ktor = "3.5.0"
# Image loading
coil = "3.1.0"
coil = "3.5.0"
# Logging
timber = "5.0.1"
# Material Components (Views renderer)
material = "1.12.0"
material = "1.14.0"
# Testing
junitJupiter = "5.11.4"
junitPlatform = "1.11.4"
turbine = "1.2.0"
junitJupiter = "6.1.0"
junitPlatform = "6.1.0"
turbine = "1.2.1"
assertk = "0.28.1"
mockk = "1.14.11"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxEspresso = "3.7.0"
@@ -115,10 +116,11 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
# --- Testing ---
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" }
# Gradle 9 no longer bundles the launcher it must be on the test runtime classpath explicitly.
# 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" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
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" }
@@ -142,7 +144,7 @@ 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"]
unit-test = ["junit-jupiter-api", "kotlinx-coroutines-test", "turbine", "assertk", "mockk"]
# 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+),

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME