From 3d5b55faa3415b9339d56d60264ae118ed68dc19 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 22 Sep 2026 12:23:55 +0800 Subject: [PATCH] test(mutual): Robolectric Compose UI tests for prefill scroll; bump 4.4.7-ba7opf.12.2 MutualMatchPrefillScrollTest verifies the map grid-QSO Match prefill: time-range card lands at the top after scrollToItem, default open stays at station inputs, and the query auto-starts with grid+24h pre-filled. Adds Robolectric + compose ui-test-junit4 to the mutual test classpath. --- feature/mutual/build.gradle.kts | 17 ++- .../mutual/MutualMatchPrefillScrollTest.kt | 130 ++++++++++++++++++ gradle/libs.versions.toml | 9 +- 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualMatchPrefillScrollTest.kt diff --git a/feature/mutual/build.gradle.kts b/feature/mutual/build.gradle.kts index 7ef105f9..dc5a884f 100644 --- a/feature/mutual/build.gradle.kts +++ b/feature/mutual/build.gradle.kts @@ -4,4 +4,19 @@ plugins { android { namespace = "com.rtbishop.look4sat.feature.mutual" -} \ No newline at end of file + testOptions { + unitTests { + // Robolectric needs real Android resources for Compose UI tests. + isIncludeAndroidResources = true + } + } +} + +dependencies { + testImplementation(libs.test.junit4) + testImplementation(libs.test.coroutines) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.debug.manifest) +} diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualMatchPrefillScrollTest.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualMatchPrefillScrollTest.kt new file mode 100644 index 00000000..59d00e51 --- /dev/null +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualMatchPrefillScrollTest.kt @@ -0,0 +1,130 @@ +package com.rtbishop.look4sat.feature.mutual + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Verifies the map grid-QSO dialog "Match" prefill: after + * [MutualViewModel.prefillMatchFromGrid] the page opens with the time-range + * card at the top and the 24h query auto-started. + * + * Note: `LazyListState(firstVisibleItemIndex = 1)` is deliberately NOT used to + * position the page. In this Compose version (BOM 2026.06.01 / ui 1.11.x) the + * constructor parameter is ignored — the list still starts at item 0 — while + * `listState.scrollToItem(1)` from a LaunchedEffect lands correctly. See + * [scrollToItemLandsAtTop] below, which guards the scrollToItem mechanism. + * + * Robolectric quirk: `assertIsNotDisplayed()` is unreliable here (a node + * scrolled fully out of the viewport can still report "displayed"), so the + * prefill test asserts the time-range card IS displayed (positive assertion) + * rather than that the station card is NOT. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +class MutualMatchPrefillScrollTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun sanity_textRenders() { + composeRule.setContent { + MaterialTheme { + Text("sanity-check-text") + } + } + composeRule.onNodeWithText("sanity-check-text").assertIsDisplayed() + } + + @Test + fun scrollToItemLandsAtTop() { + // Guards the mechanism the prefill relies on: scrollToItem(1) from a + // LaunchedEffect moves item 1 to the top of the viewport. + composeRule.setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + val state = remember { LazyListState(firstVisibleItemIndex = 0) } + LaunchedEffect(Unit) { state.scrollToItem(1) } + LazyColumn(state = state) { + item { Text("min-item-0") } + item { Text("min-item-1") } + item { Text("min-item-2") } + } + } + } + } + composeRule.waitForIdle() + composeRule.onNodeWithText("min-item-1").assertIsDisplayed() + } + + @Test + fun defaultOpen_showsStationInputsAtTop() { + val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo()) + composeRule.setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + MutualScreen(viewModel = vm) + } + } + } + // Without a prefill the list starts at item 0 (station inputs). + composeRule.onNodeWithText("Your Station").assertIsDisplayed() + } + + @Test + fun prefillFromMap_opensAtTimeRangeCard() { + val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo()) + // Same call the map grid-QSO "Match" button makes. + vm.prefillMatchFromGrid("OL62") + + composeRule.setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + MutualScreen(viewModel = vm) + } + } + } + + composeRule.waitForIdle() + + // The time-range card must be visible at the top of the page after the + // prefill scroll. + composeRule.onNodeWithText("Time range").assertIsDisplayed() + } + + @Test + fun prefillAutoStartsQueryAndFillsState() { + val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo()) + vm.prefillMatchFromGrid("OL62") + val s = vm.uiState.value + // Query auto-started: with the empty-satellite fake the query reaches + // the "no satellite data" guard (rather than never being triggered), + // proving prefillMatchFromGrid kicks off queryMutualPasses. + assertTrue( + "auto-query should reach the satellite guard: gridA=${s.stationAGrid} " + + "gridB=${s.stationBGrid} err=${s.errorMessage}", + s.errorMessage?.contains("satellite", ignoreCase = true) == true + ) + // ...and the target grid + 24h range pre-filled. + org.junit.Assert.assertEquals("OL62", s.stationBGrid) + org.junit.Assert.assertEquals(24, s.hoursAhead) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e161498..2e568a67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "487" +appVersionCode = "488" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.7-ba7opf.12.1" +appVersionName = "4.4.7-ba7opf.12.2" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry @@ -33,6 +33,8 @@ other-okhttp = "5.4.0" other-osmdroid = "6.1.20" test-junit4 = "4.13.2" +robolectric = "4.16" +androidx-test-core = "1.7.0" androidTest-junit = "1.3.0" androidTest-espresso = "3.7.0" @@ -79,6 +81,9 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } test-junit4 = { module = "junit:junit", version.ref = "test-junit4" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } # Real org.json for unit tests (android.jar stub throws "not mocked") test-json = { module = "org.json:json", version = "20240303" }