Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions blueprints/list-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ where implementations meet.
| `:app:web` | Wasm entrypoint, HTML shell, web manifest, and browser icons |
| `app/ios` | SwiftUI/Xcode shell and iOS asset catalog |
| `:app-framework:impl` | Root scope, Metro graph assembly, template stream, and shared platform integration |
| `:app-framework:impl-ui-test-robots` | Android and Desktop test graphs, lifecycle fixtures, and UI-test rules |
| `:app-framework:impl-ui-test-robots` | Android and Desktop test graphs, lifecycle fixtures, and UI and headless test rules |
| `:list-detail:public` | Reusable feature contract, character entities, and repository interface |
| `:list-detail:impl` | Repository implementation, presenters, render models, Compose renderers, and resources |
| `:list-detail:impl-robots` | Shared, layout-agnostic Compose robots for list and detail interactions |
Expand Down Expand Up @@ -304,7 +304,8 @@ interfaces remain in `commonMain`.

## Testing

The sample separates presenter tests, reusable robot APIs, and platform integration tests.
The sample separates presenter tests, reusable robot APIs, rendered platform integration tests, and
headless Desktop integration tests.

### Presenter tests

Expand Down Expand Up @@ -346,6 +347,9 @@ feature robot module and provides:
presentation, disables system animations, and destroys the root scope after each test.
- `DesktopUiTestRule`, which creates a fresh `DesktopApp`, installs its root scope for robot
lookup, renders at a controlled phone or tablet size, and tears down the application.
- `DesktopHeadlessTestRule`, which starts the same production-backed test graph and root template
stream, reports a controlled phone or tablet size, and uses an immediate test-owned Molecule
scope to observe templates without rendering a Compose scene or polling for state.

Production app modules depend on these modules only from test configurations. Robot code and test
graphs therefore never enter application artifacts.
Expand All @@ -357,38 +361,46 @@ The integration scenarios themselves are intentionally not shared:
```text
app/android/src/androidTest/.../ListDetailAndroidUiTest.kt
app/desktop/src/desktopTest/.../ListDetailDesktopUiTest.kt
app-framework/impl-ui-test-robots/src/desktopTest/.../ListDetailDesktopHeadlessTest.kt
```

Each platform has its own test class and lifecycle, while only the layout-agnostic robot vocabulary
is reused. The suites provide three platform tests across two end-to-end behaviors:
Each test mode has its own class and lifecycle. UI suites reuse the layout-agnostic robot
vocabulary, while the headless Desktop suite observes `AppTemplate.FullScreenTemplate`, unwraps
delegated presenter models, and invokes production model callbacks directly. The suites provide
five platform tests across two end-to-end behaviors:

1. Android and Desktop each verify that a phone opens Samwise's detail through the presenter
backstack and returns to the list.
2. Desktop additionally verifies that a landscape tablet starts with Frodo selected and replaces
the detail with Aragorn without exposing back navigation.
1. Android, rendered Desktop, and headless Desktop each verify that a phone opens Samwise's detail
through the presenter backstack and returns to the list.
2. Rendered and headless Desktop each verify that a landscape tablet starts with Frodo selected and
replaces the detail with Aragorn without exposing back navigation.

Android tests use a custom instrumentation runner and test application to install the robot-aware
Metro graph. Gradle Managed Devices runs them on a Pixel 3 API 30 `aosp-atd` image through AndroidX
Test Orchestrator. Android does not force an orientation or emulate tablet coverage; its device
configuration determines the presentation. Desktop tests run against controlled Compose test
scenes matching the production phone and tablet window presets.
configuration determines the presentation. Rendered Desktop tests use controlled Compose test
scenes matching the production phone and tablet window presets. Headless Desktop tests report
those same presets directly to the production screen-size provider and exercise the real Metro
graph, repository, application scope, template presenter, and feature presenters without rendering
UI.

There are deliberately no iOS integration tests or shared cross-platform integration-test source
sets in this blueprint.

Run the integration suites independently:

