From 11def98a5e9522f5f60cdafecc3aee70dcdefb37 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:55:17 -0600 Subject: [PATCH 1/3] test(utils): add JVM unit and composable integration tests for Clustering and WmsTileOverlay --- maps-compose-utils/build.gradle.kts | 21 ++ .../src/debug/AndroidManifest.xml | 23 ++ .../compose/clustering/ClusteringTest.kt | 199 ++++++++++++++++++ .../android/compose/wms/WmsTileOverlayTest.kt | 94 +++++++++ .../compose/wms/WmsUrlTileProviderTest.kt | 32 ++- 5 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 maps-compose-utils/src/debug/AndroidManifest.xml create mode 100644 maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt create mode 100644 maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsTileOverlayTest.kt diff --git a/maps-compose-utils/build.gradle.kts b/maps-compose-utils/build.gradle.kts index 0e3f83df..1b128425 100644 --- a/maps-compose-utils/build.gradle.kts +++ b/maps-compose-utils/build.gradle.kts @@ -50,6 +50,12 @@ android { enableAndroidTestCoverage = true } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } kotlin { @@ -86,4 +92,19 @@ dependencies { api(libs.maps.ktx.utils) testImplementation(libs.test.junit) + testImplementation(libs.robolectric) + testImplementation(libs.truth) + testImplementation(libs.mockk) + testImplementation(libs.androidx.test.compose.ui) + testImplementation(libs.androidx.activity.compose) + testImplementation(libs.androidx.test.core) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.maps.robolectric.shadows) } + +tasks.withType().configureEach { + javaLauncher.set(javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }) +} + diff --git a/maps-compose-utils/src/debug/AndroidManifest.xml b/maps-compose-utils/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..b106adac --- /dev/null +++ b/maps-compose-utils/src/debug/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt new file mode 100644 index 00000000..767a394b --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.clustering + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.android.maps.robolectric.annotation.EnableMapsShadows +import com.google.android.maps.robolectric.shadows.ShadowGoogleMap +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.view.ClusterRenderer +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapEffect +import com.google.maps.android.compose.MapsComposeExperimentalApi +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.shadow.api.Shadow + +// [START maps_compose_utils_clustering_test] +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@EnableMapsShadows +@OptIn(MapsComposeExperimentalApi::class) +internal class ClusteringTest { + + @get:Rule + internal val composeTestRule: AndroidComposeTestRule, ComponentActivity> = + createAndroidComposeRule() + + private data class SampleClusterItem( + override val position: LatLng, + override val title: String? = null, + override val snippet: String? = null, + override val zIndex: Float? = null, + ) : ClusterItem + + @Test + internal fun testClusteringMarkerProperties_updatesCompositionLocal(): Unit { + val properties = ClusteringMarkerProperties() + assertThat(properties.anchor).isNull() + assertThat(properties.zIndex).isNull() + assertThat(properties.rotation).isNull() + + composeTestRule.setContent { + CompositionLocalProvider(LocalClusteringMarkerProperties provides properties) { + ClusteringMarkerProperties( + anchor = Offset(0.5f, 0.5f), + zIndex = 3.0f, + rotation = 45f + ) + } + } + composeTestRule.waitForIdle() + + assertThat(properties.anchor).isEqualTo(Offset(0.5f, 0.5f)) + assertThat(properties.zIndex).isEqualTo(3.0f) + assertThat(properties.rotation).isEqualTo(45f) + } + + @Test + internal fun testClustering_defaultRenderer_rendersItemsAndClusters(): Unit { + var underlyingMap: GoogleMap? = null + var managerFromCallback: ClusterManager? = null + val items = listOf( + SampleClusterItem(LatLng(37.7749, -122.4194), "SF 1"), + SampleClusterItem(LatLng(37.7750, -122.4195), "SF 2"), + SampleClusterItem(LatLng(40.7128, -74.0060), "NYC") + ) + + composeTestRule.setContent { + GoogleMap { + MapEffect(Unit) { map -> + underlyingMap = map + } + Clustering( + items = items, + onClusterManager = { manager -> + managerFromCallback = manager + } + ) + } + } + + composeTestRule.waitForIdle() + + assertThat(underlyingMap).isNotNull() + val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap + assertThat(managerFromCallback).isNotNull() + + // ClusterManager should have added markers to the map + assertThat(shadowMap.markers.isNotEmpty()).isTrue() + } + + @Test + internal fun testClustering_withCustomComposeContent(): Unit { + var decoratedItem: SampleClusterItem? = null + val items = listOf( + SampleClusterItem(LatLng(37.7749, -122.4194), "SF Point") + ) + + composeTestRule.setContent { + GoogleMap { + Clustering( + items = items, + clusterContent = { cluster -> + ClusteringMarkerProperties(rotation = 10f) + }, + clusterItemContent = { item -> + ClusteringMarkerProperties(rotation = 20f) + }, + clusterItemDecoration = { item -> + decoratedItem = item + } + ) + } + } + + composeTestRule.waitForIdle() + assertThat(decoratedItem).isEqualTo(items.first()) + } + + @Test + internal fun testClustering_dynamicItemUpdates(): Unit { + var itemsState by mutableStateOf( + listOf(SampleClusterItem(LatLng(10.0, 10.0), "Initial")) + ) + var clusterManager: ClusterManager? = null + + composeTestRule.setContent { + GoogleMap { + Clustering( + items = itemsState, + onClusterManager = { clusterManager = it } + ) + } + } + + composeTestRule.waitForIdle() + assertThat(clusterManager).isNotNull() + assertThat(clusterManager!!.algorithm.items).hasSize(1) + + // Update items list + itemsState = listOf( + SampleClusterItem(LatLng(20.0, 20.0), "Updated 1"), + SampleClusterItem(LatLng(20.1, 20.1), "Updated 2") + ) + composeTestRule.waitForIdle() + + assertThat(clusterManager.algorithm.items).hasSize(2) + } + + @Test + internal fun testRememberClusterManagerAndRenderer_nonNullWhenMapReady(): Unit { + var rememberedManager: ClusterManager? = null + var rememberedRenderer: ClusterRenderer? = null + + composeTestRule.setContent { + GoogleMap { + val manager = rememberClusterManager() + val renderer = rememberClusterRenderer(manager) + rememberedManager = manager + rememberedRenderer = renderer + } + } + + composeTestRule.waitForIdle() + + assertThat(rememberedManager).isNotNull() + assertThat(rememberedRenderer).isNotNull() + } +} +// [END maps_compose_utils_clustering_test] diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsTileOverlayTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsTileOverlayTest.kt new file mode 100644 index 00000000..6d64655c --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsTileOverlayTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.wms + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.android.gms.maps.GoogleMap +import com.google.android.maps.robolectric.annotation.EnableMapsShadows +import com.google.android.maps.robolectric.shadows.ShadowGoogleMap +import com.google.android.maps.robolectric.shadows.ShadowTileOverlay +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapEffect +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.TileOverlayState +import com.google.maps.android.compose.rememberTileOverlayState +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.shadow.api.Shadow + +// [START maps_compose_utils_wms_tile_overlay_test] +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@EnableMapsShadows +@OptIn(MapsComposeExperimentalApi::class) +internal class WmsTileOverlayTest { + + @get:Rule + internal val composeTestRule: AndroidComposeTestRule, ComponentActivity> = + createAndroidComposeRule() + + @Test + internal fun testWmsTileOverlay_rendersAndAppliesProperties(): Unit { + var underlyingMap: GoogleMap? = null + lateinit var overlayState: TileOverlayState + + composeTestRule.setContent { + overlayState = rememberTileOverlayState() + GoogleMap { + MapEffect(Unit) { map -> + underlyingMap = map + } + WmsTileOverlay( + urlFormatter = { xMin, yMin, xMax, yMax, zoom -> + "https://example.com/tiles?bbox=$xMin,$yMin,$xMax,$yMax&zoom=$zoom" + }, + state = overlayState, + transparency = 0.5f, + fadeIn = false, + visible = true, + zIndex = 2.0f + ) + } + } + + composeTestRule.waitForIdle() + + assertThat(underlyingMap).isNotNull() + val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap + assertThat(shadowMap.tileOverlays).hasSize(1) + + val tileOverlay = shadowMap.tileOverlays.first() + val shadowOverlay = Shadow.extract(tileOverlay) as ShadowTileOverlay + + assertThat(tileOverlay.transparency).isEqualTo(0.5f) + assertThat(tileOverlay.fadeIn).isFalse() + assertThat(tileOverlay.isVisible).isTrue() + assertThat(tileOverlay.zIndex).isEqualTo(2.0f) + + // Verify clearTileCache via state + overlayState.clearTileCache() + assertThat(shadowOverlay.isTileCacheCleared).isTrue() + } +} +// [END maps_compose_utils_wms_tile_overlay_test] diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt index 55ddadee..c24445ba 100644 --- a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt @@ -16,15 +16,18 @@ package com.google.maps.android.compose.wms +import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertArrayEquals import org.junit.Test +import java.net.URL +// [START maps_compose_utils_wms_url_tile_provider_test] public class WmsUrlTileProviderTest { private val worldSize: Double = 6378137.0 * kotlin.math.PI @Test - public fun testGetBoundingBoxZoom0() { + public fun testGetBoundingBoxZoom0(): Unit { val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" } val bbox = provider.getBoundingBox(0, 0, 0) @@ -34,7 +37,7 @@ public class WmsUrlTileProviderTest { } @Test - public fun testGetBoundingBoxZoom1() { + public fun testGetBoundingBoxZoom1(): Unit { val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" } // Zoom 1, Tile 0,0 (Top Left) @@ -49,7 +52,7 @@ public class WmsUrlTileProviderTest { } @Test - public fun testGetBoundingBoxSpecificTile() { + public fun testGetBoundingBoxSpecificTile(): Unit { val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" } // Zoom 2, Tile 1,1 @@ -62,4 +65,27 @@ public class WmsUrlTileProviderTest { val expected = doubleArrayOf(-worldSize / 2, 0.0, 0.0, worldSize / 2) assertArrayEquals(expected, bbox, 0.001) } + + @Test + public fun testGetTileUrl_validUrl_returnsParsedUrl(): Unit { + val provider = WmsUrlTileProvider { xMin, yMin, xMax, yMax, zoom -> + "https://example.com/wms?bbox=$xMin,$yMin,$xMax,$yMax&zoom=$zoom" + } + val tileUrl = provider.getTileUrl(0, 0, 1) + assertThat(tileUrl).isNotNull() + assertThat(tileUrl).isEqualTo( + URL("https://example.com/wms?bbox=-${worldSize},0.0,0.0,${worldSize}&zoom=1") + ) + } + + @Test + public fun testGetTileUrl_malformedUrl_returnsNull(): Unit { + val provider = WmsUrlTileProvider { _, _, _, _, _ -> + "not a valid url ://" + } + val tileUrl = provider.getTileUrl(0, 0, 1) + assertThat(tileUrl).isNull() + } } +// [END maps_compose_utils_wms_url_tile_provider_test] + From 01981293414207abf78db45015388dd715a7be98 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:15:41 -0600 Subject: [PATCH 2/3] fix(utils): await asynchronous clustering operations using compose waitUntil --- .../compose/clustering/ClusteringTest.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt index 767a394b..38963db4 100644 --- a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt @@ -114,7 +114,9 @@ internal class ClusteringTest { val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap assertThat(managerFromCallback).isNotNull() - // ClusterManager should have added markers to the map + composeTestRule.waitUntil(timeoutMillis = 5000) { + shadowMap.markers.isNotEmpty() + } assertThat(shadowMap.markers.isNotEmpty()).isTrue() } @@ -143,6 +145,9 @@ internal class ClusteringTest { } composeTestRule.waitForIdle() + composeTestRule.waitUntil(timeoutMillis = 5000) { + decoratedItem != null + } assertThat(decoratedItem).isEqualTo(items.first()) } @@ -163,8 +168,11 @@ internal class ClusteringTest { } composeTestRule.waitForIdle() - assertThat(clusterManager).isNotNull() - assertThat(clusterManager!!.algorithm.items).hasSize(1) + composeTestRule.waitUntil(timeoutMillis = 5000) { + clusterManager?.algorithm?.items?.size == 1 + } + val manager = checkNotNull(clusterManager) + assertThat(manager.algorithm.items).hasSize(1) // Update items list itemsState = listOf( @@ -172,8 +180,10 @@ internal class ClusteringTest { SampleClusterItem(LatLng(20.1, 20.1), "Updated 2") ) composeTestRule.waitForIdle() - - assertThat(clusterManager.algorithm.items).hasSize(2) + composeTestRule.waitUntil(timeoutMillis = 5000) { + manager.algorithm.items.size == 2 + } + assertThat(manager.algorithm.items).hasSize(2) } @Test From 7741e9dc62d803b72d36fc5b89c3b8e5f0f83dab Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:54:02 -0600 Subject: [PATCH 3/3] fix(utils): assert algorithm items directly in Clustering test --- .../maps/android/compose/clustering/ClusteringTest.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt index 38963db4..dd5ab026 100644 --- a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt @@ -28,7 +28,6 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.LatLng import com.google.android.maps.robolectric.annotation.EnableMapsShadows -import com.google.android.maps.robolectric.shadows.ShadowGoogleMap import com.google.common.truth.Truth.assertThat import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.clustering.ClusterManager @@ -41,7 +40,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.robolectric.shadow.api.Shadow // [START maps_compose_utils_clustering_test] @RunWith(RobolectricTestRunner::class) @@ -111,13 +109,11 @@ internal class ClusteringTest { composeTestRule.waitForIdle() assertThat(underlyingMap).isNotNull() - val shadowMap = Shadow.extract(underlyingMap) as ShadowGoogleMap - assertThat(managerFromCallback).isNotNull() - composeTestRule.waitUntil(timeoutMillis = 5000) { - shadowMap.markers.isNotEmpty() + managerFromCallback?.algorithm?.items?.size == items.size } - assertThat(shadowMap.markers.isNotEmpty()).isTrue() + val manager = checkNotNull(managerFromCallback) + assertThat(manager.algorithm.items).containsExactlyElementsIn(items) } @Test