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 config / secrets
local.properties 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/ /.idea/

367
README.md
View File

@@ -1,84 +1,353 @@
# Android Architecture Showcase # Android Architecture Showcase
A single runnable **Android-only (Jetpack Compose)** reference app that demonstrates good A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete,
architecture conventions — each in its own module/example. Teaching repo: every module is meant to idiomatic multi-module architecture - each convention shown in its own minimal-but-complete module.
be minimal but complete and idiomatic. It is a teaching repo: the goal is not features but *how the pieces fit together*.
> **Status:** built milestone-by-milestone from the Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c). characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has
> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**.
> **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 > **Status:** built milestone-by-milestone. Foundation, Core Infrastructure, the flagship MVI
> with the *Quality & Docs* milestone. > 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 ## Stack
Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor · | Concern | Choice |
KotlinX Serialization · Coil · Timber · type-safe Compose Navigation. Data comes from the no-key |---|---|
[Rick & Morty API](https://rickandmortyapi.com/). | 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), > **AGP 9 gotcha:** AGP 9.2 has **built-in Kotlin**. Applying `com.android.application`/`library`
an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers** > auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply
Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is > `org.jetbrains.kotlin.android` themselves. Source lives in `src/main/kotlin`.
UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below.
## 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 :app → wires everything; single Activity, Compose host, Koin start
:build-logic → Gradle convention plugins (the only place versions/config live) :build-logic → Gradle convention plugins (the only place build config lives)
:core:domain → Result/error types, shared domain models (pure Kotlin)
:core:dataKtor HttpClient factory, safe-call helpers :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:presentation → UiText, ObserveAsEvents, DataError → UiText
:core:design-system → AppTheme + reusable composables :core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …)
:feature:characters:domain → models + repository interface (pure Kotlin)
:feature:characters:dataDTOs, mappers, data source, repository impl :feature:characters:domain models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin)
:feature:characters:presentation → MVI ViewModel/State/Action/Event (UI-agnostic: no Compose, no Views) :feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository
:feature:characters:presentation-compose → Compose renderer :feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views)
:feature:characters:presentation-views → Views/XML renderer (same ViewModel) :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 :feature:about:presentation → MVVM contrast screen
``` ```
**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`; **Dependency rules** (enforced by what each convention plugin exposes):
features never depend on other features; `:app` wires the graph.
## 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`) | | | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) |
|---|---|---| |---|---|---|
| State | one immutable `State` data class | one immutable `State` data class | | 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` | | 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 | | 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 The flagship list is **MVI** because its state is genuinely complex - pagination, loading vs.
vs. next-page loading, error surfacing, and `SavedStateHandle` restore after process death and it next-page loading, error surfacing, `SavedStateHandle` restore after process death - and it emits
emits navigation/snackbar effects. The About screen is deliberately MVVM: a `StateFlow` plus a couple navigation/snackbar effects. *About* is deliberately **MVVM**: a `StateFlow` plus a couple of public
of public methods, with **no `Action` and no `Event` types at all**, because that ceremony would be methods, with **no `Action` and no `Event` types at all**, because that ceremony would be pure
pure overhead for static content. Rule of thumb: **reach for MVI when state is complex and side overhead for static content.
effects matter; reach for MVVM when the screen is simple.**
### One ViewModel, two renderers ### Note - Events vs State
`:feature:characters:presentation` is **UI-toolkit-agnostic** — it has no Compose *and* no Views State is what the screen **is** (re-rendered on every change, survives recomposition/rotation).
dependency (state stays Compose-stable via `kotlinx-collections-immutable` rather than `@Stable`). Events are things that happen **once** - navigate, show a snackbar. Modeling a one-time effect as
The exact same `CharacterListViewModel` (State/Action/Event/UI-model) is rendered twice: 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`). ### Note - when MVVM is acceptable
- `:feature:characters:presentation-views``Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`.
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 `: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 ```bash
./gradlew assembleDebug # build the APK # Build
./gradlew assembleDebug # build the debug APK
./gradlew projects # print the module tree ./gradlew projects # print the module tree
./gradlew check # tests + lint (added in the Quality & Docs milestone) ./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 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) implementation(libs.androidx.navigation.compose)
// Compose↔View interop: hosts a Fragment inside the Compose NavHost. // Compose↔View interop: hosts a Fragment inside the Compose NavHost.
implementation(libs.androidx.fragment.compose) 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) 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) implementation(libs.timber)
androidTestImplementation(libs.androidx.compose.ui.test.junit4) 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` * 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). * stays navigation-agnostic (it knows nothing about Compose Navigation or this route).
*/ */
@Serializable @Serializable

View File

@@ -7,11 +7,11 @@ import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType 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). * 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 * 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 * `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 * `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the
* same for pure-JVM modules). * same for pure-JVM modules).
@@ -21,7 +21,7 @@ class AndroidUnitTestConventionPlugin : Plugin<Project> {
dependencies { dependencies {
add("testImplementation", libs.findBundle("unit-test").get()) add("testImplementation", libs.findBundle("unit-test").get())
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").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()) add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
} }

View File

@@ -27,7 +27,7 @@ class ComposeConventionPlugin : Plugin<Project> {
dependencies { dependencies {
// `implementation` (not api): every Compose consumer applies this convention itself, so // `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. // (which depends on core:presentation) free of Compose.
val bom = platform(libs.findLibrary("androidx-compose-bom").get()) val bom = platform(libs.findLibrary("androidx-compose-bom").get())
add("implementation", bom) 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 * 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> { class DomainModuleConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) { override fun apply(target: Project) = with(target) {
@@ -20,8 +20,12 @@ class DomainModuleConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("kotlinx-coroutines-core").get()) add("implementation", libs.findLibrary("kotlinx-coroutines-core").get())
add("testImplementation", libs.findLibrary("junit-jupiter-api").get()) add("testImplementation", libs.findLibrary("junit-jupiter-api").get())
add("testImplementation", libs.findLibrary("assertk").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()) 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()) 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.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension 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 MIN_SDK = 24
internal const val TARGET_SDK = 36 internal const val TARGET_SDK = 36
internal const val JVM_TARGET = 17 internal const val JVM_TARGET = 17

