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..dd5ab026 --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusteringTest.kt @@ -0,0 +1,205 @@ +/* + * 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.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 + +// [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() + composeTestRule.waitUntil(timeoutMillis = 5000) { + managerFromCallback?.algorithm?.items?.size == items.size + } + val manager = checkNotNull(managerFromCallback) + assertThat(manager.algorithm.items).containsExactlyElementsIn(items) + } + + @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() + composeTestRule.waitUntil(timeoutMillis = 5000) { + decoratedItem != null + } + 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() + composeTestRule.waitUntil(timeoutMillis = 5000) { + clusterManager?.algorithm?.items?.size == 1 + } + val manager = checkNotNull(clusterManager) + assertThat(manager.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() + composeTestRule.waitUntil(timeoutMillis = 5000) { + manager.algorithm.items.size == 2 + } + assertThat(manager.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] +