Merge pull request #4 from AdrianKuta/feat/quality-docs
Quality & Docs (REDI-94…98): UseCase, tests, error demo, architecture README
This commit is contained in:
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -32,5 +32,18 @@ jobs:
|
|||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
- name: Assemble (debug)
|
- name: Unit tests (JUnit 5)
|
||||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
run: ./gradlew test --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Assemble (debug) + compile instrumented tests
|
||||||
|
# assembleDebugAndroidTest compiles the Compose UI test; it runs on a device via
|
||||||
|
# connectedDebugAndroidTest (locally / on an emulator runner), not in this build job.
|
||||||
|
run: ./gradlew assembleDebug assembleDebugAndroidTest --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Upload test reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-reports
|
||||||
|
path: '**/build/reports/tests/'
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
394
README.md
394
README.md
@@ -1,84 +1,388 @@
|
|||||||
# 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*.
|
||||||
|
|
||||||
|
Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists
|
||||||
|
characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has
|
||||||
|
a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**.
|
||||||
|
|
||||||
> **Status:** built milestone-by-milestone from the
|
> **Status:** built milestone-by-milestone from the
|
||||||
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
|
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
|
||||||
> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and
|
> Foundation, Core Infrastructure, the flagship MVI feature, Breadth & Contrast, and Quality & Docs
|
||||||
> **Breadth & Contrast** (character detail, the MVVM About screen, the Views renderer, and
|
> are complete; the project assembles green and ships unit + UI tests. The only optional item left is
|
||||||
> Compose↔View interop) are complete and the project assembles green. Full architecture docs land
|
> the Room offline-cache stretch (see [Optional: Room stretch](#optional-room-stretch)).
|
||||||
> with the *Quality & Docs* milestone.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Stack](#stack)
|
||||||
|
- [Module structure & dependency rules](#module-structure--dependency-rules)
|
||||||
|
- [The data → UI flow](#the-data--ui-flow)
|
||||||
|
- [Presentation patterns: MVI vs MVVM](#presentation-patterns-mvi-vs-mvvm)
|
||||||
|
- [One ViewModel, two renderers (Compose vs Views)](#one-viewmodel-two-renderers-compose-vs-views)
|
||||||
|
- [Errors: `Result`, `DataError`, `UiText`](#errors-result-dataerror-uitext)
|
||||||
|
- [Navigation](#navigation)
|
||||||
|
- [Dependency injection (Koin)](#dependency-injection-koin)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Build & run (`android` CLI)](#build--run-android-cli)
|
||||||
|
- [Optional: Room stretch](#optional-room-stretch)
|
||||||
|
- [Convention skills index](#convention-skills-index)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stack
|
## 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.0.1, Kotlin 2.3.20, Gradle 9.1, `compileSdk`/`targetSdk` 36, `minSdk` 24, Java 17 |
|
||||||
|
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
|
||||||
|
| DI | Koin 4.1 (constructor DSL) |
|
||||||
|
| Networking | Ktor (OkHttp engine) + KotlinX Serialization |
|
||||||
|
| Images | Coil 3 |
|
||||||
|
| Navigation | type-safe Compose Navigation (`@Serializable` routes) |
|
||||||
|
| Logging | Timber |
|
||||||
|
| Async | Coroutines + Flow |
|
||||||
|
| Testing | JUnit 5, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
|
||||||
|
|
||||||
What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature),
|
> **AGP 9 gotcha:** AGP 9.0 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:data → Ktor 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:data → DTOs, 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.
|
||||||
|
|
||||||
|
See **android-module-structure**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The data → UI flow
|
||||||
|
|
||||||
|
One request flows through every layer, each with one job:
|
||||||
|
|
||||||
|
```
|
||||||
|
Rick & Morty API
|
||||||
|
│ JSON
|
||||||
|
▼
|
||||||
|
CharacterDto / CharactersResponseDto (:data/dto) – serialization shape
|
||||||
|
│ CharacterMapper.toDomain() (:data/mappers) – DTO → domain, never the reverse leaks up
|
||||||
|
▼
|
||||||
|
Character / CharactersPage (:domain/model) – pure Kotlin domain model
|
||||||
|
│ CharacterRepository.getCharacters() (:domain contract, :data impl)
|
||||||
|
│ GetCharactersPageUseCase(page) (:domain/usecase) – domain operation (see note)
|
||||||
|
▼
|
||||||
|
CharacterListViewModel (:presentation) – holds State, processes Action, emits Event
|
||||||
|
│ Character.toCharacterUi() (:presentation/model)– domain → UI model (display shaping)
|
||||||
|
▼
|
||||||
|
CharacterUi in CharacterListState (:presentation) – immutable UI state
|
||||||
|
▼
|
||||||
|
CharacterListScreen / CharacterListFragment (:presentation-compose / -views) – dumb renderers
|
||||||
|
```
|
||||||
|
|
||||||
|
- **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities.
|
||||||
|
Mappers are pure extension functions in a `mappers/` package (`toDomain()`). See
|
||||||
|
**android-data-layer**, **android-data-layer-mappers**.
|
||||||
|
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
|
||||||
|
pre-formatted to an em dash). See **android-presentation-mvi**.
|
||||||
|
|
||||||
|
### Note — when to add a UseCase
|
||||||
|
|
||||||
|
`GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The
|
||||||
|
rule it illustrates:
|
||||||
|
|
||||||
|
> Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** — real
|
||||||
|
> rules, or **composition of several repositories/sources** into one operation. When the ViewModel
|
||||||
|
> would merely forward a single repository call, injecting the repository directly is fine.
|
||||||
|
|
||||||
|
Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly — both are
|
||||||
|
correct, and the contrast is the point. See **android-module-structure**, **android-di-koin**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presentation patterns: MVI vs MVVM
|
||||||
|
|
||||||
|
Both patterns live side by side so the trade-off is concrete.
|
||||||
|
|
||||||
| | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) |
|
| | **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`.
|
||||||
|
|
||||||
|
### Note — when MVVM is acceptable
|
||||||
|
|
||||||
|
Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen
|
||||||
|
is small, mostly static, and has no real side effects — the *About* screen is the canonical case.
|
||||||
|
|
||||||
|
See **android-presentation-mvi**, **android-compose-ui**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One ViewModel, two renderers (Compose vs Views)
|
||||||
|
|
||||||
|
`:feature:characters:presentation` is **UI-toolkit-agnostic** — no Compose *and* no Views dependency.
|
||||||
|
State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the
|
||||||
|
`@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel`
|
||||||
|
(State/Action/Event/UI-model) is rendered twice:
|
||||||
|
|
||||||
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
|
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
|
||||||
- `:feature:characters:presentation-views` — `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`.
|
- `:feature:characters:presentation-views` — `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`,
|
||||||
|
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
|
||||||
|
|
||||||
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
|
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
|
||||||
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other.
|
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and
|
||||||
|
from navigation.
|
||||||
|
|
||||||
## Build & run
|
> **Material3-XML-theme gotcha:** the host Activity (`MainActivity`) extends **`FragmentActivity`**
|
||||||
|
> (so `AndroidFragment` has a `FragmentManager`) and uses a **Material Components XML theme**, which
|
||||||
|
> the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain
|
||||||
|
> `ComponentActivity` or a non-Material theme breaks the Fragment renderer.
|
||||||
|
|
||||||
|
See **android-compose-ui**, **android-module-structure**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Errors: `Result`, `DataError`, `UiText`
|
||||||
|
|
||||||
|
Expected failures are **values, not exceptions**. The whole app speaks one typed result:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
sealed interface Result<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.
|
||||||
|
|
||||||
|
See **android-error-handling**, **android-presentation-mvi**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Type-safe Compose Navigation with `@Serializable` route objects, one nav graph per feature, assembled
|
||||||
|
in `:app`.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Serializable data object CharacterListRoute
|
||||||
|
@Serializable data class CharacterDetailRoute(val characterId: Int)
|
||||||
|
@Serializable data object ErrorDemoRoute
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController`
|
||||||
|
passed into `charactersGraph(navController, …)`.
|
||||||
|
- **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda
|
||||||
|
callbacks** supplied by `:app` — a feature never imports another feature's route.
|
||||||
|
- **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId`
|
||||||
|
into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`.
|
||||||
|
`CharacterDetailViewModel` reads `savedStateHandle.get<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.
|
||||||
|
|
||||||
|
See **android-navigation**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency injection (Koin)
|
||||||
|
|
||||||
|
One Koin module per feature layer (only if it has something to provide), all assembled in
|
||||||
|
`ArchitectureApp` — never inside feature modules. Prefer the **constructor DSL**:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// :feature:characters:data
|
||||||
|
val charactersDataModule = module {
|
||||||
|
singleOf(::KtorCharacterDataSource)
|
||||||
|
singleOf(::NetworkCharacterRepository) { bind<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`.
|
||||||
|
|
||||||
|
See **android-di-koin**, **koin-constructor-dsl**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests prove the architecture, not just the code. Stack: **JUnit 5**, **Turbine** (Flow), **AssertK**,
|
||||||
|
`kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test.
|
||||||
|
|
||||||
|
| What | Where | Kind |
|
||||||
|
|---|---|---|
|
||||||
|
| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 |
|
||||||
|
| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, fakes + Turbine + `SavedStateHandle` |
|
||||||
|
| `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` |
|
||||||
|
| `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI |
|
||||||
|
|
||||||
|
Conventions demonstrated:
|
||||||
|
|
||||||
|
- **Fakes, not mocks.** `FakeCharacterRepository` is a real in-memory implementation with a
|
||||||
|
`failWith` toggle and call counts — tests assert against working behaviour, not recorded calls.
|
||||||
|
- **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed
|
||||||
|
`SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path,
|
||||||
|
error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the
|
||||||
|
rapid-duplicate-paging guard (which is why these use `StandardTestDispatcher`).
|
||||||
|
- **Repository tested over a real Ktor client** with a swapped `MockEngine`
|
||||||
|
(`HttpClientFactory.create(engine)`): success mapping, `404 → NOT_FOUND`, `500 → SERVER_ERROR`,
|
||||||
|
malformed body `→ SERIALIZATION`.
|
||||||
|
- **Robot pattern** for the Compose UI test: `CharacterListRobot` methods `return this` so a test
|
||||||
|
reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the
|
||||||
|
right `Action`.
|
||||||
|
|
||||||
|
> **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this
|
||||||
|
> repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test`
|
||||||
|
> convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle — including the
|
||||||
|
> `junit-platform-launcher`, which Gradle 9 no longer bundles.
|
||||||
|
|
||||||
|
> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso
|
||||||
|
> 3.5.0 calls the removed `InputManager.getInstance()`. The catalog pins espresso/runner to current
|
||||||
|
> versions in the `compose-ui-test` bundle to fix that.
|
||||||
|
|
||||||
|
What runs where: `./gradlew test` (all JVM unit tests) runs in **CI**; the instrumented Compose test
|
||||||
|
runs on a device/emulator via `./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest`
|
||||||
|
(CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is
|
||||||
|
possible but intentionally omitted (the VM logic is already covered by the shared unit tests).
|
||||||
|
|
||||||
|
See **android-testing**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & run (`android` CLI)
|
||||||
|
|
||||||
```bash
|
```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 5)
|
||||||
|
./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device)
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the `android` CLI for an emulator + run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
android emulator list # list AVDs
|
||||||
|
android emulator start <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 36`, `minSdk 24`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: Room stretch
|
||||||
|
|
||||||
|
Out of core scope and **not implemented** (tracked as the optional REDI-99). It would add a `room`
|
||||||
|
convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO +
|
||||||
|
`@Database` (prefer `autoMigrations`), then convert the repository to **offline-first**
|
||||||
|
(`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the
|
||||||
|
DB, never the network response). The current `CharacterRepository` returning the `DataError`
|
||||||
|
supertype already anticipates a multi-source implementation. See **android-data-layer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Convention skills index
|
||||||
|
|
||||||
|
This repo is a narrative index of these conventions:
|
||||||
|
|
||||||
|
| Skill | Where it shows up |
|
||||||
|
|---|---|
|
||||||
|
| android-module-structure | module graph, dependency rules, convention plugins |
|
||||||
|
| android-presentation-mvi | characters list/detail/error-demo (State/Action/Event/VM) |
|
||||||
|
| android-compose-ui | Compose renderers, design-system, previews, stability |
|
||||||
|
| android-navigation | type-safe routes, per-feature graphs, callback decoupling |
|
||||||
|
| android-di-koin / koin-constructor-dsl | feature Koin modules, `*Of` constructor DSL |
|
||||||
|
| android-data-layer / android-data-layer-mappers | data sources, repository, DTOs, mappers |
|
||||||
|
| android-error-handling | `Result`/`DataError`/`UiText`, `safeCall`, the error demo |
|
||||||
|
| android-testing | unit tests, fakes, `MockEngine`, the robot UI test |
|
||||||
|
| android-cli | build/run/emulator steps above |
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ gradlePlugin {
|
|||||||
id = "architecture.domain.module"
|
id = "architecture.domain.module"
|
||||||
implementationClass = "com.example.architecture.convention.DomainModuleConventionPlugin"
|
implementationClass = "com.example.architecture.convention.DomainModuleConventionPlugin"
|
||||||
}
|
}
|
||||||
|
register("androidUnitTest") {
|
||||||
|
id = "architecture.android.unit.test"
|
||||||
|
implementationClass = "com.example.architecture.convention.AndroidUnitTestConventionPlugin"
|
||||||
|
}
|
||||||
register("compose") {
|
register("compose") {
|
||||||
id = "architecture.compose"
|
id = "architecture.compose"
|
||||||
implementationClass = "com.example.architecture.convention.ComposeConventionPlugin"
|
implementationClass = "com.example.architecture.convention.ComposeConventionPlugin"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = MIN_SDK
|
minSdk = MIN_SDK
|
||||||
|
// Used by instrumented (androidTest) tests, e.g. the Compose UI test in
|
||||||
|
// :feature:characters:presentation-compose. Harmless for modules without androidTest.
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.tasks.testing.Test
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an Android library module's local unit tests (`src/test`) on the **JUnit 5 platform** with the
|
||||||
|
* shared `unit-test` toolset (JUnit Jupiter, kotlinx-coroutines-test, Turbine, AssertK).
|
||||||
|
*
|
||||||
|
* Deliberately does NOT use the `de.mannodermaus.android-junit5` Gradle plugin: its 1.11.x line
|
||||||
|
* targets AGP 8.x and we build on AGP 9.0. It isn't needed for *local* unit tests anyway —
|
||||||
|
* `com.android.build.gradle.tasks.factory.AndroidUnitTest` extends Gradle's [Test] task, so calling
|
||||||
|
* `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the
|
||||||
|
* same for pure-JVM modules).
|
||||||
|
*/
|
||||||
|
class AndroidUnitTestConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
dependencies {
|
||||||
|
add("testImplementation", libs.findBundle("unit-test").get())
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
|
||||||
|
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ class DomainModuleConventionPlugin : Plugin<Project> {
|
|||||||
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())
|
||||||
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.
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ 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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ plugins {
|
|||||||
alias(libs.plugins.architecture.android.library)
|
alias(libs.plugins.architecture.android.library)
|
||||||
alias(libs.plugins.architecture.koin)
|
alias(libs.plugins.architecture.koin)
|
||||||
alias(libs.plugins.architecture.kotlinx.serialization)
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
|
alias(libs.plugins.architecture.android.unit.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -12,4 +13,9 @@ dependencies {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":feature:characters:domain"))
|
implementation(project(":feature:characters:domain"))
|
||||||
|
|
||||||
|
// Swap a Ktor MockEngine into HttpClientFactory.create(...) for the repository test.
|
||||||
|
testImplementation(libs.ktor.client.mock)
|
||||||
|
testImplementation(libs.ktor.client.content.negotiation)
|
||||||
|
testImplementation(libs.ktor.serialization.kotlinx.json)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.usecase
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one page of characters.
|
||||||
|
*
|
||||||
|
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business
|
||||||
|
* logic that does NOT belong in the ViewModel — non-trivial rules, or *composition* of several
|
||||||
|
* repositories/sources into one domain operation. When the ViewModel would merely forward a single
|
||||||
|
* repository call, skipping the UseCase and injecting the repository directly is perfectly fine.
|
||||||
|
*
|
||||||
|
* This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic
|
||||||
|
* beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the
|
||||||
|
* convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would
|
||||||
|
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) — or
|
||||||
|
* delete it and let the ViewModel call the repository.
|
||||||
|
*/
|
||||||
|
class GetCharactersPageUseCase(
|
||||||
|
private val characterRepository: CharacterRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(page: Int): Result<CharactersPage, DataError> =
|
||||||
|
characterRepository.getCharacters(page)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.usecase
|
||||||
|
|
||||||
|
import assertk.assertThat
|
||||||
|
import assertk.assertions.isEqualTo
|
||||||
|
import assertk.assertions.isInstanceOf
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.Character
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to
|
||||||
|
* the repository and return its result verbatim — success and error alike. Pure JVM test on the
|
||||||
|
* JUnit 5 platform (see DomainModuleConventionPlugin); collaborator is a hand-written fake.
|
||||||
|
*/
|
||||||
|
class GetCharactersPageUseCaseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns the repository page on success`() = runBlocking {
|
||||||
|
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
|
||||||
|
val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page)))
|
||||||
|
|
||||||
|
val result = useCase(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(Result.Success(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `propagates the repository error`() = runBlocking {
|
||||||
|
val useCase = GetCharactersPageUseCase(
|
||||||
|
FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = useCase(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(Result.Error::class)
|
||||||
|
assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forwards the requested page number`() = runBlocking {
|
||||||
|
val fake = FakeCharacterRepository(
|
||||||
|
pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)),
|
||||||
|
)
|
||||||
|
val useCase = GetCharactersPageUseCase(fake)
|
||||||
|
|
||||||
|
useCase(page = 7)
|
||||||
|
|
||||||
|
assertThat(fake.lastRequestedPage).isEqualTo(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCharacterRepository(
|
||||||
|
private val pageResult: Result<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(
|
||||||
|
id = id,
|
||||||
|
name = "Character $id",
|
||||||
|
status = CharacterStatus.ALIVE,
|
||||||
|
species = "Human",
|
||||||
|
imageUrl = "https://example.com/$id.png",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,4 +13,9 @@ dependencies {
|
|||||||
implementation(project(":core:design-system"))
|
implementation(project(":core:design-system"))
|
||||||
implementation(project(":feature:characters:domain"))
|
implementation(project(":feature:characters:domain"))
|
||||||
implementation(project(":feature:characters:presentation"))
|
implementation(project(":feature:characters:presentation"))
|
||||||
|
|
||||||
|
// Instrumented Compose UI test (robot pattern). The Compose convention already adds the BOM to
|
||||||
|
// androidTestImplementation; ui-test-manifest provides the empty Activity ComposeTestRule hosts in.
|
||||||
|
androidTestImplementation(libs.bundles.compose.ui.test)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListState
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent
|
||||||
|
* scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns
|
||||||
|
* the interaction vocabulary; the test owns the assertions' intent — keeping tests readable and
|
||||||
|
* resilient to UI structure changes. See android-testing.
|
||||||
|
*/
|
||||||
|
class CharacterListRobot(
|
||||||
|
private val composeRule: ComposeContentTestRule,
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
private val recordedActions = mutableListOf<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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,14 +66,15 @@ import org.koin.androidx.compose.koinViewModel
|
|||||||
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
|
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
|
||||||
* The snackbar is resolved with the Context-based [asString] because it runs outside composition.
|
* The snackbar is resolved with the Context-based [asString] because it runs outside composition.
|
||||||
*
|
*
|
||||||
* [onOpenAbout] and [onOpenViewsList] are renderer-only chrome (a Compose overflow menu), so they
|
* [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] are renderer-only chrome (a Compose overflow
|
||||||
* are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
|
* menu), so they are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CharacterListRoot(
|
fun CharacterListRoot(
|
||||||
onCharacterClick: (Int) -> Unit,
|
onCharacterClick: (Int) -> Unit,
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
onOpenViewsList: () -> Unit,
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
viewModel: CharacterListViewModel = koinViewModel(),
|
viewModel: CharacterListViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -95,6 +96,7 @@ fun CharacterListRoot(
|
|||||||
onAction = viewModel::onAction,
|
onAction = viewModel::onAction,
|
||||||
onOpenAbout = onOpenAbout,
|
onOpenAbout = onOpenAbout,
|
||||||
onOpenViewsList = onOpenViewsList,
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = onOpenErrorDemo,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,13 +109,20 @@ fun CharacterListScreen(
|
|||||||
onAction: (CharacterListAction) -> Unit,
|
onAction: (CharacterListAction) -> Unit,
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
onOpenViewsList: () -> Unit,
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
) {
|
) {
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.characters_title)) },
|
title = { Text(stringResource(R.string.characters_title)) },
|
||||||
actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) },
|
actions = {
|
||||||
|
CharacterListOverflowMenu(
|
||||||
|
onOpenAbout = onOpenAbout,
|
||||||
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = onOpenErrorDemo,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
@@ -149,6 +158,7 @@ fun CharacterListScreen(
|
|||||||
private fun CharacterListOverflowMenu(
|
private fun CharacterListOverflowMenu(
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
onOpenViewsList: () -> Unit,
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
IconButton(onClick = { expanded = true }) {
|
IconButton(onClick = { expanded = true }) {
|
||||||
@@ -165,6 +175,13 @@ private fun CharacterListOverflowMenu(
|
|||||||
onOpenViewsList()
|
onOpenViewsList()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_error_demo)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onOpenErrorDemo()
|
||||||
|
},
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_about)) },
|
text = { Text(stringResource(R.string.menu_about)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -284,6 +301,7 @@ private fun CharacterListScreenLoadedPreview() {
|
|||||||
onAction = {},
|
onAction = {},
|
||||||
onOpenAbout = {},
|
onOpenAbout = {},
|
||||||
onOpenViewsList = {},
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,6 +319,7 @@ private fun CharacterListScreenErrorPreview() {
|
|||||||
onAction = {},
|
onAction = {},
|
||||||
onOpenAbout = {},
|
onOpenAbout = {},
|
||||||
onOpenViewsList = {},
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ data object CharacterListRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class CharacterDetailRoute(val characterId: Int)
|
data class CharacterDetailRoute(val characterId: Int)
|
||||||
|
|
||||||
|
/** Type-safe route for the error-handling demo screen. */
|
||||||
|
@Serializable
|
||||||
|
data object ErrorDemoRoute
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the
|
* The characters feature nav graph. List→detail and list→error-demo are intra-feature navigation, so
|
||||||
* [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted
|
* they are driven by the [navController] passed in. Cross-boundary destinations (the About screen,
|
||||||
* by `:app`) stay decoupled as callbacks supplied by `:app`.
|
* the Views renderer hosted by `:app`) stay decoupled as callbacks supplied by `:app`.
|
||||||
*/
|
*/
|
||||||
fun NavGraphBuilder.charactersGraph(
|
fun NavGraphBuilder.charactersGraph(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
@@ -30,6 +34,7 @@ fun NavGraphBuilder.charactersGraph(
|
|||||||
},
|
},
|
||||||
onOpenAbout = onOpenAbout,
|
onOpenAbout = onOpenAbout,
|
||||||
onOpenViewsList = onOpenViewsList,
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = { navController.navigate(ErrorDemoRoute) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable<CharacterDetailRoute> {
|
composable<CharacterDetailRoute> {
|
||||||
@@ -38,4 +43,7 @@ fun NavGraphBuilder.charactersGraph(
|
|||||||
// 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() })
|
||||||
}
|
}
|
||||||
|
composable<ErrorDemoRoute> {
|
||||||
|
ErrorDemoRoot(onNavigateBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.architecture.core.design.system.component.AppScaffold
|
||||||
|
import com.example.architecture.core.design.system.component.ErrorState
|
||||||
|
import com.example.architecture.core.design.system.component.LoadingIndicator
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.core.presentation.ObserveAsEvents
|
||||||
|
import com.example.architecture.core.presentation.asString
|
||||||
|
import com.example.architecture.feature.characters.presentation.ErrorDemoAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.ErrorDemoEvent
|
||||||
|
import com.example.architecture.feature.characters.presentation.ErrorDemoState
|
||||||
|
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
|
||||||
|
import com.example.architecture.feature.characters.presentation.ErrorScenario
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root: owns the demo ViewModel (Koin) and forwards the one-time NavigateBack event up the stack.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ErrorDemoRoot(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ErrorDemoViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
ObserveAsEvents(viewModel.events) { event ->
|
||||||
|
when (event) {
|
||||||
|
ErrorDemoEvent.NavigateBack -> onNavigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure, stateless screen — previewable without a ViewModel. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ErrorDemoScreen(
|
||||||
|
state: ErrorDemoState,
|
||||||
|
onAction: (ErrorDemoAction) -> Unit,
|
||||||
|
) {
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.error_demo_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onAction(ErrorDemoAction.OnBackClick) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.cd_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.error_demo_intro),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NO_INTERNET)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.error_demo_force_no_internet)) }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.NOT_FOUND)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.error_demo_force_not_found)) }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onAction(ErrorDemoAction.OnForceError(ErrorScenario.SERVER_ERROR)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.error_demo_force_server)) }
|
||||||
|
Button(
|
||||||
|
onClick = { onAction(ErrorDemoAction.OnLoadSuccess) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.error_demo_load_success)) }
|
||||||
|
|
||||||
|
// Result area: loading → mapped error (with retry) → success → idle hint.
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
val error = state.error
|
||||||
|
when {
|
||||||
|
state.isLoading -> LoadingIndicator()
|
||||||
|
error != null -> ErrorState(
|
||||||
|
message = error.asString(),
|
||||||
|
onRetry = { onAction(ErrorDemoAction.OnRetry) },
|
||||||
|
)
|
||||||
|
state.loaded -> Text(
|
||||||
|
text = stringResource(R.string.error_demo_success),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
else -> Text(
|
||||||
|
text = stringResource(R.string.error_demo_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ErrorDemoScreenIdlePreview() {
|
||||||
|
AppTheme { ErrorDemoScreen(state = ErrorDemoState(), onAction = {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ErrorDemoScreenErrorPreview() {
|
||||||
|
AppTheme {
|
||||||
|
ErrorDemoScreen(
|
||||||
|
state = ErrorDemoState(
|
||||||
|
error = com.example.architecture.core.presentation.UiText.DynamicString(
|
||||||
|
"No internet connection. Check your network and try again.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onAction = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
<string name="cd_more_options">More options</string>
|
<string name="cd_more_options">More options</string>
|
||||||
<string name="menu_about">About</string>
|
<string name="menu_about">About</string>
|
||||||
<string name="menu_open_as_views">Open as Views</string>
|
<string name="menu_open_as_views">Open as Views</string>
|
||||||
|
<string name="menu_error_demo">Error handling demo</string>
|
||||||
|
|
||||||
|
<!-- Error-handling demo screen -->
|
||||||
|
<string name="error_demo_title">Error handling demo</string>
|
||||||
|
<string name="error_demo_intro">Force a network failure to watch it flow through the pipeline: DataError.Network → toUiText() → the shared ErrorState. Retry re-issues the same request; a successful load clears the error.</string>
|
||||||
|
<string name="error_demo_force_no_internet">Force: No internet</string>
|
||||||
|
<string name="error_demo_force_not_found">Force: Not found</string>
|
||||||
|
<string name="error_demo_force_server">Force: Server error</string>
|
||||||
|
<string name="error_demo_load_success">Load (success)</string>
|
||||||
|
<string name="error_demo_success">Loaded successfully ✓</string>
|
||||||
|
<string name="error_demo_hint">Pick an action above to see the result here.</string>
|
||||||
|
|
||||||
<!-- Detail screen -->
|
<!-- Detail screen -->
|
||||||
<string name="character_detail_title">Character</string>
|
<string name="character_detail_title">Character</string>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.architecture.android.library)
|
alias(libs.plugins.architecture.android.library)
|
||||||
alias(libs.plugins.architecture.koin)
|
alias(libs.plugins.architecture.koin)
|
||||||
|
alias(libs.plugins.architecture.android.unit.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI-agnostic presentation: the MVI ViewModel + State/Action/Event live here and are shared by
|
// UI-agnostic presentation: the MVI ViewModel + State/Action/Event live here and are shared by
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import com.example.architecture.core.domain.onFailure
|
|||||||
import com.example.architecture.core.domain.onSuccess
|
import com.example.architecture.core.domain.onSuccess
|
||||||
import com.example.architecture.core.presentation.UiText
|
import com.example.architecture.core.presentation.UiText
|
||||||
import com.example.architecture.core.presentation.toUiText
|
import com.example.architecture.core.presentation.toUiText
|
||||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||||
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||||
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
|
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -25,7 +25,7 @@ import kotlinx.coroutines.launch
|
|||||||
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
|
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
|
||||||
*/
|
*/
|
||||||
class CharacterListViewModel(
|
class CharacterListViewModel(
|
||||||
private val characterRepository: CharacterRepository,
|
private val getCharactersPage: GetCharactersPageUseCase,
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class CharacterListViewModel(
|
|||||||
|
|
||||||
var page = 1
|
var page = 1
|
||||||
while (page <= targetPage) {
|
while (page <= targetPage) {
|
||||||
when (val result = characterRepository.getCharacters(page)) {
|
when (val result = getCharactersPage(page)) {
|
||||||
is Result.Success -> {
|
is Result.Success -> {
|
||||||
accumulated += result.data.characters.map { it.toCharacterUi() }
|
accumulated += result.data.characters.map { it.toCharacterUi() }
|
||||||
lastLoadedPage = page
|
lastLoadedPage = page
|
||||||
@@ -123,7 +123,7 @@ class CharacterListViewModel(
|
|||||||
// 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 {
|
||||||
characterRepository.getCharacters(page)
|
getCharactersPage(page)
|
||||||
.onSuccess { pageData ->
|
.onSuccess { pageData ->
|
||||||
_state.update { state ->
|
_state.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
sealed interface ErrorDemoAction {
|
||||||
|
/** Force a load that fails with the given [ErrorScenario]. */
|
||||||
|
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
|
||||||
|
|
||||||
|
/** Force a load that succeeds — clears any current error. */
|
||||||
|
data object OnLoadSuccess : ErrorDemoAction
|
||||||
|
|
||||||
|
/** Re-issue the most recent load (the design-system retry button). */
|
||||||
|
data object OnRetry : ErrorDemoAction
|
||||||
|
|
||||||
|
data object OnBackClick : ErrorDemoAction
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
sealed interface ErrorDemoEvent {
|
||||||
|
data object NavigateBack : ErrorDemoEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import com.example.architecture.core.presentation.UiText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
|
||||||
|
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` — exactly what the real
|
||||||
|
* screens hold — so the renderer resolves and shows it the same way.
|
||||||
|
*/
|
||||||
|
data class ErrorDemoState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val loaded: Boolean = false,
|
||||||
|
val error: UiText? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The failure the user asks the demo to reproduce. A presentation-local choice (not a `DataError`)
|
||||||
|
* so the renderer stays free of domain error types; the ViewModel maps it to the real
|
||||||
|
* `DataError.Network` case.
|
||||||
|
*/
|
||||||
|
enum class ErrorScenario {
|
||||||
|
NO_INTERNET,
|
||||||
|
NOT_FOUND,
|
||||||
|
SERVER_ERROR,
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.core.domain.onFailure
|
||||||
|
import com.example.architecture.core.domain.onSuccess
|
||||||
|
import com.example.architecture.core.presentation.toUiText
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-agnostic MVI ViewModel for the **error-handling demo** — a runnable walk-through of the whole
|
||||||
|
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
|
||||||
|
* through the *same* steps a genuine network call uses:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The outcome is *simulated* (no real request) only so every case — including NO_INTERNET, which you
|
||||||
|
* can't reliably trigger on demand — is reachable deterministically. [OnRetry] re-issues the last
|
||||||
|
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
|
||||||
|
* success). See android-error-handling.
|
||||||
|
*/
|
||||||
|
class ErrorDemoViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ErrorDemoState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = Channel<ErrorDemoEvent>()
|
||||||
|
val events = _events.receiveAsFlow()
|
||||||
|
|
||||||
|
// Remembered so OnRetry re-issues exactly what was last attempted.
|
||||||
|
private var lastAttempt: Attempt = Attempt.Success
|
||||||
|
|
||||||
|
fun onAction(action: ErrorDemoAction) {
|
||||||
|
when (action) {
|
||||||
|
is ErrorDemoAction.OnForceError -> load(Attempt.Fail(action.scenario))
|
||||||
|
ErrorDemoAction.OnLoadSuccess -> load(Attempt.Success)
|
||||||
|
ErrorDemoAction.OnRetry -> load(lastAttempt)
|
||||||
|
ErrorDemoAction.OnBackClick -> viewModelScope.launch {
|
||||||
|
_events.send(ErrorDemoEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(attempt: Attempt) {
|
||||||
|
lastAttempt = attempt
|
||||||
|
_state.update { it.copy(isLoading = true, error = null, loaded = false) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(LOAD_DELAY_MS) // pretend a request is in flight, so the loading state is visible
|
||||||
|
simulate(attempt)
|
||||||
|
.onSuccess { _state.update { it.copy(isLoading = false, loaded = true, error = null) } }
|
||||||
|
.onFailure { dataError ->
|
||||||
|
// The crux of the demo: a DataError becomes user-facing UiText right here.
|
||||||
|
_state.update { it.copy(isLoading = false, error = dataError.toUiText()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun simulate(attempt: Attempt): Result<Unit, DataError.Network> = when (attempt) {
|
||||||
|
Attempt.Success -> Result.Success(Unit)
|
||||||
|
is Attempt.Fail -> Result.Error(attempt.scenario.toDataError())
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface Attempt {
|
||||||
|
data object Success : Attempt
|
||||||
|
data class Fail(val scenario: ErrorScenario) : Attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ErrorScenario.toDataError(): DataError.Network = when (this) {
|
||||||
|
ErrorScenario.NO_INTERNET -> DataError.Network.NO_INTERNET
|
||||||
|
ErrorScenario.NOT_FOUND -> DataError.Network.NOT_FOUND
|
||||||
|
ErrorScenario.SERVER_ERROR -> DataError.Network.SERVER_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LOAD_DELAY_MS = 400L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package com.example.architecture.feature.characters.presentation.di
|
package com.example.architecture.feature.characters.presentation.di
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||||
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
|
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
|
||||||
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||||
|
import com.example.architecture.feature.characters.presentation.ErrorDemoViewModel
|
||||||
|
import org.koin.core.module.dsl.factoryOf
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
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
|
||||||
|
// its CharacterRepository from charactersDataModule. See koin-constructor-dsl.
|
||||||
|
factoryOf(::GetCharactersPageUseCase)
|
||||||
viewModelOf(::CharacterListViewModel)
|
viewModelOf(::CharacterListViewModel)
|
||||||
viewModelOf(::CharacterDetailViewModel)
|
viewModelOf(::CharacterDetailViewModel)
|
||||||
|
viewModelOf(::ErrorDemoViewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import assertk.assertThat
|
||||||
|
import assertk.assertions.isEqualTo
|
||||||
|
import assertk.assertions.isFalse
|
||||||
|
import assertk.assertions.isNotNull
|
||||||
|
import assertk.assertions.isNull
|
||||||
|
import assertk.assertions.isSameInstanceAs
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
|
||||||
|
* by type-safe navigation), which is constructed directly here — proving the VM needs no navigation
|
||||||
|
* dependency. Collaborator is a [FakeCharacterRepository]; assertions use AssertK; the back event is
|
||||||
|
* observed with Turbine.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class CharacterDetailViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val repository = FakeCharacterRepository()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel(characterId: Int = 1) =
|
||||||
|
CharacterDetailViewModel(SavedStateHandle(mapOf("characterId" to characterId)), repository)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads details on init`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setDetails(characterDetails(1))
|
||||||
|
|
||||||
|
val viewModel = viewModel(characterId = 1)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.state.value
|
||||||
|
assertThat(state.isLoading).isFalse()
|
||||||
|
assertThat(state.error).isNull()
|
||||||
|
assertThat(state.details).isNotNull()
|
||||||
|
assertThat(state.details?.name).isEqualTo("Character 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.failWith = DataError.Network.SERVER_ERROR
|
||||||
|
|
||||||
|
val viewModel = viewModel(characterId = 1)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.state.value
|
||||||
|
assertThat(state.error).isNotNull()
|
||||||
|
assertThat(state.details).isNull()
|
||||||
|
assertThat(state.isLoading).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.failWith = DataError.Network.NO_INTERNET
|
||||||
|
val viewModel = viewModel(characterId = 1)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(viewModel.state.value.error).isNotNull()
|
||||||
|
|
||||||
|
// The next attempt will succeed.
|
||||||
|
repository.failWith = null
|
||||||
|
repository.setDetails(characterDetails(1))
|
||||||
|
viewModel.onAction(CharacterDetailAction.OnRetry)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.state.value
|
||||||
|
assertThat(state.error).isNull()
|
||||||
|
assertThat(state.details).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setDetails(characterDetails(1))
|
||||||
|
val viewModel = viewModel(characterId = 1)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.events.test {
|
||||||
|
viewModel.onAction(CharacterDetailAction.OnBackClick)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(awaitItem()).isSameInstanceAs(CharacterDetailEvent.NavigateBack)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing character id fails fast`() {
|
||||||
|
// The route contract: type-safe nav must have written characterId into SavedStateHandle.
|
||||||
|
assertThrows<IllegalStateException> {
|
||||||
|
CharacterDetailViewModel(SavedStateHandle(), repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import assertk.assertThat
|
||||||
|
import assertk.assertions.hasSize
|
||||||
|
import assertk.assertions.isEmpty
|
||||||
|
import assertk.assertions.isEqualTo
|
||||||
|
import assertk.assertions.isFalse
|
||||||
|
import assertk.assertions.isInstanceOf
|
||||||
|
import assertk.assertions.isNotNull
|
||||||
|
import assertk.assertions.isNull
|
||||||
|
import assertk.assertions.isTrue
|
||||||
|
import assertk.assertions.prop
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [CharacterListViewModel] — driven entirely through its public MVI surface
|
||||||
|
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
|
||||||
|
*
|
||||||
|
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
|
||||||
|
* which lets the duplicate-paging test observe the *synchronous* loading-flag guard before any
|
||||||
|
* coroutine runs. Collaborator is a [FakeCharacterRepository] (a fake, not a mock); `state`/`events`
|
||||||
|
* are observed with Turbine; assertions use AssertK.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class CharacterListViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val repository = FakeCharacterRepository()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()) =
|
||||||
|
CharacterListViewModel(GetCharactersPageUseCase(repository), savedStateHandle)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads the first page on init`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
|
||||||
|
|
||||||
|
val viewModel = viewModel()
|
||||||
|
|
||||||
|
viewModel.state.test {
|
||||||
|
// restore() flips isLoading synchronously during construction, before the coroutine runs.
|
||||||
|
assertThat(awaitItem()).isEqualTo(CharacterListState(isLoading = true))
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val loaded = awaitItem()
|
||||||
|
assertThat(loaded).prop(CharacterListState::characters).hasSize(2)
|
||||||
|
assertThat(loaded).prop(CharacterListState::isLoading).isFalse()
|
||||||
|
assertThat(loaded).prop(CharacterListState::currentPage).isEqualTo(1)
|
||||||
|
assertThat(loaded).prop(CharacterListState::endReached).isFalse()
|
||||||
|
assertThat(loaded).prop(CharacterListState::error).isNull()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial load failure emits a snackbar event and a full-screen error`() =
|
||||||
|
runTest(dispatcher.scheduler) {
|
||||||
|
repository.failWith = DataError.Network.NO_INTERNET
|
||||||
|
|
||||||
|
val viewModel = viewModel()
|
||||||
|
|
||||||
|
viewModel.events.test {
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::error).isNotNull()
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not load past the last page`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
|
||||||
|
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null) // last page
|
||||||
|
|
||||||
|
val viewModel = viewModel()
|
||||||
|
advanceUntilIdle() // init → page 1
|
||||||
|
|
||||||
|
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
advanceUntilIdle() // → page 2, end reached
|
||||||
|
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue()
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||||
|
|
||||||
|
val callsBefore = repository.getCharactersCallCount
|
||||||
|
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
advanceUntilIdle() // guarded by endReached → no request
|
||||||
|
|
||||||
|
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
|
||||||
|
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
|
||||||
|
|
||||||
|
val viewModel = viewModel()
|
||||||
|
advanceUntilIdle() // init → page 1
|
||||||
|
val callsBefore = repository.getCharactersCallCount
|
||||||
|
|
||||||
|
// Both fire before any launched coroutine runs; the second sees the synchronously-set
|
||||||
|
// isLoadingNextPage flag and is guarded out.
|
||||||
|
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ignores a next-page request while the initial load is in flight`() =
|
||||||
|
runTest(dispatcher.scheduler) {
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
|
||||||
|
|
||||||
|
val viewModel = viewModel()
|
||||||
|
// restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this
|
||||||
|
// OnLoadNextPage hits the `isLoading` guard in loadNextPage() and is dropped.
|
||||||
|
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Only the single initial load ran — the guarded next-page request never fired.
|
||||||
|
assertThat(repository.getCharactersCallCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.failWith = DataError.Network.NO_INTERNET
|
||||||
|
val viewModel = viewModel()
|
||||||
|
|
||||||
|
viewModel.events.test {
|
||||||
|
advanceUntilIdle()
|
||||||
|
// The initial-load failure surfaces as a snackbar; consuming it is also how the
|
||||||
|
// rendezvous-Channel send in restore() completes so state can settle.
|
||||||
|
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty()
|
||||||
|
|
||||||
|
// Empty branch of retry(): the repository recovers, OnRetry rebuilds from page 1.
|
||||||
|
repository.failWith = null
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2)
|
||||||
|
viewModel.onAction(CharacterListAction.OnRetry)
|
||||||
|
advanceUntilIdle()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
|
||||||
|
val viewModel = viewModel()
|
||||||
|
advanceUntilIdle() // page 1 loaded (no event)
|
||||||
|
|
||||||
|
viewModel.events.test {
|
||||||
|
// Page 2 isn't configured yet → next-page load fails; list keeps page 1, shows an error.
|
||||||
|
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(1)
|
||||||
|
|
||||||
|
// Non-empty branch of retry(): with page 2 now available, OnRetry re-requests page 2 and
|
||||||
|
// appends it (currentPage stayed 1 because loadPage only advances on success).
|
||||||
|
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null)
|
||||||
|
viewModel.onAction(CharacterListAction.OnRetry)
|
||||||
|
advanceUntilIdle()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::error).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) {
|
||||||
|
repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2)
|
||||||
|
repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3)
|
||||||
|
// Navigation/SavedStateHandle persisted the last loaded page across process death.
|
||||||
|
val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2))
|
||||||
|
|
||||||
|
val viewModel = viewModel(savedStateHandle)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Both pages are rebuilt (1 then 2), and currentPage is restored.
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2)
|
||||||
|
assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.Character
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [CharacterRepository] for ViewModel tests — a **fake**, not a mock: it has real behaviour
|
||||||
|
* (returns configured pages/details, counts calls, can be flipped to fail) so tests assert against a
|
||||||
|
* working collaborator instead of recording interactions. Configure pages via [setPage]/[setDetails];
|
||||||
|
* set [failWith] to make every call fail with a specific [DataError].
|
||||||
|
*/
|
||||||
|
class FakeCharacterRepository : CharacterRepository {
|
||||||
|
|
||||||
|
/** When non-null, every call fails with this error (overrides any configured data). */
|
||||||
|
var failWith: DataError? = null
|
||||||
|
|
||||||
|
var getCharactersCallCount = 0
|
||||||
|
private set
|
||||||
|
var getCharacterDetailsCallCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val pages = mutableMapOf<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,
|
||||||
|
)
|
||||||
@@ -36,12 +36,10 @@ timber = "5.0.1"
|
|||||||
material = "1.12.0"
|
material = "1.12.0"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
junit4 = "4.13.2"
|
|
||||||
junitJupiter = "5.11.4"
|
junitJupiter = "5.11.4"
|
||||||
androidJunit5 = "1.11.4"
|
junitPlatform = "1.11.4"
|
||||||
turbine = "1.2.0"
|
turbine = "1.2.0"
|
||||||
assertk = "0.28.1"
|
assertk = "0.28.1"
|
||||||
androidxTest = "1.7.0"
|
|
||||||
androidxTestExt = "1.3.0"
|
androidxTestExt = "1.3.0"
|
||||||
androidxTestRunner = "1.7.0"
|
androidxTestRunner = "1.7.0"
|
||||||
androidxEspresso = "3.7.0"
|
androidxEspresso = "3.7.0"
|
||||||
@@ -97,7 +95,6 @@ koin-core = { module = "io.insert-koin:koin-core" }
|
|||||||
koin-android = { module = "io.insert-koin:koin-android" }
|
koin-android = { module = "io.insert-koin:koin-android" }
|
||||||
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
|
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
|
||||||
koin-test = { module = "io.insert-koin:koin-test" }
|
koin-test = { module = "io.insert-koin:koin-test" }
|
||||||
koin-test-junit5 = { module = "io.insert-koin:koin-test-junit5" }
|
|
||||||
|
|
||||||
# --- Ktor ---
|
# --- Ktor ---
|
||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
@@ -116,13 +113,12 @@ coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
|
|||||||
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||||
|
|
||||||
# --- Testing ---
|
# --- Testing ---
|
||||||
junit4 = { module = "junit:junit", version.ref = "junit4" }
|
|
||||||
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" }
|
||||||
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junitJupiter" }
|
# Gradle 9 no longer bundles the launcher — it must be on the test runtime classpath explicitly.
|
||||||
|
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
|
||||||
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
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" }
|
||||||
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" }
|
|
||||||
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" }
|
||||||
@@ -147,6 +143,16 @@ 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"]
|
||||||
|
# Instrumented Compose UI test (androidTest): ComposeTestRule + AndroidJUnit4 runner.
|
||||||
|
# espresso-core/runner are pinned to current versions: Compose's test rule drives Espresso's
|
||||||
|
# onIdle, and the transitive espresso 3.5.0 calls InputManager.getInstance() (removed on API 34+),
|
||||||
|
# which crashes on modern devices. 3.7.0 fixes that reflection.
|
||||||
|
compose-ui-test = [
|
||||||
|
"androidx-compose-ui-test-junit4",
|
||||||
|
"androidx-test-ext-junit",
|
||||||
|
"androidx-test-espresso-core",
|
||||||
|
"androidx-test-runner",
|
||||||
|
]
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
# Upstream plugins
|
# Upstream plugins
|
||||||
@@ -155,8 +161,6 @@ android-library = { id = "com.android.library", version.ref = "agp" }
|
|||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
# Declared for milestone 5 (ViewModel/Compose tests on Android); wired when tests land.
|
|
||||||
android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" }
|
|
||||||
|
|
||||||
# Convention plugins (defined in :build-logic, resolved from the included build)
|
# Convention plugins (defined in :build-logic, resolved from the included build)
|
||||||
architecture-android-application = { id = "architecture.android.application" }
|
architecture-android-application = { id = "architecture.android.application" }
|
||||||
@@ -164,6 +168,7 @@ architecture-android-library = { id = "architecture.android.library" }
|
|||||||
architecture-android-feature = { id = "architecture.android.feature" }
|
architecture-android-feature = { id = "architecture.android.feature" }
|
||||||
architecture-android-feature-views = { id = "architecture.android.feature.views" }
|
architecture-android-feature-views = { id = "architecture.android.feature.views" }
|
||||||
architecture-domain-module = { id = "architecture.domain.module" }
|
architecture-domain-module = { id = "architecture.domain.module" }
|
||||||
|
architecture-android-unit-test = { id = "architecture.android.unit.test" }
|
||||||
architecture-compose = { id = "architecture.compose" }
|
architecture-compose = { id = "architecture.compose" }
|
||||||
architecture-koin = { id = "architecture.koin" }
|
architecture-koin = { id = "architecture.koin" }
|
||||||
architecture-ktor = { id = "architecture.ktor" }
|
architecture-ktor = { id = "architecture.ktor" }
|
||||||
|
|||||||
Reference in New Issue
Block a user