```bash
./gradlew :app-framework:impl-ui-test-robots:desktopTest
./gradlew :app:desktop:desktopTest
./gradlew :app:android:emulatorCheck
```

The GitHub Actions workflow gives Android and Desktop integration tests separate jobs. The Desktop
unit-test job discovers every `desktopTest` task and excludes only the app integration-test task,
so future library modules are covered automatically. Android prepares hardware acceleration before
starting the managed emulator, and both integration jobs upload test reports on failure. Unit
tests, static analysis, module checks, and platform builds remain separate so failures identify the
affected architectural layer.
The GitHub Actions workflow gives Android and rendered Desktop integration tests separate jobs. The
Desktop test job discovers every `desktopTest` task and excludes only the app's rendered integration
task, so it automatically runs the headless application-framework tests and covers future library
modules. Android prepares hardware acceleration before starting the managed emulator, and both
rendered integration jobs upload test reports on failure. Headless tests, rendered tests, static
analysis, module checks, and platform builds remain separate so failures identify the affected
architectural layer.

## Extending the blueprint

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
appPlatform {
enableComposeUi true
enableMetro true
enableMoleculePresenters true
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package software.ralf.app.platform.listdetail

import androidx.compose.ui.unit.DpSize
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.createGraphFactory
import kotlinx.coroutines.test.runTest
import software.ralf.app.platform.listdetail.screen.DefaultScreenSizeProvider
import software.ralf.app.platform.listdetail.screen.ScreenSize
import software.ralf.app.platform.listdetail.templates.AppTemplate
import software.ralf.app.platform.presenter.molecule.moleculeScope
import software.ralf.app.platform.scope.di.metro.metroDependencyGraph

/**
* Desktop integration-test fixture that observes production templates without rendering UI.
*
* Each test starts a fresh application, production-backed Metro graph, and root presenter stream.
* The production window preset is reported directly to the screen-size provider before the stream
* starts. A test-owned Molecule scope produces template emissions under the test scheduler,
* allowing tests to exercise either adaptive presentation without a Compose scene or polling.
*/
class DesktopHeadlessTestRule {
/** Runs [block] against the templates produced for the production phone window preset. */
fun runPhoneTest(block: suspend ReceiveTurbine<AppTemplate>.() -> Unit) {
runHeadlessTest(windowSize = DesktopWindowSizes.phone, block = block)
}

/** Runs [block] against the templates produced for the production tablet window preset. */
fun runTabletTest(block: suspend ReceiveTurbine<AppTemplate>.() -> Unit) {
runHeadlessTest(windowSize = DesktopWindowSizes.tablet, block = block)
}

private fun runHeadlessTest(
windowSize: DpSize,
block: suspend ReceiveTurbine<AppTemplate>.() -> Unit,
) {
runTest {
val application = Application()
application.create(createGraphFactory<TestDesktopAppGraph.Factory>().create(application))

val graph = application.rootScope.metroDependencyGraph<Graph>()
graph.screenSizeProvider.update(ScreenSize.from(windowSize.width, windowSize.height))
val templateProvider = graph.templateProviderInternalFactory.create(moleculeScope())

try {
templateProvider.templates.test { block() }
} finally {
templateProvider.cancel()
application.destroy()
}
}
}

/** Production dependencies needed to configure and observe the headless application. */
@ContributesTo(AppScope::class)
interface Graph {
/** App-scoped provider that receives the controlled headless window dimensions. */
val screenSizeProvider: DefaultScreenSizeProvider

/** Factory for creating the production template provider with a deterministic test scope. */
val templateProviderInternalFactory: TemplateProvider.InternalFactory
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package software.ralf.app.platform.listdetail

import app.cash.turbine.ReceiveTurbine
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import kotlin.test.Test
import software.ralf.app.platform.listdetail.presenternavigation.DefaultBackstackModel
import software.ralf.app.platform.listdetail.templates.AppTemplate
import software.ralf.app.platform.presenter.BaseModel
import software.ralf.app.platform.presenter.template.ModelDelegate

/** Desktop integration tests that drive the full application through its template models. */
class ListDetailDesktopHeadlessTest {
private val headlessTestRule = DesktopHeadlessTestRule()

@Test
fun `phone opens character detail and returns to list`() = headlessTestRule.runPhoneTest {
val listModel = awaitPhoneList()
listModel.requireCharacter("samwise")
listModel.selectCharacter("samwise")

val detailModel = awaitPhoneDetail()
detailModel.assertCharacter(name = "Samwise Gamgee", ageAtRingDestruction = "38")
assertThat(detailModel.showBackButton).isTrue()

detailModel.onBack()

awaitPhoneList().requireCharacter("samwise")
}

@Test
fun `tablet updates detail without phone navigation`() = headlessTestRule.runTabletTest {
val initialModel = awaitTabletContent()

initialModel.listModel.requireCharacter("aragorn")
assertThat(initialModel.listModel.selectedCharacterId).isEqualTo("frodo")
initialModel.detailModel.assertCharacter(
name = "Frodo Baggins",
ageAtRingDestruction = "50",
)
assertThat(initialModel.detailModel.showBackButton).isFalse()

initialModel.listModel.selectCharacter("aragorn")

val updatedModel = awaitTabletContent(selectedCharacterId = "aragorn")

assertThat(updatedModel.listModel.selectedCharacterId).isEqualTo("aragorn")
updatedModel.detailModel.assertCharacter(name = "Aragorn", ageAtRingDestruction = "88")
assertThat(updatedModel.detailModel.showBackButton).isFalse()
}

private suspend fun ReceiveTurbine<AppTemplate>.awaitPhoneList(): CharacterListPresenter.Model {
val backstack = awaitPhoneBackstack(size = 1)

return requireInstance(backstack.backstack.single())
}

private suspend fun ReceiveTurbine<AppTemplate>.awaitPhoneDetail():
CharacterDetailPresenter.Model {
val backstack = awaitPhoneBackstack(size = 2)

return requireInstance(backstack.backstack.last())
}

private suspend fun ReceiveTurbine<AppTemplate>.awaitPhoneBackstack(
size: Int
): DefaultBackstackModel {
while (true) {
val template = requireInstance<AppTemplate.FullScreenTemplate>(awaitItem())
val backstack = requireInstance<DefaultBackstackModel>(template.model.delegatedModel())

if (backstack.backstack.size == size) {
assertThat(backstack.backstack).hasSize(size)

return backstack
}
}
}

private suspend fun ReceiveTurbine<AppTemplate>.awaitTabletContent(
selectedCharacterId: String? = null
): TabletListDetailPresenter.Model.Content {
while (true) {
val template = requireInstance<AppTemplate.FullScreenTemplate>(awaitItem())
val model =
requireInstance<TabletListDetailPresenter.Model.Content>(template.model.delegatedModel())

if (
selectedCharacterId == null || model.listModel.selectedCharacterId == selectedCharacterId
) {
return model
}
}
}

private fun BaseModel.delegatedModel(): BaseModel {
var model = this

while (model is ModelDelegate) {
val delegatedModel = model.delegate()

if (delegatedModel === model) {
break
}

model = delegatedModel
}

return model
}

private inline fun <reified T : Any> requireInstance(value: Any): T {
assertThat(value).isInstanceOf<T>()

return value as T
}

private fun CharacterListPresenter.Model.requireCharacter(characterId: String): Character {
val character = characters.firstOrNull { it.id == characterId }

assertThat(character, name = "character $characterId").isNotNull()

return character as Character
}

private fun CharacterListPresenter.Model.selectCharacter(characterId: String) {
onCharacterSelected(requireCharacter(characterId))
}

private fun CharacterDetailPresenter.Model.assertCharacter(
name: String,
ageAtRingDestruction: String,
) {
val availableState = requireInstance<CharacterDetailPresenter.State.Available>(state)

assertThat(availableState.character.name).isEqualTo(name)
assertThat(availableState.character.ageAtRingDestruction).isEqualTo(ageAtRingDestruction)
}
}
Loading