From 45d2fd3ae43326445c3792f66f179f4ec8e7aacf Mon Sep 17 00:00:00 2001 From: Ralf Wondratschek Date: Mon, 27 Jul 2026 10:36:17 -0700 Subject: [PATCH] Add headless list-detail integration tests Make full-application list-detail scenarios fast and deterministic without requiring rendered UI, animation timing, or state polling. Keep the real application graph and template lifecycle in `:app-framework:impl-ui-test-robots` so the desktop application does not acquire test-only presenter dependencies. --- blueprints/list-detail/README.md | 44 ++++-- .../impl-ui-test-robots/build.gradle | 1 + .../listdetail/DesktopHeadlessTestRule.kt | 65 ++++++++ .../ListDetailDesktopHeadlessTest.kt | 144 ++++++++++++++++++ 4 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopMain/kotlin/software/ralf/app/platform/listdetail/DesktopHeadlessTestRule.kt create mode 100644 blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopTest/kotlin/software/ralf/app/platform/listdetail/ListDetailDesktopHeadlessTest.kt diff --git a/blueprints/list-detail/README.md b/blueprints/list-detail/README.md index 1e3e2d2b..d2a198b2 100644 --- a/blueprints/list-detail/README.md +++ b/blueprints/list-detail/README.md @@ -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 | @@ -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 @@ -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. @@ -357,21 +361,27 @@ 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. @@ -379,16 +389,18 @@ 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 diff --git a/blueprints/list-detail/app-framework/impl-ui-test-robots/build.gradle b/blueprints/list-detail/app-framework/impl-ui-test-robots/build.gradle index 619b2ab2..a6f2ef30 100644 --- a/blueprints/list-detail/app-framework/impl-ui-test-robots/build.gradle +++ b/blueprints/list-detail/app-framework/impl-ui-test-robots/build.gradle @@ -5,6 +5,7 @@ plugins { appPlatform { enableComposeUi true enableMetro true + enableMoleculePresenters true } dependencies { diff --git a/blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopMain/kotlin/software/ralf/app/platform/listdetail/DesktopHeadlessTestRule.kt b/blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopMain/kotlin/software/ralf/app/platform/listdetail/DesktopHeadlessTestRule.kt new file mode 100644 index 00000000..3c0248ca --- /dev/null +++ b/blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopMain/kotlin/software/ralf/app/platform/listdetail/DesktopHeadlessTestRule.kt @@ -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.() -> Unit) { + runHeadlessTest(windowSize = DesktopWindowSizes.phone, block = block) + } + + /** Runs [block] against the templates produced for the production tablet window preset. */ + fun runTabletTest(block: suspend ReceiveTurbine.() -> Unit) { + runHeadlessTest(windowSize = DesktopWindowSizes.tablet, block = block) + } + + private fun runHeadlessTest( + windowSize: DpSize, + block: suspend ReceiveTurbine.() -> Unit, + ) { + runTest { + val application = Application() + application.create(createGraphFactory().create(application)) + + val graph = application.rootScope.metroDependencyGraph() + 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 + } +} diff --git a/blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopTest/kotlin/software/ralf/app/platform/listdetail/ListDetailDesktopHeadlessTest.kt b/blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopTest/kotlin/software/ralf/app/platform/listdetail/ListDetailDesktopHeadlessTest.kt new file mode 100644 index 00000000..17aa0f0f --- /dev/null +++ b/blueprints/list-detail/app-framework/impl-ui-test-robots/src/desktopTest/kotlin/software/ralf/app/platform/listdetail/ListDetailDesktopHeadlessTest.kt @@ -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.awaitPhoneList(): CharacterListPresenter.Model { + val backstack = awaitPhoneBackstack(size = 1) + + return requireInstance(backstack.backstack.single()) + } + + private suspend fun ReceiveTurbine.awaitPhoneDetail(): + CharacterDetailPresenter.Model { + val backstack = awaitPhoneBackstack(size = 2) + + return requireInstance(backstack.backstack.last()) + } + + private suspend fun ReceiveTurbine.awaitPhoneBackstack( + size: Int + ): DefaultBackstackModel { + while (true) { + val template = requireInstance(awaitItem()) + val backstack = requireInstance(template.model.delegatedModel()) + + if (backstack.backstack.size == size) { + assertThat(backstack.backstack).hasSize(size) + + return backstack + } + } + } + + private suspend fun ReceiveTurbine.awaitTabletContent( + selectedCharacterId: String? = null + ): TabletListDetailPresenter.Model.Content { + while (true) { + val template = requireInstance(awaitItem()) + val model = + requireInstance(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 requireInstance(value: Any): T { + assertThat(value).isInstanceOf() + + 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(state) + + assertThat(availableState.character.name).isEqualTo(name) + assertThat(availableState.character.ageAtRingDestruction).isEqualTo(ageAtRingDestruction) + } +}