View File

@@ -6,7 +6,7 @@ import io.ktor.client.engine.okhttp.OkHttp
import org.koin.dsl.module 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 * 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. * constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
*/ */

View File

@@ -75,10 +75,18 @@ suspend inline fun <reified T> safeCall(
Result.Error(DataError.Network.SERIALIZATION) Result.Error(DataError.Network.SERIALIZATION)
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
// 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") logNetworkError(e, "Unknown network failure")
Result.Error(DataError.Network.UNKNOWN) Result.Error(DataError.Network.UNKNOWN)
} }
} }
}
/** /**
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it * Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
@@ -90,7 +98,7 @@ internal fun logNetworkError(throwable: Throwable, message: String) {
Timber.tag("HttpClient").e(throwable, message) 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( suspend inline fun <reified T> responseToResult(
response: HttpResponse, response: HttpResponse,
): Result<T, DataError.Network> { ): Result<T, DataError.Network> {

View File

@@ -4,7 +4,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color 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 Green10 = Color(0xFF00210B)
private val Green20 = Color(0xFF003918) private val Green20 = Color(0xFF003918)
private val Green40 = Color(0xFF1E6C36) private val Green40 = Color(0xFF1E6C36)

View File

@@ -13,7 +13,7 @@ sealed interface Result<out D, out E : Error> {
) : Result<Nothing, E> ) : 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> typealias EmptyResult<E> = Result<Unit, E>
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, 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 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. * NavController, keeping this feature decoupled from how it is reached.
*/ */
fun NavGraphBuilder.aboutGraph( 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 * 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. * `onAction` funnel and no event observation, because this screen has neither.
*/ */
@Composable @Composable
@@ -88,7 +88,7 @@ fun AboutScreen(
Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium) 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( AppCard(
onClick = onToggleMvvmNote, onClick = onToggleMvvmNote,
header = { header = {

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update 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 * 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: * 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 " + 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 " + "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: " + "process-death-restorable characters list. This screen is intentionally MVVM instead: " +
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " + "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 " + "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() 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() { fun onToggleMvvmNote() {
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) } _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 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. * CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
*/ */
internal class KtorCharacterDataSource( 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. * Loads one page of characters.
* *
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business * **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 * 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. * 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 * 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 * 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 * 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. * delete it and let the ViewModel call the repository.
*/ */
class GetCharactersPageUseCase( 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.core.domain.Result
import com.example.architecture.feature.characters.domain.CharacterRepository 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.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.CharacterStatus
import com.example.architecture.feature.characters.domain.model.CharactersPage 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 import org.junit.jupiter.api.Test
/** /**
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to * 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 * 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. * JUnit Platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
*/ */
class GetCharactersPageUseCaseTest { class GetCharactersPageUseCaseTest {
private val repository = mockk<CharacterRepository>()
private val useCase = GetCharactersPageUseCase(repository)
@Test @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 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) val result = useCase(page = 1)
@@ -31,10 +37,8 @@ class GetCharactersPageUseCaseTest {
} }
@Test @Test
fun `propagates the repository error`() = runBlocking { fun `propagates the repository error`() = runTest {
val useCase = GetCharactersPageUseCase( coEvery { repository.getCharacters(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
)
val result = useCase(page = 1) val result = useCase(page = 1)
@@ -43,30 +47,13 @@ class GetCharactersPageUseCaseTest {
} }
@Test @Test
fun `forwards the requested page number`() = runBlocking { fun `forwards the requested page number`() = runTest {
val fake = FakeCharacterRepository( coEvery { repository.getCharacters(any()) } returns
pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)), Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
)
val useCase = GetCharactersPageUseCase(fake)
useCase(page = 7) useCase(page = 7)
assertThat(fake.lastRequestedPage).isEqualTo(7) coVerify(exactly = 1) { repository.getCharacters(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)
} }
private fun domainCharacter(id: Int) = Character( 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) CharacterDetailScreen(state = state, onAction = viewModel::onAction)
} }
/** Pure, stateless screen previewable without a ViewModel. */ /** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CharacterDetailScreen( 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CharacterListScreen( fun CharacterListScreen(

View File

@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data object CharacterListRoute 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 @Serializable
data class CharacterDetailRoute(val characterId: Int) data class CharacterDetailRoute(val characterId: Int)
@@ -39,7 +39,7 @@ fun NavGraphBuilder.charactersGraph(
} }
composable<CharacterDetailRoute> { composable<CharacterDetailRoute> {
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments, // 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). // CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() }) CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
} }

View File

@@ -57,7 +57,7 @@ fun ErrorDemoRoot(
ErrorDemoScreen(state = state, onAction = viewModel::onAction) ErrorDemoScreen(state = state, onAction = viewModel::onAction)
} }
/** Pure, stateless screen previewable without a ViewModel. */ /** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ErrorDemoScreen( 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 * 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`). * UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
* *
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches * `: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 * 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. * owns its own resources, so the small label duplication across modules is expected.
*/ */
@StringRes @StringRes

View File

@@ -18,7 +18,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate) implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.android) 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). // 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` because CharacterListState.characters exposes ImmutableList in the public state API.
api(libs.kotlinx.collections.immutable) api(libs.kotlinx.collections.immutable)

View File

@@ -19,7 +19,7 @@ import kotlinx.coroutines.launch
* UI-agnostic MVI ViewModel for the character detail screen. * UI-agnostic MVI ViewModel for the character detail screen.
* *
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its * 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 * 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()`. * 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 * 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 * `@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. * snackbars are one-time Events, never state.
*/ */
data class CharacterListState( data class CharacterListState(

View File

@@ -77,7 +77,7 @@ class CharacterListViewModel(
page++ 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) { if (error != null) {
_events.send(CharacterListEvent.ShowSnackbar(error)) _events.send(CharacterListEvent.ShowSnackbar(error))
} }
@@ -119,7 +119,7 @@ class CharacterListViewModel(
private fun loadPage(page: Int) { private fun loadPage(page: Int) {
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is // 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. // get appended twice.
_state.update { it.copy(isLoadingNextPage = true, error = null) } _state.update { it.copy(isLoadingNextPage = true, error = null) }
viewModelScope.launch { viewModelScope.launch {

View File

@@ -4,7 +4,7 @@ sealed interface ErrorDemoAction {
/** Force a load that fails with the given [ErrorScenario]. */ /** Force a load that fails with the given [ErrorScenario]. */
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction 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 data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */ /** 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. * 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 * [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. * screens hold - so the renderer resolves and shows it the same way.
*/ */
data class ErrorDemoState( data class ErrorDemoState(
val isLoading: Boolean = false, val isLoading: Boolean = false,

View File

@@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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 * error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
* through the *same* steps a genuine network call uses: * 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 * Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
* ``` * ```
* *
* The outcome is *simulated* (no real request) only so every case including NO_INTERNET, which you * 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 * 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 * attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
* success). See android-error-handling. * success).
*/ */
class ErrorDemoViewModel : ViewModel() { 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. */ /** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module { val charactersPresentationModule = module {
// Stateless domain UseCase `factoryOf` (a fresh, cheap instance per resolution). Koin supplies // Stateless domain UseCase - `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
// its CharacterRepository from charactersDataModule. See koin-constructor-dsl. // its CharacterRepository from charactersDataModule.
factoryOf(::GetCharactersPageUseCase) factoryOf(::GetCharactersPageUseCase)
viewModelOf(::CharacterListViewModel) viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel) 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.Character
import com.example.architecture.feature.characters.domain.model.CharacterStatus 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( data class CharacterUi(
val id: Int, val id: Int,
val name: String, val name: String,

View File

@@ -9,6 +9,10 @@ import assertk.assertions.isNotNull
import assertk.assertions.isNull import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs import assertk.assertions.isSameInstanceAs
import com.example.architecture.core.domain.DataError 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher 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 * 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 * 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 * dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
* observed with Turbine. * 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) @OptIn(ExperimentalCoroutinesApi::class)
class CharacterDetailViewModelTest { class CharacterDetailViewModelTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository() private val repository = mockk<CharacterRepository>(relaxed = true)
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
@@ -48,7 +53,7 @@ class CharacterDetailViewModelTest {
@Test @Test
fun `loads details on init`() = runTest(dispatcher.scheduler) { 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) val viewModel = viewModel(characterId = 1)
advanceUntilIdle() advanceUntilIdle()
@@ -62,7 +67,7 @@ class CharacterDetailViewModelTest {
@Test @Test
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) { 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) val viewModel = viewModel(characterId = 1)
advanceUntilIdle() advanceUntilIdle()
@@ -75,14 +80,13 @@ class CharacterDetailViewModelTest {
@Test @Test
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) { 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) val viewModel = viewModel(characterId = 1)
advanceUntilIdle() advanceUntilIdle()
assertThat(viewModel.state.value.error).isNotNull() assertThat(viewModel.state.value.error).isNotNull()
// The next attempt will succeed. // Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
repository.failWith = null coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
repository.setDetails(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry) viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle() advanceUntilIdle()
@@ -93,7 +97,7 @@ class CharacterDetailViewModelTest {
@Test @Test
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) { 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) val viewModel = viewModel(characterId = 1)
advanceUntilIdle() advanceUntilIdle()
@@ -108,6 +112,7 @@ class CharacterDetailViewModelTest {
@Test @Test
fun `missing character id fails fast`() { fun `missing character id fails fast`() {
// The route contract: type-safe nav must have written characterId into SavedStateHandle. // The route contract: type-safe nav must have written characterId into SavedStateHandle.
// The constructor throws before the (relaxed) repository is ever touched.
assertThrows<IllegalStateException> { assertThrows<IllegalStateException> {
CharacterDetailViewModel(SavedStateHandle(), repository) 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.isTrue
import assertk.assertions.prop import assertk.assertions.prop
import com.example.architecture.core.domain.DataError 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 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@@ -26,19 +33,20 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test 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. * (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`, * 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 * 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` * coroutine runs. The collaborator is a MockK mock of [CharacterRepository] (the real
* are observed with Turbine; assertions use AssertK. * [GetCharactersPageUseCase] wraps it), stubbed per page with `coEvery` and verified with `coVerify`;
* `state`/`events` are observed with Turbine; assertions use AssertK.
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class CharacterListViewModelTest { class CharacterListViewModelTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val repository = FakeCharacterRepository() private val repository = mockk<CharacterRepository>()
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
@@ -55,7 +63,8 @@ class CharacterListViewModelTest {
@Test @Test
fun `loads the first page on init`() = runTest(dispatcher.scheduler) { 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() val viewModel = viewModel()
@@ -78,7 +87,7 @@ class CharacterListViewModelTest {
@Test @Test
fun `initial load failure emits a snackbar event and a full-screen error`() = fun `initial load failure emits a snackbar event and a full-screen error`() =
runTest(dispatcher.scheduler) { runTest(dispatcher.scheduler) {
repository.failWith = DataError.Network.NO_INTERNET coEvery { repository.getCharacters(any()) } returns Result.Error(DataError.Network.NO_INTERNET)
val viewModel = viewModel() val viewModel = viewModel()
@@ -95,8 +104,10 @@ class CharacterListViewModelTest {
@Test @Test
fun `does not load past the last page`() = runTest(dispatcher.scheduler) { fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) coEvery { repository.getCharacters(1) } returns
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null) // last page 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() val viewModel = viewModel()
advanceUntilIdle() // init → page 1 advanceUntilIdle() // init → page 1
@@ -107,21 +118,24 @@ class CharacterListViewModelTest {
assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue() assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue()
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2) assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
val callsBefore = repository.getCharactersCallCount
viewModel.onAction(CharacterListAction.OnLoadNextPage) viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() // guarded by endReached → no request 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 @Test
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) { fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) coEvery { repository.getCharacters(1) } returns
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3) Result.Success(CharactersPage(listOf(character(1)), nextPage = 2))
coEvery { repository.getCharacters(2) } returns
Result.Success(CharactersPage(listOf(character(2)), nextPage = 3))
val viewModel = viewModel() val viewModel = viewModel()
advanceUntilIdle() // init → page 1 advanceUntilIdle() // init → page 1
val callsBefore = repository.getCharactersCallCount
// Both fire before any launched coroutine runs; the second sees the synchronously-set // Both fire before any launched coroutine runs; the second sees the synchronously-set
// isLoadingNextPage flag and is guarded out. // isLoadingNextPage flag and is guarded out.
@@ -129,14 +143,15 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage) viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() advanceUntilIdle()
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1) coVerify(exactly = 1) { repository.getCharacters(2) }
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2) assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
} }
@Test @Test
fun `ignores a next-page request while the initial load is in flight`() = fun `ignores a next-page request while the initial load is in flight`() =
runTest(dispatcher.scheduler) { 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() val viewModel = viewModel()
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this // restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
@@ -144,13 +159,14 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage) viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() advanceUntilIdle()
// Only the single initial load ran the guarded next-page request never fired. // Only the single initial load ran - the guarded next-page request never fired.
assertThat(repository.getCharactersCallCount).isEqualTo(1) coVerify(exactly = 1) { repository.getCharacters(1) }
coVerify(exactly = 0) { repository.getCharacters(2) }
} }
@Test @Test
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) { 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() val viewModel = viewModel()
viewModel.events.test { viewModel.events.test {
@@ -160,9 +176,10 @@ class CharacterListViewModelTest {
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class) assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty() assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
// Empty branch of retry(): the repository recovers, OnRetry rebuilds from page 1. // Empty branch of retry(): the repository recovers (the later, more specific stub for
repository.failWith = null // page 1 wins over the `any()` failure), OnRetry rebuilds from page 1.
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))
viewModel.onAction(CharacterListAction.OnRetry) viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle() advanceUntilIdle()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -174,12 +191,14 @@ class CharacterListViewModelTest {
@Test @Test
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) { 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() val viewModel = viewModel()
advanceUntilIdle() // page 1 loaded (no event) advanceUntilIdle() // page 1 loaded (no event)
viewModel.events.test { 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) viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() advanceUntilIdle()
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class) 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 // 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). // 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) viewModel.onAction(CharacterListAction.OnRetry)
advanceUntilIdle() advanceUntilIdle()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -196,12 +216,16 @@ class CharacterListViewModelTest {
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2) assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2) assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull() 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 @Test
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) { fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) coEvery { repository.getCharacters(1) } returns
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3) 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. // Navigation/SavedStateHandle persisted the last loaded page across process death.
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2)) val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
@@ -211,5 +235,9 @@ class CharacterListViewModelTest {
// Both pages are rebuilt (1 then 2), and currentPage is restored. // 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::characters).hasSize(2)
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(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] [versions]
# Build / language # Build / language
agp = "9.0.1" agp = "9.2.1"
kotlin = "2.3.20" kotlin = "2.4.0"
# AndroidX core / lifecycle / activity / views # AndroidX - core / lifecycle / activity / views
androidxCore = "1.18.0" androidxCore = "1.19.0"
androidxLifecycle = "2.10.0" androidxLifecycle = "2.10.0"
androidxActivity = "1.13.0" androidxActivity = "1.13.0"
androidxAppcompat = "1.7.0" androidxAppcompat = "1.7.1"
androidxFragment = "1.8.5" androidxFragment = "1.8.9"
androidxRecyclerview = "1.4.0" androidxRecyclerview = "1.4.0"
androidxNavigation = "2.9.0" androidxNavigation = "2.9.8"
# Compose (BOM-managed) # Compose (BOM-managed)
composeBom = "2026.03.01" composeBom = "2026.05.01"
# Async / serialization # Async / serialization
coroutines = "1.10.2" coroutines = "1.11.0"
kotlinxSerialization = "1.8.1" kotlinxSerialization = "1.11.0"
kotlinxCollectionsImmutable = "0.3.8" kotlinxCollectionsImmutable = "0.5.0"
# DI # DI
koin = "4.1.0" koin = "4.2.1"
# Networking # Networking
ktor = "3.1.3" ktor = "3.5.0"
# Image loading # Image loading
coil = "3.1.0" coil = "3.5.0"
# Logging # Logging
timber = "5.0.1" timber = "5.0.1"
# Material Components (Views renderer) # Material Components (Views renderer)
material = "1.12.0" material = "1.14.0"
# Testing # Testing
junitJupiter = "5.11.4" junitJupiter = "6.1.0"
junitPlatform = "1.11.4" junitPlatform = "6.1.0"
turbine = "1.2.0" turbine = "1.2.1"
assertk = "0.28.1" assertk = "0.28.1"
mockk = "1.14.11"
androidxTestExt = "1.3.0" androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
androidxEspresso = "3.7.0" androidxEspresso = "3.7.0"
@@ -115,10 +116,11 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
# --- Testing --- # --- Testing ---
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" } 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-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" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } 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-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" } 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" } 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"] lifecycle-compose = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]
views = ["androidx-appcompat", "material", "androidx-recyclerview", "androidx-fragment-ktx"] 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. # Instrumented Compose UI test (androidTest): ComposeTestRule + AndroidJUnit4 runner.
# espresso-core/runner are pinned to current versions: Compose's test rule drives Espresso's # 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+), # 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME