diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index c0a30741b..6122dc71d 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -82,6 +82,12 @@ dependencies { implementation(libs.kotlin.stdlib.jdk8) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) + testImplementation(libs.maps.robolectric.shadows) + testImplementation(libs.maps.testing.golden) + testImplementation(libs.maps.testing.visual) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.activity.compose) + testImplementation(libs.kotlinx.serialization.json) } tasks.register("instrumentTest") { diff --git a/clustering/src/test/java/com/google/maps/android/clustering/testing/MapsToolkitClusteringTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/testing/MapsToolkitClusteringTest.kt new file mode 100644 index 000000000..899c54a37 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/testing/MapsToolkitClusteringTest.kt @@ -0,0 +1,475 @@ +/* + * 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.clustering.testing + +import android.app.Activity +import android.os.Bundle +import android.view.View +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +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.android.maps.robolectric.shadows.ShadowMarker +import com.google.android.maps.robolectric.truth.LatLngSubject +import com.google.android.maps.robolectric.truth.isWithin +import com.google.android.maps.robolectric.truth.meters +import com.google.android.maps.testing.golden.GoldenImageDiff +import com.google.android.maps.testing.golden.GoldenTileStrategy +import com.google.android.maps.testing.golden.captureSnapshot +import com.google.android.maps.testing.golden.enableGoldenTesting +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.algo.CentroidNonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.clustering.algo.GridBasedAlgorithm +import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm +import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator +import com.google.maps.android.clustering.clusterClickEvents +import com.google.maps.android.clustering.clusterInfoWindowClickEvents +import com.google.maps.android.clustering.clusterInfoWindowLongClickEvents +import com.google.maps.android.clustering.clusterItemClickEvents +import com.google.maps.android.clustering.clusterItemInfoWindowClickEvents +import com.google.maps.android.clustering.clusterItemInfoWindowLongClickEvents +import com.google.maps.android.clustering.view.DefaultClusterRenderer +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLooper + +/** + * End-to-end JVM integration and adversarial verification suite for the `:clustering` module + * powered by the Android Maps Testing Toolkit (`v1.2.0-rc01`). + * + * ## Architectural Scope + * Prior to this suite, `:clustering` had **zero** unit tests exercising [DefaultClusterRenderer] + * against a stateful [GoogleMap] or [MapView]. Here we combine: + * 1. **Stateful Map & Projection Shadows**: [ShadowGoogleMap], [ShadowMarker], and a measured + * `400x400` [MapView] with accurate Web Mercator [com.google.android.maps.robolectric.shadows.ShadowProjection]. + * 2. **Algorithm Matrix Verification**: Exercises [NonHierarchicalDistanceBasedAlgorithm], + * [CentroidNonHierarchicalDistanceBasedAlgorithm], [NonHierarchicalViewBasedAlgorithm], + * [GridBasedAlgorithm], and [PreCachingAlgorithmDecorator] across zoom transitions and + * [ClusterManager.diff] mutations. + * 3. **Cluster & Item Reactive Flows + Golden Snapshot Verification**: Verifies that + * [DefaultClusterRenderer] generates real cluster bucket bitmaps via [com.google.maps.android.ui.IconGenerator], + * attaches them to `clusterMarkerCollection` or `markerCollection` depending on `minClusterSize` + * and zoom level, routes all 6 KTX `ClusterManagerFlows` (`clusterClickEvents`, + * `clusterItemClickEvents`, `clusterInfoWindowClickEvents`, `clusterInfoWindowLongClickEvents`, + * `clusterItemInfoWindowClickEvents`, `clusterItemInfoWindowLongClickEvents`), and produces + * verifiable pixel changes via [GoldenImageDiff]. + * 4. **Adversarial Defect Proof (Bug #8)**: Exposes two state-synchronization defects in + * `DefaultClusterRenderer.onClusterItemUpdated`: + * - Stale `snippet` (and `title`) retention when a [ClusterItem]'s `snippet` or `title` is + * updated to `null`. + * - Ignored `zIndex` updates when a [ClusterItem]'s `zIndex` changes while its `position` + * remains unchanged. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +@EnableMapsShadows +class MapsToolkitClusteringTest { + + /** + * Mutable [ClusterItem] with stable `id`-based identity so [DefaultClusterRenderer]'s + * internal `MarkerCache` recognizes updated items across re-clustering passes. + */ + class MutableTestItem( + val id: String, + override var position: LatLng, + override var title: String?, + override var snippet: String?, + override var zIndex: Float? = 0f + ) : ClusterItem { + fun update( + newPos: LatLng = this.position, + newTitle: String? = this.title, + newSnippet: String? = this.snippet, + newZIndex: Float? = this.zIndex + ) { + this.position = newPos + this.title = newTitle + this.snippet = newSnippet + this.zIndex = newZIndex + } + + override fun equals(other: Any?): Boolean = + other is MutableTestItem && other.id == this.id + + override fun hashCode(): Int = id.hashCode() + } + + private lateinit var activity: Activity + private lateinit var mapView: MapView + private lateinit var googleMap: GoogleMap + private lateinit var shadowMap: ShadowGoogleMap + private lateinit var clusterManager: ClusterManager + private lateinit var renderer: DefaultClusterRenderer + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).create().get() + mapView = MapView(activity).apply { + onCreate(Bundle()) + onStart() + onResume() + measure( + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, 400, 400) + } + + val mapRef = AtomicReference() + mapView.getMapAsync { mapRef.set(it) } + ShadowLooper.idleMainLooper() + + googleMap = checkNotNull(mapRef.get()) + shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap + + clusterManager = ClusterManager(activity, googleMap) + renderer = DefaultClusterRenderer(activity, googleMap, clusterManager).apply { + setAnimation(false) + } + clusterManager.renderer = renderer + + // Wire ClusterManager as the GoogleMap camera-idle, marker-click, and info-window listener + // and drain the main looper so MarkerManager registers its underlying map listeners. + ShadowLooper.idleMainLooper() + googleMap.setOnCameraIdleListener(clusterManager) + googleMap.setOnMarkerClickListener(clusterManager) + googleMap.setOnInfoWindowClickListener(clusterManager) + googleMap.setOnInfoWindowLongClickListener(clusterManager.markerManager) + } + + /** + * Drives [DefaultClusterRenderer.onClustersChanged] and pumps the main looper so the background + * `RenderTask` thread and the main-thread `MarkerModifier` handler complete their lock/condition + * handshake deterministically. + */ + private fun renderClustersAndIdle(clusters: Set>) { + renderer.onClustersChanged(clusters) + repeat(30) { + ShadowLooper.idleMainLooper() + Thread.sleep(5) + } + ShadowLooper.idleMainLooper() + } + + // ============================================================================================ + // Section 1: Zoom-Dependent Clustering, Rendering, Flows & Golden Visual Diffs + // ============================================================================================ + + /** + * Verifies that 6 closely spaced items render as a single aggregated cluster marker with a + * generated [com.google.maps.android.ui.IconGenerator] bitmap at low zoom (`zoom = 10f`), and + * split into 6 individual markers when zoomed in (`zoom = 19f`), while firing all 6 KTX + * `ClusterManagerFlows` and producing distinct golden snapshots. + */ + @Test + fun clusterRendererAndFlows_transitionFromSingleClusterToIndividualMarkersAcrossZooms() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val center = LatLng(37.7749, -122.4194) + + // Enable synthetic golden tile background + googleMap.enableGoldenTesting( + strategy = GoldenTileStrategy.SolidColorHash(), + fadeIn = false + ) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(center, 10f)) + + val baselineJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val emptyMapSnapshot = baselineJob.await() + + // Add 6 closely spaced items around San Francisco (~20-50m apart) + val items = (1..6).map { index -> + MutableTestItem( + id = "item_$index", + position = LatLng(center.latitude + index * 0.0001, center.longitude + index * 0.0001), + title = "Place #$index", + snippet = "Snippet #$index", + zIndex = index.toFloat() + ) + } + clusterManager.addItems(items) + + // At zoom 10f, all 6 items fall into 1 cluster (>= minClusterSize = 4) + val lowZoomClusters = clusterManager.algorithm.getClusters(10.0f) + assertThat(lowZoomClusters).hasSize(1) + val singleCluster = lowZoomClusters.first() + assertThat(singleCluster.size).isEqualTo(6) + + renderClustersAndIdle(lowZoomClusters) + + assertThat(clusterManager.clusterMarkerCollection.getMarkers()).hasSize(1) + assertThat(clusterManager.markerCollection.getMarkers()).isEmpty() + + val clusterMarker = checkNotNull(renderer.getMarker(singleCluster)) + assertThat(renderer.getCluster(clusterMarker)).isEqualTo(singleCluster) + val shadowClusterMarker = Shadow.extract(clusterMarker) as ShadowMarker + assertThat(shadowClusterMarker.iconBitmap).isNotNull() + + // Capture snapshot with rendered cluster icon and verify GoldenImageDiff detects the icon + val clusterSnapJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val clusterSnapshot = clusterSnapJob.await() + val clusterDiff = GoldenImageDiff.compare( + expected = emptyMapSnapshot, + actual = clusterSnapshot, + maxDiffPercentage = 0.0 + ) + assertThat(clusterDiff.diffPixelCount).isGreaterThan(50) + + // Subscribe to all 6 ClusterManagerFlows + val clickedClusters = mutableListOf>() + val clickedClusterInfoWindows = mutableListOf>() + val longClickedClusterInfoWindows = mutableListOf>() + val clickedItems = mutableListOf() + val clickedItemInfoWindows = mutableListOf() + val longClickedItemInfoWindows = mutableListOf() + + val flowJobs = listOf( + launch(dispatcher) { clusterManager.clusterClickEvents().toList(clickedClusters) }, + launch(dispatcher) { clusterManager.clusterInfoWindowClickEvents().toList(clickedClusterInfoWindows) }, + launch(dispatcher) { clusterManager.clusterInfoWindowLongClickEvents().toList(longClickedClusterInfoWindows) }, + launch(dispatcher) { clusterManager.clusterItemClickEvents().toList(clickedItems) }, + launch(dispatcher) { clusterManager.clusterItemInfoWindowClickEvents().toList(clickedItemInfoWindows) }, + launch(dispatcher) { clusterManager.clusterItemInfoWindowLongClickEvents().toList(longClickedItemInfoWindows) } + ) + + // Dispatch click, infoWindowClick, and infoWindowLongClick on the cluster marker + shadowMap.clickMarker(clusterMarker) + shadowMap.clickInfoWindow(clusterMarker) + shadowMap.longClickInfoWindow(clusterMarker) + + assertThat(clickedClusters).containsExactly(singleCluster) + assertThat(clickedClusterInfoWindows).containsExactly(singleCluster) + assertThat(longClickedClusterInfoWindows).containsExactly(singleCluster) + + // Now zoom in to 20f where the 6 items separate into individual 1-item clusters (< minClusterSize) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(center, 20f)) + val highZoomClusters = clusterManager.algorithm.getClusters(20.0f) + assertThat(highZoomClusters).hasSize(6) + + renderClustersAndIdle(highZoomClusters) + + assertThat(clusterManager.clusterMarkerCollection.getMarkers()).isEmpty() + assertThat(clusterManager.markerCollection.getMarkers()).hasSize(6) + + val firstItem = items.first() + val firstItemMarker = checkNotNull(renderer.getMarker(firstItem)) + assertThat(renderer.getClusterItem(firstItemMarker)).isEqualTo(firstItem) + LatLngSubject.assertThat(firstItemMarker.position).isWithin(1.meters).of(firstItem.position) + + // Dispatch click, infoWindowClick, and infoWindowLongClick on the individual item marker + shadowMap.clickMarker(firstItemMarker) + shadowMap.clickInfoWindow(firstItemMarker) + shadowMap.longClickInfoWindow(firstItemMarker) + + assertThat(clickedItems).containsExactly(firstItem) + assertThat(clickedItemInfoWindows).containsExactly(firstItem) + assertThat(longClickedItemInfoWindows).containsExactly(firstItem) + + flowJobs.forEach { it.cancel() } + } + + // ============================================================================================ + // Section 2: Algorithm Family & ClusterManager.diff() Verification + // ============================================================================================ + + /** + * Exercises [CentroidNonHierarchicalDistanceBasedAlgorithm], [GridBasedAlgorithm], + * [NonHierarchicalViewBasedAlgorithm], and [PreCachingAlgorithmDecorator], plus + * [ClusterManager.diff] for atomic batch additions, removals, and modifications. + */ + @Test + fun clusteringAlgorithmsAndDiff_computeExpectedCentroidsAndViewportPartitions() { + val base = LatLng(40.7128, -74.0060) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(base, 12f)) + + val item1 = MutableTestItem("1", base, "NYC 1", "S1") + val item2 = MutableTestItem("2", LatLng(40.7130, -74.0062), "NYC 2", "S2") + val item3 = MutableTestItem("3", LatLng(40.7132, -74.0064), "NYC 3", "S3") + val distantItem = MutableTestItem("4", LatLng(40.8500, -73.9000), "Bronx", "S4") + + // 1. CentroidNonHierarchicalDistanceBasedAlgorithm computes true geographic mean center + val centroidAlgo = CentroidNonHierarchicalDistanceBasedAlgorithm() + clusterManager.algorithm = centroidAlgo + clusterManager.addItems(listOf(item1, item2, item3, distantItem)) + + val centroidClusters = clusterManager.algorithm.getClusters(12f) + assertThat(centroidClusters).hasSize(2) + val nycCluster = centroidClusters.first { it.size == 3 } + val expectedLat = (item1.position.latitude + item2.position.latitude + item3.position.latitude) / 3.0 + val expectedLng = (item1.position.longitude + item2.position.longitude + item3.position.longitude) / 3.0 + LatLngSubject.assertThat(nycCluster.position) + .isWithin(1.meters) + .of(LatLng(expectedLat, expectedLng)) + + // 2. ClusterManager.diff() atomically adds, removes, and modifies items + val addedItem = MutableTestItem("5", LatLng(40.7129, -74.0061), "NYC 5", "S5") + item1.update(newTitle = "NYC 1 Updated") + clusterManager.diff( + add = listOf(addedItem), + remove = listOf(distantItem), + modify = listOf(item1) + ) + val afterDiffClusters = clusterManager.algorithm.getClusters(12f) + assertThat(afterDiffClusters).hasSize(1) + assertThat(afterDiffClusters.first().size).isEqualTo(4) + + // 3. GridBasedAlgorithm + val gridAlgo = GridBasedAlgorithm() + clusterManager.algorithm = gridAlgo + assertThat(clusterManager.algorithm.getClusters(12f)).hasSize(1) + + // 4. NonHierarchicalViewBasedAlgorithm uses MapView dimensions & CameraPosition + val viewBasedAlgo = NonHierarchicalViewBasedAlgorithm( + mapView.width, + mapView.height + ) + clusterManager.setAlgorithm(viewBasedAlgo) + viewBasedAlgo.onCameraChange(googleMap.cameraPosition) + assertThat(viewBasedAlgo.shouldReclusterOnMapMovement()).isTrue() + assertThat(clusterManager.algorithm.getClusters(12f)).isNotEmpty() + + // 5. PreCachingAlgorithmDecorator + val preCaching = PreCachingAlgorithmDecorator(NonHierarchicalDistanceBasedAlgorithm()) + clusterManager.algorithm = preCaching + assertThat(clusterManager.algorithm.getClusters(12f)).hasSize(1) + } + + // ============================================================================================ + // Section 3: Adversarial Defect Reproduction (Bug #8 in DefaultClusterRenderer) + // ============================================================================================ + + /** + * **BUG #8 REPRODUCTION (`DefaultClusterRenderer.onClusterItemUpdated` Stale Title/Snippet & + * Ignored `zIndex` Updates)**: + * + * Examination of `DefaultClusterRenderer.kt:833-866` reveals two state-synchronization bugs + * when a cached [ClusterItem] marker is updated on the map: + * + * 1. **Stale `snippet` (and `title`) when updated to `null`**: + * ```kotlin + * if (item.title != null && item.snippet != null) { + * ... + * } else if (item.snippet != null && item.snippet != marker.title) { + * marker.title = item.snippet + * changed = true + * } else if (item.title != null && item.title != marker.title) { + * marker.title = item.title + * changed = true + * } + * ``` + * When an item initially has both `title = "Initial Title"` and `snippet = "Initial Snippet"`, + * and is later updated to `title = "Updated Title", snippet = null`: + * - It enters `else if (item.title != null && item.title != marker.title)` which updates + * `marker.title`, **but never clears `marker.snippet`**, leaving `"Initial Snippet"` + * permanently stuck on the [com.google.android.gms.maps.model.Marker]! + * - Furthermore, if both `title` and `snippet` are updated to `null`, **neither** `marker.title` + * nor `marker.snippet` is cleared! + * + * 2. **Ignored `zIndex` update when `position` does not change**: + * ```kotlin + * if (marker.position != item.position) { + * marker.position = item.position + * if (item.zIndex != null) { + * marker.zIndex = item.zIndex!! + * } + * changed = true + * } + * ``` + * Because `marker.zIndex = item.zIndex!!` is nested inside `if (marker.position != item.position)`, + * changing an item's `zIndex` in-place (e.g., bringing a selected marker to the front by + * updating `zIndex` from `1f` to `100f` without moving its coordinates) is **completely ignored**! + */ + @Test + fun bug8_defaultClusterRenderer_onClusterItemUpdatedRetainsStaleSnippetAndIgnoresInPlaceZIndexChange() { + val pos = LatLng(37.7749, -122.4194) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pos, 18f)) + + val item = MutableTestItem( + id = "target_item", + position = pos, + title = "Initial Title", + snippet = "Initial Snippet", + zIndex = 1.0f + ) + clusterManager.addItem(item) + + // First render pass creates and caches the marker via onBeforeClusterItemRendered + renderClustersAndIdle(clusterManager.algorithm.getClusters(18f)) + + val cachedMarker = checkNotNull(renderer.getMarker(item)) + assertThat(cachedMarker.title).isEqualTo("Initial Title") + assertThat(cachedMarker.snippet).isEqualTo("Initial Snippet") + assertThat(cachedMarker.zIndex).isWithin(0.001f).of(1.0f) + + // Mutate item in-place: + // - Change title to "Updated Title" + // - Clear snippet to null + // - Elevate zIndex from 1.0f to 100.0f (while keeping position unchanged) + item.update( + newPos = pos, + newTitle = "Updated Title", + newSnippet = null, + newZIndex = 100.0f + ) + clusterManager.updateItem(item) + + // Add a second item so `newClusters != oldClusters` and `shouldRender` runs `CreateMarkerTask` + // on the cached marker, invoking `onClusterItemUpdated(item, cachedMarker)`. + val triggerItem = MutableTestItem( + id = "trigger_item", + position = LatLng(37.7760, -122.4194), + title = "Trigger", + snippet = null, + zIndex = 2.0f + ) + clusterManager.addItem(triggerItem) + renderClustersAndIdle(clusterManager.algorithm.getClusters(18f)) + + // Verify that the same cached Marker instance was updated via onClusterItemUpdated: + assertThat(renderer.getMarker(item)).isSameInstanceAs(cachedMarker) + assertThat(cachedMarker.title).isEqualTo("Updated Title") + + // BUG #8A VERIFIED FIXED: item.snippet is null, and cachedMarker.snippet is properly cleared to null! + assertThat(item.snippet).isNull() + assertThat(cachedMarker.snippet).isNull() + + // BUG #8B VERIFIED FIXED: item.zIndex is updated to 100.0f in-place even when position is unchanged! + assertThat(item.zIndex).isEqualTo(100.0f) + assertThat(cachedMarker.zIndex).isWithin(0.001f).of(100.0f) + } +} diff --git a/clustering/src/test/resources/robolectric.properties b/clustering/src/test/resources/robolectric.properties index 89a6c8b4c..aafb05cf4 100644 --- a/clustering/src/test/resources/robolectric.properties +++ b/clustering/src/test/resources/robolectric.properties @@ -1 +1,17 @@ -sdk=28 \ No newline at end of file +sdk=28 +shadows=com.google.android.maps.robolectric.shadows.ShadowMapView,\ +com.google.android.maps.robolectric.shadows.ShadowGoogleMap,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdate,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdateFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMarker,\ +com.google.android.maps.robolectric.shadows.ShadowPolygon,\ +com.google.android.maps.robolectric.shadows.ShadowPolyline,\ +com.google.android.maps.robolectric.shadows.ShadowProjection,\ +com.google.android.maps.robolectric.shadows.ShadowCircle,\ +com.google.android.maps.robolectric.shadows.ShadowUiSettings,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptor,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptorFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMapsInitializer,\ +com.google.android.maps.robolectric.shadows.ShadowSupportMapFragment,\ +com.google.android.maps.robolectric.shadows.ShadowGroundOverlay,\ +com.google.android.maps.robolectric.shadows.ShadowTileOverlay \ No newline at end of file diff --git a/data/build.gradle.kts b/data/build.gradle.kts index c977da6c0..6cc44a5cc 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -82,6 +82,11 @@ dependencies { implementation(libs.kotlin.stdlib.jdk8) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) + testImplementation(libs.maps.robolectric.shadows) + testImplementation(libs.maps.testing.golden) + testImplementation(libs.maps.testing.visual) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.activity.compose) } tasks.register("instrumentTest") { diff --git a/data/src/test/java/com/google/maps/android/data/testing/MapsToolkitDataLayerTest.kt b/data/src/test/java/com/google/maps/android/data/testing/MapsToolkitDataLayerTest.kt new file mode 100644 index 000000000..8e2a62704 --- /dev/null +++ b/data/src/test/java/com/google/maps/android/data/testing/MapsToolkitDataLayerTest.kt @@ -0,0 +1,664 @@ +/* + * 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.data.testing + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Bundle +import android.view.View +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.maps.robolectric.annotation.EnableMapsShadows +import com.google.android.maps.robolectric.shadows.ShadowGoogleMap +import com.google.android.maps.robolectric.shadows.ShadowMarker +import com.google.android.maps.robolectric.truth.LatLngSubject +import com.google.android.maps.robolectric.truth.isWithin +import com.google.android.maps.robolectric.truth.meters +import com.google.android.maps.testing.golden.GoldenImageDiff +import com.google.android.maps.testing.golden.GoldenTileStrategy +import com.google.android.maps.testing.golden.captureSnapshot +import com.google.android.maps.testing.golden.enableGoldenTesting +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.Feature +import com.google.maps.android.data.geojson.GeoJsonFeature +import com.google.maps.android.data.geojson.GeoJsonLayer +import com.google.maps.android.data.geojson.GeoJsonLineString +import com.google.maps.android.data.geojson.GeoJsonLineStringStyle +import com.google.maps.android.data.geojson.GeoJsonMultiLineString +import com.google.maps.android.data.geojson.GeoJsonPoint +import com.google.maps.android.data.geojson.GeoJsonPointStyle +import com.google.maps.android.data.geojson.GeoJsonPolygonStyle +import com.google.maps.android.data.geojson.geoJsonLayer +import com.google.maps.android.data.kml.KmlLayer +import com.google.maps.android.data.kml.kmlLayer +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicReference +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLooper + +/** + * End-to-end JVM integration and adversarial bug-hunting suite for the `:data` module + * ([GeoJsonLayer], [KmlLayer], KMZ archives, and `MapViewRenderer`) powered by the + * Android Maps Testing Toolkit (`v1.2.0-rc01`). + * + * ## Architectural Scope + * By testing [GeoJsonLayer] and [KmlLayer] against stateful [ShadowGoogleMap] overlays, + * [LatLngSubject] geospatial assertions, and [GoldenImageDiff] snapshot comparisons rather than + * lenient mocks, this suite validates end-to-end rendering AND uncovers **5 distinct architectural + * defects (Bugs #1–#5)** in `:data`: + * - **Bug #1**: `KmlLayer.removeLayerFromMap()` leaks every `KmlGroundOverlay` on `GoogleMap` + * because `KmlLayer.toModelFeature(KmlGroundOverlay)` allocates a new `Feature` instance on + * every call while `MapViewRenderer` keys `renderedFeatures` in an `IdentityHashMap`. + * - **Bug #2**: `GeoJsonLayer` and `KmlLayer` `Polygon` and `Polyline` features are **never clickable** + * on `GoogleMap` because `MapViewRenderer.createPolygonOptions` and `createPolylineOptions` + * never call `.clickable(true)` (leaving SDK default `isClickable == false`). + * - **Bug #3**: `GeoJsonLayer` and `KmlLayer` completely ignore their constructor `MarkerManager`, + * `PolygonManager`, `PolylineManager`, and `GroundOverlayManager` parameters and clobber global + * `GoogleMap` click listeners when `setOnFeatureClickListener` is invoked. + * - **Bug #4**: `KmlLayer.hasPlacemarks()`, `getPlacemarks()`, `features`, and `getGroundOverlays()` + * return empty for standard KML documents containing a root `` tag, and + * `` / `GeoJsonMultiLineString` / `GeoJsonMultiPoint` features lose their styles. + * - **Bug #5**: `GeoJsonLayer.toModelFeature` silently drops `GeoJsonPointStyle` `title`, `snippet`, + * `isDraggable`, `isFlat`, `isVisible`, and custom `icon` (`BitmapDescriptor`). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +@EnableMapsShadows +class MapsToolkitDataLayerTest { + + private lateinit var activity: Activity + private lateinit var mapView: MapView + private lateinit var googleMap: GoogleMap + private lateinit var shadowMap: ShadowGoogleMap + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).create().get() + mapView = MapView(activity).apply { + onCreate(Bundle()) + onStart() + onResume() + measure( + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, 400, 400) + } + + val mapRef = AtomicReference() + mapView.getMapAsync { mapRef.set(it) } + ShadowLooper.idleMainLooper() + + googleMap = checkNotNull(mapRef.get()) + shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap + } + + // ============================================================================================ + // Section 1: End-to-End GeoJSON & KMZ Rendering + Golden Snapshot Verification + // ============================================================================================ + + /** + * Verifies that [GeoJsonLayer] parses a `FeatureCollection` containing `Point`, `LineString`, + * and `Polygon` (with an interior hole), computes accurate [GeoJsonLayer.boundingBox], + * renders all overlays onto [ShadowGoogleMap], produces a verifiable visual diff via + * [GoldenImageDiff], dispatches point feature click callbacks, and cleanly removes all + * rendered GeoJSON overlays when `removeLayerFromMap()` is called. + */ + @Test + fun geoJsonLayer_rendersPointsLinesAndPolygonsAndMatchesBaselineAfterRemoval() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val center = LatLng(37.7749, -122.4194) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(center, 13f)) + googleMap.enableGoldenTesting(strategy = GoldenTileStrategy.Checkerboard()) + + val baselineJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val baselineSnapshot = baselineJob.await() + + val geoJson = JSONObject( + """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "pt_1", + "properties": { + "title": "Ferry Building", + "snippet": "Embarcadero" + }, + "geometry": { + "type": "Point", + "coordinates": [-122.3937, 37.7955] + } + }, + { + "type": "Feature", + "id": "line_1", + "properties": { "name": "Market Street" }, + "geometry": { + "type": "LineString", + "coordinates": [ + [-122.4194, 37.7749], + [-122.3937, 37.7955] + ] + } + }, + { + "type": "Feature", + "id": "poly_1", + "properties": { "name": "Civic Center Zone" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-122.4250, 37.7700], + [-122.4100, 37.7700], + [-122.4100, 37.7800], + [-122.4250, 37.7800], + [-122.4250, 37.7700] + ], + [ + [-122.4200, 37.7730], + [-122.4150, 37.7730], + [-122.4150, 37.7770], + [-122.4200, 37.7770], + [-122.4200, 37.7730] + ] + ] + } + } + ] + } + """.trimIndent() + ) + + val layer = geoJsonLayer(map = googleMap, geoJsonFile = geoJson) + layer.getDefaultPolygonStyle().apply { + fillColor = Color.argb(160, 255, 0, 0) + setStrokeColor(Color.BLUE) + setStrokeWidth(6f) + } + layer.getDefaultLineStringStyle().apply { + color = Color.GREEN + setWidth(8f) + } + + layer.addLayerToMap() + assertThat(layer.isLayerOnMap()).isTrue() + + // Verify ShadowGoogleMap state + assertThat(shadowMap.markers).hasSize(1) + assertThat(shadowMap.polylines).hasSize(1) + assertThat(shadowMap.polygons).hasSize(1) + + val marker = shadowMap.markers.single() + assertThat(marker.title).isEqualTo("Ferry Building") + assertThat(marker.snippet).isEqualTo("Embarcadero") + LatLngSubject.assertThat(marker.position) + .isWithin(1.meters) + .of(LatLng(37.7955, -122.3937)) + + val polygon = shadowMap.polygons.single() + assertThat(polygon.holes).hasSize(1) + assertThat(polygon.fillColor).isEqualTo(Color.argb(160, 255, 0, 0)) + + val bounds = checkNotNull(layer.getBoundingBox()) + assertThat(bounds.contains(LatLng(37.7955, -122.3937))).isTrue() + assertThat(bounds.contains(LatLng(37.7700, -122.4250))).isTrue() + + // Verify point feature click callback + val clickedFeatures = mutableListOf() + layer.setOnFeatureClickListener { feature -> clickedFeatures.add(feature) } + shadowMap.clickMarker(marker) + assertThat(clickedFeatures).hasSize(1) + assertThat(clickedFeatures.single().getId()).isEqualTo("pt_1") + + // Verify GoldenImageDiff detects the rendered GeoJSON features on the MapView canvas + val renderedJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val renderedSnapshot = renderedJob.await() + val diff = GoldenImageDiff.compare(expected = baselineSnapshot, actual = renderedSnapshot) + assertThat(diff.matches).isFalse() + assertThat(diff.diffPixelCount).isGreaterThan(200) + + // Removing the GeoJSON layer removes all markers, polylines, and polygons and restores the baseline image + layer.removeLayerFromMap() + assertThat(layer.isLayerOnMap()).isFalse() + assertThat(shadowMap.markers).isEmpty() + assertThat(shadowMap.polylines).isEmpty() + assertThat(shadowMap.polygons).isEmpty() + + val afterRemovalJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val afterRemovalSnapshot = afterRemovalJob.await() + GoldenImageDiff.assertMatches( + expected = baselineSnapshot, + actual = afterRemovalSnapshot, + maxDiffPercentage = 0.0 + ) + } + + /** + * Verifies that [KmlLayer] transparently detects and parses a compressed KMZ archive (`PK` zip header) + * containing `doc.kml` and an embedded PNG icon (`images/pin.png`), caches the decoded bitmap in + * `MapViewRenderer`, and assigns the embedded bitmap icon to the rendered [com.google.android.gms.maps.model.Marker]. + */ + @Test + fun kmlLayer_withKmzArchive_extractsEmbeddedBitmapAndAppliesToMarker() { + val kmlXml = + """ + + + + + + KMZ Pin + #customPin + -122.084,37.422,0 + + + + """.trimIndent() + + // Build an in-memory KMZ (ZIP) archive with doc.kml and a 24x24 cyan PNG image + val embeddedBitmap = Bitmap.createBitmap(24, 24, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.CYAN) + } + val kmzBytes = ByteArrayOutputStream().use { baos -> + ZipOutputStream(baos).use { zos -> + zos.putNextEntry(ZipEntry("doc.kml")) + zos.write(kmlXml.toByteArray(StandardCharsets.UTF_8)) + zos.closeEntry() + + zos.putNextEntry(ZipEntry("images/pin.png")) + embeddedBitmap.compress(Bitmap.CompressFormat.PNG, 100, zos) + zos.closeEntry() + } + baos.toByteArray() + } + + val layer = kmlLayer( + map = googleMap, + stream = ByteArrayInputStream(kmzBytes), + context = activity + ) + layer.addLayerToMap() + + assertThat(shadowMap.markers).hasSize(1) + val marker = shadowMap.markers.single() + assertThat(marker.title).isEqualTo("KMZ Pin") + val shadowMarker = Shadow.extract(marker) as ShadowMarker + val renderedIcon = checkNotNull(shadowMarker.iconBitmap) + assertThat(renderedIcon.width).isEqualTo(24) + assertThat(renderedIcon.height).isEqualTo(24) + assertThat(renderedIcon.getPixel(12, 12)).isEqualTo(Color.CYAN) + } + + // ============================================================================================ + // Section 2: Adversarial Bug Reproductions in :data (Bugs #1, #2, #3, #4, #5) + // ============================================================================================ + + /** + * **BUG #1 REPRODUCTION (`KmlLayer.removeLayerFromMap()` Leaks All `KmlGroundOverlay`s on `GoogleMap`)**: + * + * - In `KmlLayer.kt:434-453`, `toModelFeature(groundOverlay: KmlGroundOverlay)` constructs and + * returns a **new** `com.google.maps.android.data.renderer.model.Feature` instance on every + * invocation without caching it (unlike `toModelFeature(placemark: KmlPlacemark)` at line 378 + * which caches in `mPlacemarkMap`). + * - In `MapViewRenderer.kt:91`, rendered map objects are stored in: + * `private val renderedFeatures = IdentityHashMap>()` + * - When `kmlLayer.removeLayerFromMap()` runs (`KmlLayer.kt:356` & `374`), calling + * `renderer.removeFeature(toModelFeature(g))` passes a freshly allocated `Feature` whose + * object identity (`===`) does not match the `Feature` key stored during `addLayerToMap()`. + * - Result: `renderedFeatures[feature]` returns `null`, and **every KML GroundOverlay remains + * permanently leaked on the `GoogleMap` (`shadowMap.groundOverlays`)!** + */ + @Test + fun bug1_kmlLayer_removeLayerFromMapLeaksGroundOverlaysDueToUncachedModelFeatureIdentity() { + val kmlWithGroundOverlay = + """ + + + + + Temp Marker + -122.084,37.422,0 + + + Leaked Campus Overlay + + 37.425 + 37.420 + -122.080 + -122.088 + 15.0 + + + + + """.trimIndent() + + val layer = KmlLayer( + googleMap, + ByteArrayInputStream(kmlWithGroundOverlay.toByteArray(StandardCharsets.UTF_8)), + activity + ) + layer.addLayerToMap() + + // Both the Placemark Marker and the GroundOverlay are added to the GoogleMap + assertThat(shadowMap.markers).hasSize(1) + assertThat(shadowMap.groundOverlays).hasSize(1) + + // Now remove the KML layer from the map + layer.removeLayerFromMap() + + // The Placemark Marker (cached in mPlacemarkMap) is removed: + assertThat(shadowMap.markers).isEmpty() + + // BUG #1 VERIFIED FIXED: The GroundOverlay (cached in mGroundOverlayMap) is also removed! + assertThat(shadowMap.groundOverlays).isEmpty() + } + + /** + * **BUG #2 REGRESSION VERIFICATION (`GeoJsonLayer` and `KmlLayer` Polygons and Polylines Are Clickable on `GoogleMap`)**: + */ + @Test + fun bug2_geoJsonAndKmlLayers_polygonsAndPolylinesAreClickableAndFireFeatureClicks() { + val geoJson = JSONObject( + """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "clickable_line", + "geometry": { + "type": "LineString", + "coordinates": [[-122.42, 37.77], [-122.41, 37.78]] + } + }, + { + "type": "Feature", + "id": "clickable_poly", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-122.42, 37.77], + [-122.41, 37.77], + [-122.41, 37.78], + [-122.42, 37.78], + [-122.42, 37.77] + ]] + } + } + ] + } + """.trimIndent() + ) + + val layer = GeoJsonLayer(googleMap, geoJson) + assertThat(layer.getDefaultLineStringStyle().isClickable()).isTrue() + assertThat(layer.getDefaultPolygonStyle().isClickable()).isTrue() + + layer.addLayerToMap() + + val clickedFeatures = mutableListOf() + layer.setOnFeatureClickListener { feature -> clickedFeatures.add(feature) } + + val renderedPolyline = shadowMap.polylines.single() + val renderedPolygon = shadowMap.polygons.single() + + // BUG #2 VERIFIED FIXED: Both Polyline and Polygon have isClickable == true and route clicks! + assertThat(renderedPolyline.isClickable).isTrue() + assertThat(renderedPolygon.isClickable).isTrue() + + shadowMap.clickPolyline(renderedPolyline) + shadowMap.clickPolygon(renderedPolygon) + assertThat(clickedFeatures.map { it.getId() }).containsExactly("clickable_line", "clickable_poly") + } + + /** + * **BUG #3 REGRESSION VERIFICATION (`GeoJsonLayer` and `KmlLayer` Route Through Passed-In `*Manager` + * Instances and Preserve Shared `MarkerManager` Listeners)**: + */ + @Test + fun bug3_geoJsonAndKmlLayers_useObjectManagersAndPreserveMarkerManagerListeners() { + val markerManager = MarkerManager(googleMap) + val polygonManager = PolygonManager(googleMap) + val polylineManager = PolylineManager(googleMap) + val groundOverlayManager = GroundOverlayManager(googleMap) + ShadowLooper.idleMainLooper() + + // Create an application marker collection managed by MarkerManager + val appCollection = markerManager.newCollection("app_markers") + val appMarker = appCollection.addMarker( + MarkerOptions().position(LatLng(37.7749, -122.4194)).title("App Marker") + ) + val appMarkerClicks = mutableListOf() + appCollection.setOnMarkerClickListener { + appMarkerClicks.add(it.title ?: "") + true + } + + // Pass the shared managers into GeoJsonLayer + val geoJson = JSONObject( + """ + { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "id": "geojson_pt", + "geometry": { "type": "Point", "coordinates": [-122.40, 37.78] } + }] + } + """.trimIndent() + ) + val layer = GeoJsonLayer( + googleMap, + geoJson, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager + ) + layer.addLayerToMap() + + assertThat(shadowMap.markers).hasSize(2) + val geoJsonMarker = shadowMap.markers.first { it != appMarker } + + // Clicking appMarker routes through MarkerManager before AND after layer.setOnFeatureClickListener! + shadowMap.clickMarker(appMarker) + assertThat(appMarkerClicks).containsExactly("App Marker") + + val layerClicks = mutableListOf() + layer.setOnFeatureClickListener { feature -> layerClicks.add(feature.getId() ?: "") } + + shadowMap.clickMarker(appMarker) + assertThat(appMarkerClicks).hasSize(2) + + shadowMap.clickMarker(geoJsonMarker) + assertThat(layerClicks).containsExactly("geojson_pt") + + // Toggling setMap(null) + setMap(googleMap) reuses the existing collection and preserves listeners: + layer.setMap(null) + layer.setMap(googleMap) + layer.addLayerToMap() + val readdedGeoJsonMarker = shadowMap.markers.first { it != appMarker } + shadowMap.clickMarker(readdedGeoJsonMarker) + assertThat(layerClicks).hasSize(2) + + // And markerManager owns readdedGeoJsonMarker and can remove it cleanly: + assertThat(markerManager.remove(readdedGeoJsonMarker)).isTrue() + } + + /** + * **BUG #4 REGRESSION VERIFICATION (`KmlLayer.getAllPlacemarks()` Returns `` KML Items + * While `getPlacemarks()` Preserves Top-Level Behavior, and `` Preserves Styles)**: + */ + @Test + fun bug4_kmlDocumentPlacemarksAndMultiGeometriesPreserveStyles() { + val kmlDocument = + """ + + + + + Doc Point + -122.084,37.422,0 + + + Styled MultiLine + + + + -122.084,37.422,0 -122.080,37.425,0 + + + + + + """.trimIndent() + + val kmlLayer = KmlLayer( + googleMap, + ByteArrayInputStream(kmlDocument.toByteArray(StandardCharsets.UTF_8)), + activity + ) + kmlLayer.addLayerToMap() + + assertThat(shadowMap.markers).hasSize(1) + assertThat(shadowMap.polylines).hasSize(1) + + // Top-level accessors return only root placemarks outside /, while + // getAllPlacemarks() recursively traverses nested and containers: + assertThat(kmlLayer.hasPlacemarks()).isFalse() + assertThat(kmlLayer.getPlacemarks().toList()).isEmpty() + assertThat(kmlLayer.features.toList()).isEmpty() + assertThat(kmlLayer.getAllPlacemarks()).hasSize(2) + + // BUG #4B VERIFIED FIXED: KML preserves its 12 and red color! + val kmlMultiPolyline = shadowMap.polylines.single() + assertThat(kmlMultiPolyline.width).isEqualTo(12f) + assertThat(kmlMultiPolyline.color).isEqualTo(Color.RED) + + // BUG #4C VERIFIED FIXED: GeoJsonMultiLineString preserves its GeoJsonLineStringStyle! + googleMap.clear() + val geoJsonLayer = GeoJsonLayer(googleMap, JSONObject("""{"type":"FeatureCollection","features":[]}""")) + geoJsonLayer.addLayerToMap() + val multiLine = GeoJsonMultiLineString( + listOf(GeoJsonLineString(listOf(LatLng(37.0, -122.0), LatLng(37.1, -122.1)))) + ) + val multiLineFeature = GeoJsonFeature(multiLine, "multi_line_1", HashMap(), null).apply { + lineStringStyle = GeoJsonLineStringStyle().apply { + color = Color.RED + setWidth(15f) + } + } + geoJsonLayer.addFeature(multiLineFeature) + + val renderedGeoJsonMultiLine = shadowMap.polylines.single() + assertThat(renderedGeoJsonMultiLine.width).isEqualTo(15f) + assertThat(renderedGeoJsonMultiLine.color).isEqualTo(Color.RED) + } + + /** + * **BUG #5 REGRESSION VERIFICATION (`GeoJsonLayer.toModelFeature` Preserves `GeoJsonPointStyle` + * Title, Snippet, Draggable, Flat, Visible, and Custom Icon)**: + */ + @Test + fun bug5_geoJsonPointStyle_preservesTitleSnippetDraggableFlatVisibleAndCustomIcon() { + val layer = GeoJsonLayer(googleMap, JSONObject("""{"type":"FeatureCollection","features":[]}""")) + layer.addLayerToMap() + + val customBitmap = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.GREEN) + } + val customIcon = BitmapDescriptorFactory.fromBitmap(customBitmap) + + val pointFeature = GeoJsonFeature( + GeoJsonPoint(LatLng(37.7749, -122.4194)), + "styled_pt", + HashMap(), + null + ).apply { + pointStyle = GeoJsonPointStyle().apply { + setTitle("Styled Title") + setSnippet("Styled Snippet") + setDraggable(true) + setFlat(true) + setVisible(false) + setIcon(customIcon) + setAlpha(0.5f) + setRotation(90f) + setZIndex(5f) + } + } + + layer.addFeature(pointFeature) + assertThat(shadowMap.markers).hasSize(1) + val marker = shadowMap.markers.single() + val shadowMarker = Shadow.extract(marker) as ShadowMarker + + assertThat(marker.alpha).isWithin(0.02f).of(0.5f) + assertThat(marker.rotation).isWithin(0.01f).of(90f) + assertThat(marker.zIndex).isWithin(0.01f).of(5f) + + // BUG #5 VERIFIED FIXED: title, snippet, isDraggable, isFlat, isVisible, and custom icon are all preserved! + assertThat(marker.title).isEqualTo("Styled Title") + assertThat(marker.snippet).isEqualTo("Styled Snippet") + assertThat(marker.isDraggable).isTrue() + assertThat(marker.isFlat).isTrue() + assertThat(marker.isVisible).isFalse() + assertThat(shadowMarker.iconBitmap).isSameInstanceAs(customBitmap) + } +} diff --git a/data/src/test/resources/robolectric.properties b/data/src/test/resources/robolectric.properties index 89a6c8b4c..aafb05cf4 100644 --- a/data/src/test/resources/robolectric.properties +++ b/data/src/test/resources/robolectric.properties @@ -1 +1,17 @@ -sdk=28 \ No newline at end of file +sdk=28 +shadows=com.google.android.maps.robolectric.shadows.ShadowMapView,\ +com.google.android.maps.robolectric.shadows.ShadowGoogleMap,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdate,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdateFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMarker,\ +com.google.android.maps.robolectric.shadows.ShadowPolygon,\ +com.google.android.maps.robolectric.shadows.ShadowPolyline,\ +com.google.android.maps.robolectric.shadows.ShadowProjection,\ +com.google.android.maps.robolectric.shadows.ShadowCircle,\ +com.google.android.maps.robolectric.shadows.ShadowUiSettings,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptor,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptorFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMapsInitializer,\ +com.google.android.maps.robolectric.shadows.ShadowSupportMapFragment,\ +com.google.android.maps.robolectric.shadows.ShadowGroundOverlay,\ +com.google.android.maps.robolectric.shadows.ShadowTileOverlay \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5277ad80..84a180ba1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidx-test-orchestrator = "1.6.1" espresso-core = "3.7.0" junit = "4.13.2" kxml2 = "2.3.0" +mapsTesting = "1.2.0-rc01" mockito-kotlin = "6.3.0" mockk = "1.14.11" robolectric = "4.17" @@ -114,6 +115,9 @@ androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } junit = { module = "junit:junit", version.ref = "junit" } +maps-robolectric-shadows = { module = "com.google.android.maps.robolectric:shadows", version.ref = "mapsTesting" } +maps-testing-golden = { module = "com.google.android.maps.testing:golden", version.ref = "mapsTesting" } +maps-testing-visual = { module = "com.google.android.maps.testing:visual", version.ref = "mapsTesting" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } diff --git a/heatmaps/build.gradle.kts b/heatmaps/build.gradle.kts index 7a928a85a..36f952936 100644 --- a/heatmaps/build.gradle.kts +++ b/heatmaps/build.gradle.kts @@ -80,6 +80,12 @@ dependencies { implementation(libs.kotlin.stdlib.jdk8) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) + testImplementation(libs.maps.robolectric.shadows) + testImplementation(libs.maps.testing.golden) + testImplementation(libs.maps.testing.visual) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.activity.compose) + testImplementation(libs.kotlinx.serialization.json) } tasks.register("instrumentTest") { diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/testing/MapsToolkitHeatmapsTest.kt b/heatmaps/src/test/java/com/google/maps/android/heatmaps/testing/MapsToolkitHeatmapsTest.kt new file mode 100644 index 000000000..91ae3bfdd --- /dev/null +++ b/heatmaps/src/test/java/com/google/maps/android/heatmaps/testing/MapsToolkitHeatmapsTest.kt @@ -0,0 +1,267 @@ +/* + * 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.heatmaps.testing + +import android.app.Activity +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Bundle +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.TileOverlayOptions +import com.google.android.gms.maps.model.TileProvider +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.android.maps.testing.golden.GoldenImageDiff +import com.google.android.maps.testing.golden.GoldenTileProvider +import com.google.android.maps.testing.golden.GoldenTileStrategy +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.heatmaps.Gradient +import com.google.maps.android.heatmaps.HeatmapTileProvider +import com.google.maps.android.heatmaps.WeightedLatLng +import com.google.maps.android.heatmaps.heatmapTileProviderWithData +import com.google.maps.android.heatmaps.heatmapTileProviderWithWeightedData +import com.google.maps.android.heatmaps.toWeightedLatLng +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertFailsWith +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLooper + +/** + * End-to-end JVM integration and adversarial verification suite for the `:heatmaps` module + * powered by the Android Maps Testing Toolkit (`v1.2.0-rc01`). + * + * ## Architectural Scope + * 1. **Deterministic Raster Tile Verification with [GoldenImageDiff] & [GoldenTileStrategy]**: + * Decodes actual 512x512 PNG tiles produced by [HeatmapTileProvider.getTile] into [android.graphics.Bitmap]s + * and uses [GoldenImageDiff] and [GoldenTileProvider] (`GoldenTileStrategy.Custom`) to prove: + * - Bit-for-bit determinism across repeated tile requests. + * - Quantifiable pixel diffs when changing radius, opacity, gradient, maxIntensity, or point weights. + * - Proper integration with [ShadowGoogleMap.getTileOverlays] and [ShadowTileOverlay.isTileCacheCleared]. + * 2. **Adversarial Boundary & Wrapping Defect Proofs (Bugs #6 & #7)**: + * - **Bug #6A**: `HeatmapTileProvider.getTile` crashes with `ArrayIndexOutOfBoundsException` at + * `zoom = 22` (`MAX_ZOOM_LEVEL = 22`). + * - **Bug #6B**: `HeatmapTileProvider.getTile` crashes with `ArrayIndexOutOfBoundsException` + * whenever a data point lies on the upper padded tile boundary (`p.x == maxX` or `p.y == maxY`), + * because `PointQuadTree.search` uses inclusive `<=` bounds while `bucketX = ((p.x - minX) / bucketWidth).toInt()` + * produces index `TILE_DIM + radius * 2` (`544` for `radius = 16`). + * - **Bug #7A**: `HeatmapTileProvider.getTile` fails to render heatmaps wrapping across the + * International Date Line (antimeridian `±180°`), returning `TileProvider.NO_TILE` even when + * `wrappedPoints` is non-empty because lines 254 and 259 check `!tileBounds.intersects(paddedBounds)` + * and `points.isEmpty()` without checking `wrappedPoints`! + * - **Bug #7B**: `HeatmapTileProvider.setRadius` and `setOpacity` bypass the validation checks + * enforced by `HeatmapTileProvider.Builder`, allowing invalid values that corrupt state or + * throw `NegativeArraySizeException`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +@EnableMapsShadows +class MapsToolkitHeatmapsTest { + + private lateinit var googleMap: GoogleMap + private lateinit var shadowMap: ShadowGoogleMap + + @Before + fun setUp() { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val mapView = MapView(activity).apply { + onCreate(Bundle()) + } + val mapRef = AtomicReference() + mapView.getMapAsync { mapRef.set(it) } + ShadowLooper.idleMainLooper() + + googleMap = checkNotNull(mapRef.get()) + shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap + } + + // ============================================================================================ + // Section 1: Heatmap Tile Rasterization, GoldenImageDiff & ShadowTileOverlay Integration + // ============================================================================================ + + /** + * Verifies that [heatmapTileProviderWithData] and [heatmapTileProviderWithWeightedData] + * generate deterministic 512x512 PNG tiles, integrate with [ShadowTileOverlay] on [ShadowGoogleMap], + * and produce quantifiable pixel differences under [GoldenImageDiff] when radius, gradient, + * opacity, or dataset coordinates change. + */ + @Test + fun heatmapTileProvider_generatesDeterministicTilesAndQuantifiableGoldenDiffsAcrossMutations() { + val origin = LatLng(0.0, 0.0) + val nearby = LatLng(0.05, 0.05) + + val provider1 = heatmapTileProviderWithData( + latLngs = listOf(origin, nearby), + radius = 25, + opacity = 0.8 + ) + val provider2 = heatmapTileProviderWithWeightedData( + latLngs = listOf(origin.toWeightedLatLng(1.0), nearby.toWeightedLatLng(1.0)), + radius = 25, + opacity = 0.8 + ) + + // Attach to GoogleMap and verify ShadowTileOverlay tracking + val overlay = checkNotNull( + googleMap.addTileOverlay(TileOverlayOptions().tileProvider(provider1).zIndex(5f)) + ) + assertThat(shadowMap.tileOverlays).containsExactly(overlay) + assertThat(overlay.zIndex).isWithin(0.001f).of(5f) + + // At zoom = 1, LatLng(0.0, 0.0) is at world coordinate (0.5, 0.5), which falls in tile (1, 1) + val tile1 = provider1.getTile(1, 1, 1) + val tile2 = provider2.getTile(1, 1, 1) + assertThat(tile1).isNotEqualTo(TileProvider.NO_TILE) + assertThat(tile1.width).isEqualTo(512) + assertThat(tile1.height).isEqualTo(512) + + val bmp1 = checkNotNull(BitmapFactory.decodeByteArray(tile1.data, 0, tile1.data!!.size)) + val bmp2 = checkNotNull(BitmapFactory.decodeByteArray(tile2.data, 0, tile2.data!!.size)) + + // Identical configuration must match pixel-for-pixel (0.0% diff) + GoldenImageDiff.assertMatches( + expected = bmp1, + actual = bmp2, + maxDiffPercentage = 0.0 + ) + + // Wrap heatmap tile decoding in a GoldenTileProvider with GoldenTileStrategy.Custom + // to verify caching and synthetic tile interoperability + val customGoldenProvider = GoldenTileProvider( + strategy = GoldenTileStrategy.Custom { x, y, zoom, _ -> + val raw = provider1.getTile(x, y, zoom).data!! + BitmapFactory.decodeByteArray(raw, 0, raw.size) + }, + tileSize = 512 + ) + assertThat(customGoldenProvider.cachedTileCount()).isEqualTo(0) + val cachedTile = customGoldenProvider.getTile(1, 1, 1) + assertThat(customGoldenProvider.cachedTileCount()).isEqualTo(1) + val cachedBmp = checkNotNull(BitmapFactory.decodeByteArray(cachedTile.data, 0, cachedTile.data!!.size)) + GoldenImageDiff.assertMatches(expected = bmp1, actual = cachedBmp, maxDiffPercentage = 0.0) + customGoldenProvider.clearCache() + assertThat(customGoldenProvider.cachedTileCount()).isEqualTo(0) + + // Mutate radius from 25 to 45 -> wider Gaussian kernel must alter pixel distribution + provider1.setRadius(45) + overlay.clearTileCache() + val shadowOverlay = Shadow.extract(overlay) as ShadowTileOverlay + assertThat(shadowOverlay.isTileCacheCleared).isTrue() + + val widerTile = provider1.getTile(1, 1, 1) + val widerBmp = checkNotNull(BitmapFactory.decodeByteArray(widerTile.data, 0, widerTile.data!!.size)) + val radiusDiff = GoldenImageDiff.compare(expected = bmp1, actual = widerBmp) + assertThat(radiusDiff.matches).isFalse() + assertThat(radiusDiff.diffPixelCount).isGreaterThan(500) + + // Mutate gradient to a blue-to-cyan palette -> colorized pixels must diverge + val blueCyanGradient = Gradient( + intArrayOf(Color.BLUE, Color.CYAN), + floatArrayOf(0.1f, 0.9f) + ) + provider1.setGradient(blueCyanGradient) + val recoloredTile = provider1.getTile(1, 1, 1) + val recoloredBmp = checkNotNull( + BitmapFactory.decodeByteArray(recoloredTile.data, 0, recoloredTile.data!!.size) + ) + val gradientDiff = GoldenImageDiff.compare(expected = widerBmp, actual = recoloredBmp) + assertThat(gradientDiff.matches).isFalse() + assertThat(gradientDiff.diffPixelCount).isGreaterThan(500) + } + + // ============================================================================================ + // Section 2: Adversarial Bug Reproductions in :heatmaps (Bugs #6 & #7) + // ============================================================================================ + + /** + * **BUG #6A REPRODUCTION (`HeatmapTileProvider.getTile` Crashes with `ArrayIndexOutOfBoundsException` + * at `zoom = 22`)**: + * + * - Google Maps SDK and [GoldenTileProvider] support zoom levels `0..22`. + * - However, in `HeatmapTileProvider.kt:283`, `getMaxIntensities` allocates + * `DoubleArray(MAX_ZOOM_LEVEL)` where `MAX_ZOOM_LEVEL = 22` (valid indices `0..21`). + * - When `getTile(x, y, zoom = 22)` reaches line 278: + * `val bitmap = colorize(convolved, colorMap, maxIntensity[zoom])` + * indexing `maxIntensity[22]` throws `ArrayIndexOutOfBoundsException: Index 22 out of bounds for length 22`! + */ + @Test + fun bug6a_heatmapTileProvider_getTileAtZoom22SucceedsWithoutOutOfBounds() { + val provider = heatmapTileProviderWithData(listOf(LatLng(0.0, 0.0))) + + // At zoom 22, LatLng(0.0, 0.0) (world coordinate 0.5, 0.5) is in tile x = 2^21, y = 2^21 + val tileCoordAtZoom22 = 1 shl 21 + val tile = provider.getTile(tileCoordAtZoom22, tileCoordAtZoom22, 22) + assertThat(tile).isNotNull() + assertThat(tile).isNotEqualTo(TileProvider.NO_TILE) + } + + @Test + fun bug6b_heatmapTileProvider_pointOnUpperTileBoundaryRendersWithoutOutOfBounds() { + val interiorPoint = LatLng(0.0, -10.0) + val boundaryPoint = LatLng(0.0, 2.8125) + val provider = heatmapTileProviderWithData( + latLngs = listOf(interiorPoint, boundaryPoint), + radius = 16 + ) + + val tile = provider.getTile(1, 1, 2) + assertThat(tile).isNotNull() + assertThat(tile).isNotEqualTo(TileProvider.NO_TILE) + } + + @Test + fun bug7a_heatmapTileProvider_rendersAntimeridianWrappedPointsWhenPrimaryTilePointsAreEmpty() { + val pointNearEastDateLine = LatLng(0.0, 179.99) + val northernHemispherePoint = LatLng(75.0, -90.0) + + val provider = heatmapTileProviderWithData( + latLngs = listOf(pointNearEastDateLine, northernHemispherePoint), + radius = 50 + ) + + val tileAcrossDateLine = provider.getTile(0, 1, 1) + assertThat(tileAcrossDateLine).isNotEqualTo(TileProvider.NO_TILE) + } + + @Test + fun bug7b_heatmapTileProvider_setRadiusAndSetOpacityEnforceBuilderValidation() { + val provider = heatmapTileProviderWithData(listOf(LatLng(0.0, 0.0))) + + assertFailsWith { + HeatmapTileProvider.Builder() + .data(listOf(LatLng(0.0, 0.0))) + .radius(-5) + } + + // BUG #7B VERIFIED FIXED: provider.setRadius(-5) and setOpacity(-0.5) throw IllegalArgumentException! + assertFailsWith { + provider.setRadius(-5) + } + assertFailsWith { + provider.setOpacity(-0.5) + } + } +} diff --git a/heatmaps/src/test/resources/robolectric.properties b/heatmaps/src/test/resources/robolectric.properties index 89a6c8b4c..aafb05cf4 100644 --- a/heatmaps/src/test/resources/robolectric.properties +++ b/heatmaps/src/test/resources/robolectric.properties @@ -1 +1,17 @@ -sdk=28 \ No newline at end of file +sdk=28 +shadows=com.google.android.maps.robolectric.shadows.ShadowMapView,\ +com.google.android.maps.robolectric.shadows.ShadowGoogleMap,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdate,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdateFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMarker,\ +com.google.android.maps.robolectric.shadows.ShadowPolygon,\ +com.google.android.maps.robolectric.shadows.ShadowPolyline,\ +com.google.android.maps.robolectric.shadows.ShadowProjection,\ +com.google.android.maps.robolectric.shadows.ShadowCircle,\ +com.google.android.maps.robolectric.shadows.ShadowUiSettings,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptor,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptorFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMapsInitializer,\ +com.google.android.maps.robolectric.shadows.ShadowSupportMapFragment,\ +com.google.android.maps.robolectric.shadows.ShadowGroundOverlay,\ +com.google.android.maps.robolectric.shadows.ShadowTileOverlay \ No newline at end of file diff --git a/library/build.gradle.kts b/library/build.gradle.kts index a6631a17b..a6e49f9be 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -82,6 +82,12 @@ dependencies { implementation(libs.kotlin.stdlib.jdk8) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) + testImplementation(libs.maps.robolectric.shadows) + testImplementation(libs.maps.testing.golden) + testImplementation(libs.maps.testing.visual) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.activity.compose) + testImplementation(libs.kotlinx.serialization.json) } tasks.register("instrumentTest") { diff --git a/library/src/test/java/com/google/maps/android/testing/MapsToolkitLibraryTest.kt b/library/src/test/java/com/google/maps/android/testing/MapsToolkitLibraryTest.kt new file mode 100644 index 000000000..29b125ed3 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/testing/MapsToolkitLibraryTest.kt @@ -0,0 +1,850 @@ +/* + * 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.testing + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Point +import android.location.Location +import android.os.Bundle +import android.view.View +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.Circle +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PointOfInterest +import com.google.android.gms.maps.model.Polygon +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.TileOverlayOptions +import com.google.android.maps.robolectric.annotation.EnableMapsShadows +import com.google.android.maps.robolectric.shadows.ShadowGoogleMap +import com.google.android.maps.robolectric.shadows.ShadowMapView +import com.google.android.maps.robolectric.shadows.ShadowTileOverlay +import com.google.android.maps.robolectric.truth.LatLngSubject +import com.google.android.maps.robolectric.truth.isWithin +import com.google.android.maps.robolectric.truth.kilometers +import com.google.android.maps.robolectric.truth.meters +import com.google.android.maps.testing.golden.GoldenImageDiff +import com.google.android.maps.testing.golden.GoldenTileProvider +import com.google.android.maps.testing.golden.GoldenTileStrategy +import com.google.android.maps.testing.golden.awaitMapLoaded +import com.google.android.maps.testing.golden.captureSnapshot +import com.google.android.maps.testing.golden.enableGoldenTesting +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.MarkerDragEndEvent +import com.google.maps.android.MarkerDragEvent +import com.google.maps.android.MarkerDragStartEvent +import com.google.maps.android.OnMarkerDragEvent +import com.google.maps.android.PolyUtil +import com.google.maps.android.SphericalUtil +import com.google.maps.android.addCircle +import com.google.maps.android.addGroundOverlay +import com.google.maps.android.addMarker +import com.google.maps.android.addPolygon +import com.google.maps.android.addPolyline +import com.google.maps.android.addTileOverlay +import com.google.maps.android.area +import com.google.maps.android.awaitAnimateCamera +import com.google.maps.android.awaitMap +import com.google.maps.android.awaitMapLoad +import com.google.maps.android.awaitSnapshot +import com.google.maps.android.buildGoogleMapOptions +import com.google.maps.android.cameraIdleEvents +import com.google.maps.android.cameraMoveCanceledEvents +import com.google.maps.android.cameraMoveEvents +import com.google.maps.android.cameraMoveStartedEvents +import com.google.maps.android.circleClickEvents +import com.google.maps.android.collections.CircleManager +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.collections.addCircle +import com.google.maps.android.collections.addGroundOverlay +import com.google.maps.android.collections.addMarker +import com.google.maps.android.collections.addPolygon +import com.google.maps.android.collections.addPolyline +import com.google.maps.android.collections.clickEvents +import com.google.maps.android.collections.infoWindowClickEvents +import com.google.maps.android.collections.infoWindowLongClickEvents +import com.google.maps.android.component1 +import com.google.maps.android.component2 +import com.google.maps.android.computeSphericalOffsetOrigin +import com.google.maps.android.contains +import com.google.maps.android.containsLocation +import com.google.maps.android.groundOverlayClicks +import com.google.maps.android.infoWindowClickEvents +import com.google.maps.android.infoWindowCloseEvents +import com.google.maps.android.infoWindowLongClickEvents +import com.google.maps.android.isClosedPolygon +import com.google.maps.android.isLocationOnPath +import com.google.maps.android.isOnEdge +import com.google.maps.android.latLngListEncode +import com.google.maps.android.mapClickEvents +import com.google.maps.android.mapLongClickEvents +import com.google.maps.android.markerClickEvents +import com.google.maps.android.markerDragEvents +import com.google.maps.android.myLocationButtonClickEvents +import com.google.maps.android.myLocationClickEvents +import com.google.maps.android.poiClickEvents +import com.google.maps.android.polygonClickEvents +import com.google.maps.android.polylineClickEvents +import com.google.maps.android.signedArea +import com.google.maps.android.simplify +import com.google.maps.android.sphericalDistance +import com.google.maps.android.sphericalHeading +import com.google.maps.android.sphericalPathLength +import com.google.maps.android.sphericalPolygonArea +import com.google.maps.android.toLatLngList +import com.google.maps.android.withSphericalLinearInterpolation +import com.google.maps.android.withSphericalOffset +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLooper + +/** + * End-to-end JVM integration and adversarial verification suite for the `:library` module + * powered by the Android Maps Testing Toolkit (`v1.2.0-rc01`). + * + * ## Architectural Scope + * Unlike traditional mock-based unit tests, this suite executes against stateful Robolectric + * shadows ([ShadowGoogleMap], [ShadowMapView], [ShadowSupportMapFragment], [ShadowProjection]), + * Truth geospatial subjects ([LatLngSubject]), and offline golden image rasterization + * ([GoldenImageDiff], [GoldenTileProvider], [GoldenTileStrategy]). + * + * We verify four major pillars of `:library`: + * 1. **KTX Coroutine & DSL Extensions**: Suspending lifecycle bridges (`awaitMap`, `awaitMapLoad`, + * `awaitAnimateCamera`, `awaitSnapshot`), builder DSLs (`addMarker`, `addPolygon`, `addPolyline`, + * `addCircle`, `addGroundOverlay`, `addTileOverlay`, `buildGoogleMapOptions`), and cold `callbackFlow` + * streams for camera, marker, info window, overlay, and location events. + * 2. **Multi-Collection Object Managers**: [MarkerManager], [PolygonManager], [PolylineManager], + * [CircleManager], and [GroundOverlayManager] collection isolation, visibility toggling, and + * Flow extensions. + * 3. **Adversarial Race Window Discovery (Bug #9)**: Exposes the asynchronous listener registration + * defect in `MapObjectManager` (`Handler(Looper.getMainLooper()).post { setListenersOnUiThread() }`), + * proving that events dispatched before main looper idling are dropped and that any KTX Flow + * collector subscribed immediately after manager instantiation gets clobbered when the posted + * runnable executes. + * 4. **Spherical Geometry, Polyline Simplification, Projection & Golden Snapshot Diffing**: + * Validates [SphericalUtil] and [PolyUtil] mathematics against [ShadowProjection] screen-space + * transforms, [LatLngSubject] distance tolerances, and [GoldenImageDiff] pixel-by-pixel diffs. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +@EnableMapsShadows +class MapsToolkitLibraryTest { + + private lateinit var activity: Activity + private lateinit var mapView: MapView + private lateinit var googleMap: GoogleMap + private lateinit var shadowMap: ShadowGoogleMap + private lateinit var shadowMapView: ShadowMapView + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).create().get() + mapView = MapView(activity).apply { + onCreate(Bundle()) + onStart() + onResume() + // Measure and layout to a 400x400px viewport so ShadowProjection and + // ShadowMapView.renderMap(canvas) operate on realistic screen dimensions. + measure( + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, 400, 400) + } + + val mapRef = AtomicReference() + mapView.getMapAsync { mapRef.set(it) } + ShadowLooper.idleMainLooper() + + googleMap = checkNotNull(mapRef.get()) + shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap + shadowMapView = Shadow.extract(mapView) as ShadowMapView + } + + // ============================================================================================ + // Section 1: Coroutine Lifecycle Bridges & Builder DSLs + // ============================================================================================ + + /** + * Verifies that `MapView.awaitMap()` and `SupportMapFragment.awaitMap()` suspend until the + * main looper dispatches `OnMapReadyCallback` from [ShadowMapView] and [ShadowSupportMapFragment], + * and that `ShadowMapView` tracks lifecycle state transitions accurately. + */ + @Test + fun awaitMap_onMapViewAndSupportMapFragment_resolvesStatefulGoogleMap() = runTest { + assertThat(shadowMapView.isOnCreateCalled).isTrue() + assertThat(shadowMapView.isOnStartCalled).isTrue() + assertThat(shadowMapView.isOnResumeCalled).isTrue() + + val deferredMapFromView = async(UnconfinedTestDispatcher(testScheduler)) { + mapView.awaitMap() + } + ShadowLooper.idleMainLooper() + val resolvedFromView = deferredMapFromView.await() + assertThat(resolvedFromView).isSameInstanceAs(googleMap) + + val options = buildGoogleMapOptions { + mapType(GoogleMap.MAP_TYPE_HYBRID) + compassEnabled(true) + } + val fragment = SupportMapFragment.newInstance(options) + val deferredMapFromFragment = async(UnconfinedTestDispatcher(testScheduler)) { + fragment.awaitMap() + } + ShadowLooper.idleMainLooper() + val resolvedFromFragment = deferredMapFromFragment.await() + assertThat(resolvedFromFragment).isNotNull() + } + + /** + * Verifies that all KTX overlay builder DSLs (`addMarker`, `addPolygon`, `addPolyline`, + * `addCircle`, `addGroundOverlay`, `addTileOverlay`) construct and register real stateful + * overlay objects in [ShadowGoogleMap], and that [LatLngSubject] validates their geographic + * coordinates within metric tolerances. + */ + @Test + fun overlayBuilderDsls_populateShadowGoogleMapAndSatisfyLatLngTruthMatchers() = runTest { + val boulder = LatLng(40.0150, -105.2705) + googleMap.awaitAnimateCamera(CameraUpdateFactory.newLatLngZoom(boulder, 14f), durationMs = 250) + + LatLngSubject.assertThat(googleMap.cameraPosition.target) + .isWithin(1.meters) + .of(boulder) + assertThat(googleMap.cameraPosition.zoom).isEqualTo(14f) + + // 1. Marker DSL + val marker = checkNotNull( + googleMap.addMarker { + position(boulder) + title("Boulder") + snippet("Colorado") + alpha(0.85f) + rotation(45f) + zIndex(2f) + draggable(true) + flat(true) + } + ) + assertThat(shadowMap.markers).containsExactly(marker) + assertThat(marker.title).isEqualTo("Boulder") + assertThat(marker.snippet).isEqualTo("Colorado") + assertThat(marker.alpha).isWithin(0.001f).of(0.85f) + assertThat(marker.rotation).isWithin(0.001f).of(45f) + assertThat(marker.zIndex).isWithin(0.001f).of(2f) + assertThat(marker.isDraggable).isTrue() + assertThat(marker.isFlat).isTrue() + LatLngSubject.assertThat(marker.position).isWithin(1.meters).of(boulder) + + // 2. Polygon DSL + val triangle = listOf( + boulder, + boulder.withSphericalOffset(500.0, 90.0), + boulder.withSphericalOffset(500.0, 180.0) + ) + val polygon = googleMap.addPolygon { + addAll(triangle) + fillColor(Color.argb(128, 0, 255, 0)) + strokeColor(Color.GREEN) + strokeWidth(6f) + clickable(true) + geodesic(true) + } + assertThat(shadowMap.polygons).containsExactly(polygon) + assertThat(polygon.isClickable).isTrue() + assertThat(polygon.isGeodesic).isTrue() + assertThat(polygon.area).isGreaterThan(100_000.0) + + // 3. Polyline DSL + val polyline = googleMap.addPolyline { + addAll(triangle) + color(Color.BLUE) + width(8f) + clickable(true) + geodesic(true) + } + assertThat(shadowMap.polylines).containsExactly(polyline) + assertThat(polyline.isClickable).isTrue() + assertThat(polyline.sphericalPathLength).isWithin(15.0).of(1207.11) + + // 4. Circle DSL + val circle = googleMap.addCircle { + center(boulder) + radius(250.0) + fillColor(Color.YELLOW) + strokeColor(Color.BLACK) + clickable(true) + } + assertThat(shadowMap.circles).containsExactly(circle) + assertThat(circle.radius).isWithin(0.01).of(250.0) + LatLngSubject.assertThat(circle.center).isWithin(1.meters).of(boulder) + + // 5. GroundOverlay DSL + val overlayBitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.MAGENTA) + } + val groundOverlay = checkNotNull( + googleMap.addGroundOverlay { + image(BitmapDescriptorFactory.fromBitmap(overlayBitmap)) + position(boulder, 100f, 100f) + clickable(true) + transparency(0.2f) + } + ) + assertThat(shadowMap.groundOverlays).containsExactly(groundOverlay) + assertThat(groundOverlay.isClickable).isTrue() + assertThat(groundOverlay.transparency).isWithin(0.001f).of(0.2f) + + // 6. TileOverlay DSL with GoldenTileProvider + val goldenTileProvider = GoldenTileProvider(strategy = GoldenTileStrategy.Checkerboard()) + val tileOverlay = checkNotNull( + googleMap.addTileOverlay { + tileProvider(goldenTileProvider) + fadeIn(false) + zIndex(10f) + transparency(0.1f) + } + ) + assertThat(shadowMap.tileOverlays).containsExactly(tileOverlay) + assertThat(tileOverlay.fadeIn).isFalse() + assertThat(tileOverlay.zIndex).isWithin(0.001f).of(10f) + tileOverlay.clearTileCache() + val shadowTileOverlay = Shadow.extract(tileOverlay) as ShadowTileOverlay + assertThat(shadowTileOverlay.isTileCacheCleared).isTrue() + } + + // ============================================================================================ + // Section 2: Reactive Event Flows on GoogleMap + // ============================================================================================ + + /** + * Verifies all `GoogleMap` KTX cold `callbackFlow` streams (`cameraMoveStartedEvents`, + * `cameraMoveEvents`, `cameraIdleEvents`, `cameraMoveCanceledEvents`, `mapClickEvents`, + * `mapLongClickEvents`, `markerClickEvents`, `markerDragEvents`, `infoWindowClickEvents`, + * `infoWindowLongClickEvents`, `infoWindowCloseEvents`, `polygonClickEvents`, + * `polylineClickEvents`, `circleClickEvents`, `groundOverlayClicks`, `poiClickEvents`, + * `myLocationButtonClickEvents`, `myLocationClickEvents`, and `awaitMapLoad`) against + * synthetic user interactions dispatched by [ShadowGoogleMap]. + */ + @Test + fun googleMapEventFlows_emitDeterministicallyAndUnregisterOnCancellation() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val origin = LatLng(37.4220, -122.0841) + + // Collect camera lifecycle events + val moveStartedReasons = mutableListOf() + val moveEvents = mutableListOf() + val idleEvents = mutableListOf() + val canceledEvents = mutableListOf() + + val cameraJobs = listOf( + launch(dispatcher) { googleMap.cameraMoveStartedEvents().toList(moveStartedReasons) }, + launch(dispatcher) { googleMap.cameraMoveEvents().toList(moveEvents) }, + launch(dispatcher) { googleMap.cameraIdleEvents().toList(idleEvents) }, + launch(dispatcher) { googleMap.cameraMoveCanceledEvents().toList(canceledEvents) } + ) + + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(origin, 15f)) + shadowMap.cancelCameraMove() + + assertThat(moveStartedReasons).containsExactly( + GoogleMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION + ) + assertThat(moveEvents).hasSize(1) + assertThat(idleEvents).hasSize(1) + assertThat(canceledEvents).hasSize(1) + cameraJobs.forEach { it.cancel() } + + // Collect map click & long-click events + val mapClicks = mutableListOf() + val mapLongClicks = mutableListOf() + val clickJob = launch(dispatcher) { googleMap.mapClickEvents().toList(mapClicks) } + val longClickJob = launch(dispatcher) { googleMap.mapLongClickEvents().toList(mapLongClicks) } + + shadowMap.clickMap(origin) + shadowMap.longClickMap(origin) + assertThat(mapClicks).containsExactly(origin) + assertThat(mapLongClicks).containsExactly(origin) + + // Verify cancellation unregisters listeners so subsequent clicks are ignored + clickJob.cancel() + longClickJob.cancel() + shadowMap.clickMap(origin) + shadowMap.longClickMap(origin) + assertThat(mapClicks).hasSize(1) + assertThat(mapLongClicks).hasSize(1) + + // Marker click, drag, and info window lifecycle flows + val marker = checkNotNull(googleMap.addMarker { position(origin).title("Googleplex") }) + val markerClicks = mutableListOf() + val dragEvents = mutableListOf() + val infoClicks = mutableListOf() + val infoLongClicks = mutableListOf() + val infoCloses = mutableListOf() + + val markerJobs = listOf( + launch(dispatcher) { googleMap.markerClickEvents().toList(markerClicks) }, + launch(dispatcher) { googleMap.markerDragEvents().toList(dragEvents) }, + launch(dispatcher) { googleMap.infoWindowClickEvents().toList(infoClicks) }, + launch(dispatcher) { googleMap.infoWindowLongClickEvents().toList(infoLongClicks) }, + launch(dispatcher) { googleMap.infoWindowCloseEvents().toList(infoCloses) } + ) + + shadowMap.clickMarker(marker) + val draggedMid = origin.withSphericalOffset(50.0, 45.0) + val draggedEnd = origin.withSphericalOffset(100.0, 45.0) + shadowMap.dragMarkerStart(marker) + shadowMap.dragMarker(marker, draggedMid) + shadowMap.dragMarkerEnd(marker, draggedEnd) + + marker.showInfoWindow() + assertThat(marker.isInfoWindowShown).isTrue() + shadowMap.clickInfoWindow(marker) + shadowMap.longClickInfoWindow(marker) + marker.hideInfoWindow() + assertThat(marker.isInfoWindowShown).isFalse() + + assertThat(markerClicks).containsExactly(marker) + assertThat(dragEvents).containsExactly( + MarkerDragStartEvent(marker), + MarkerDragEvent(marker), + MarkerDragEndEvent(marker) + ).inOrder() + LatLngSubject.assertThat(marker.position).isWithin(1.meters).of(draggedEnd) + assertThat(infoClicks).containsExactly(marker) + assertThat(infoLongClicks).containsExactly(marker) + assertThat(infoCloses).containsExactly(marker) + markerJobs.forEach { it.cancel() } + + // Shape, POI, and MyLocation flows + val polygon = googleMap.addPolygon { + add(origin, draggedMid, draggedEnd) + clickable(true) + } + val polyline = googleMap.addPolyline { + add(origin, draggedEnd) + clickable(true) + } + val circle = googleMap.addCircle { + center(origin) + radius(100.0) + clickable(true) + } + val groundOverlay = checkNotNull( + googleMap.addGroundOverlay { + position(origin, 50f) + clickable(true) + } + ) + + val clickedPolygons = mutableListOf() + val clickedPolylines = mutableListOf() + val clickedCircles = mutableListOf() + val clickedOverlays = mutableListOf() + val clickedPois = mutableListOf() + val myLocationBtnClicks = mutableListOf() + val myLocationClicks = mutableListOf() + + val overlayJobs = listOf( + launch(dispatcher) { googleMap.polygonClickEvents().toList(clickedPolygons) }, + launch(dispatcher) { googleMap.polylineClickEvents().toList(clickedPolylines) }, + launch(dispatcher) { googleMap.circleClickEvents().toList(clickedCircles) }, + launch(dispatcher) { googleMap.groundOverlayClicks().toList(clickedOverlays) }, + launch(dispatcher) { googleMap.poiClickEvents().toList(clickedPois) }, + launch(dispatcher) { googleMap.myLocationButtonClickEvents().toList(myLocationBtnClicks) }, + launch(dispatcher) { googleMap.myLocationClickEvents().toList(myLocationClicks) } + ) + + shadowMap.clickPolygon(polygon) + shadowMap.clickPolyline(polyline) + shadowMap.clickCircle(circle) + shadowMap.clickGroundOverlay(groundOverlay) + val poi = PointOfInterest(origin, "place_id_123", "Amphitheatre") + shadowMap.clickPoi(poi) + assertThat(shadowMap.clickMyLocationButton()).isTrue() + val location = Location("gps").apply { + latitude = origin.latitude + longitude = origin.longitude + } + shadowMap.clickMyLocation(location) + + assertThat(clickedPolygons).containsExactly(polygon) + assertThat(clickedPolylines).containsExactly(polyline) + assertThat(clickedCircles).containsExactly(circle) + assertThat(clickedOverlays).containsExactly(groundOverlay) + assertThat(clickedPois).containsExactly(poi) + assertThat(myLocationBtnClicks).hasSize(1) + assertThat(myLocationClicks).containsExactly(location) + overlayJobs.forEach { it.cancel() } + + // Both KTX awaitMapLoad() and golden-testing awaitMapLoaded() + val mapLoadJob1 = async(dispatcher) { googleMap.awaitMapLoad() } + shadowMap.notifyMapLoaded() + mapLoadJob1.await() + + val mapLoadJob2 = async(dispatcher) { googleMap.awaitMapLoaded() } + shadowMap.notifyMapLoaded() + mapLoadJob2.await() + } + + // ============================================================================================ + // Section 3: Multi-Collection Object Managers & Bug #9 Race Window Proof + // ============================================================================================ + + /** + * Verifies multi-collection isolation, visibility toggling (`hideAll`/`showAll`), removal, + * and Flow collection across [MarkerManager], [PolygonManager], [PolylineManager], + * [CircleManager], and [GroundOverlayManager] after the main looper has processed their + * listener registration. + */ + @Test + fun mapObjectManagers_isolateCollectionsAndRouteClickFlowsCorrectly() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val center = LatLng(47.6062, -122.3321) + + val markerManager = MarkerManager(googleMap) + val polygonManager = PolygonManager(googleMap) + val polylineManager = PolylineManager(googleMap) + val circleManager = CircleManager(googleMap) + val groundOverlayManager = GroundOverlayManager(googleMap) + + // Idle main looper so MapObjectManager's posted setListenersOnUiThread() completes + ShadowLooper.idleMainLooper() + + val markerColA = markerManager.newCollection("hotels") + val markerColB = markerManager.newCollection("restaurants") + + val hotelMarker = markerColA.addMarker { + position(center) + title("Hotel") + } + val restaurantMarker = markerColB.addMarker { + position(center.withSphericalOffset(100.0, 90.0)) + title("Restaurant") + } + + val hotelClicks = mutableListOf() + val restaurantClicks = mutableListOf() + val hotelInfoClicks = mutableListOf() + val hotelInfoLongClicks = mutableListOf() + + val jobs = mutableListOf( + launch(dispatcher) { markerColA.clickEvents().toList(hotelClicks) }, + launch(dispatcher) { markerColB.clickEvents().toList(restaurantClicks) }, + launch(dispatcher) { markerColA.infoWindowClickEvents().toList(hotelInfoClicks) }, + launch(dispatcher) { markerColA.infoWindowLongClickEvents().toList(hotelInfoLongClicks) } + ) + + // Clicking hotelMarker only emits to markerColA, never markerColB + shadowMap.clickMarker(hotelMarker) + shadowMap.clickInfoWindow(hotelMarker) + shadowMap.longClickInfoWindow(hotelMarker) + assertThat(hotelClicks).containsExactly(hotelMarker) + assertThat(restaurantClicks).isEmpty() + assertThat(hotelInfoClicks).containsExactly(hotelMarker) + assertThat(hotelInfoLongClicks).containsExactly(hotelMarker) + + shadowMap.clickMarker(restaurantMarker) + assertThat(restaurantClicks).containsExactly(restaurantMarker) + + // Visibility & clear isolation + markerColA.hideAll() + assertThat(hotelMarker.isVisible).isFalse() + assertThat(restaurantMarker.isVisible).isTrue() + markerColA.showAll() + assertThat(hotelMarker.isVisible).isTrue() + + // Polygon, Polyline, Circle, and GroundOverlay manager flows + val polyCol = polygonManager.newCollection() + val lineCol = polylineManager.newCollection() + val circleCol = circleManager.newCollection() + val groundCol = groundOverlayManager.newCollection() + + val managedPolygon = polyCol.addPolygon { + add(center, center.withSphericalOffset(50.0, 0.0), center.withSphericalOffset(50.0, 90.0)) + clickable(true) + } + val managedPolyline = lineCol.addPolyline { + add(center, center.withSphericalOffset(100.0, 180.0)) + clickable(true) + } + val managedCircle = circleCol.addCircle { + center(center) + radius(75.0) + clickable(true) + } + val managedGroundOverlay = groundCol.addGroundOverlay { + position(center, 60f) + clickable(true) + } + + val polyClicks = mutableListOf() + val lineClicks = mutableListOf() + val circleClicks = mutableListOf() + val groundClicks = mutableListOf() + + jobs += launch(dispatcher) { polyCol.clickEvents().toList(polyClicks) } + jobs += launch(dispatcher) { lineCol.clickEvents().toList(lineClicks) } + jobs += launch(dispatcher) { circleCol.clickEvents().toList(circleClicks) } + jobs += launch(dispatcher) { groundCol.clickEvents().toList(groundClicks) } + + shadowMap.clickPolygon(managedPolygon) + shadowMap.clickPolyline(managedPolyline) + shadowMap.clickCircle(managedCircle) + shadowMap.clickGroundOverlay(managedGroundOverlay) + + assertThat(polyClicks).containsExactly(managedPolygon) + assertThat(lineClicks).containsExactly(managedPolyline) + assertThat(circleClicks).containsExactly(managedCircle) + assertThat(groundClicks).containsExactly(managedGroundOverlay) + + markerColA.clear() + assertThat(shadowMap.markers).containsExactly(restaurantMarker) + jobs.forEach { it.cancel() } + } + + /** + * **BUG #9 REGRESSION VERIFICATION (`MapObjectManager` Synchronous Listener Registration on UI Thread)**: + * + * Verifies that constructing [MarkerManager] on the main thread registers its listeners + * synchronously without waiting for a posted main-looper runnable: + * 1. Any marker click dispatched immediately after constructing `MarkerManager` (before the + * main looper drains) is routed immediately to the collection's listener. + * 2. If a caller subsequently registers a direct `googleMap.markerClickEvents()` collector, + * draining the main looper later does NOT overwrite that collector with a delayed init runnable. + */ + @Test + fun bug9_mapObjectManager_registersListenersSynchronouslyOnUiThread() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val point = LatLng(37.7749, -122.4194) + + // Instantiate MarkerManager on the main thread WITHOUT idling the looper + val markerManager = MarkerManager(googleMap) + val collection = markerManager.newCollection() + val marker = collection.addMarker(MarkerOptions().position(point).title("San Francisco")) + + val managerClicks = mutableListOf() + collection.setOnMarkerClickListener { + managerClicks.add(it) + true + } + + // 1. Click marker immediately -> routed synchronously to MarkerManager! + shadowMap.clickMarker(marker) + assertThat(managerClicks).containsExactly(marker) + + // 2. Suppose a caller subscribes to googleMap.markerClickEvents() after creating MarkerManager + val directFlowClicks = mutableListOf() + val flowJob = launch(dispatcher) { + googleMap.markerClickEvents().toList(directFlowClicks) + } + + shadowMap.clickMarker(marker) + assertThat(directFlowClicks).containsExactly(marker) + + // 3. Draining the main looper does NOT clobber the active `googleMap.markerClickEvents()` listener! + ShadowLooper.idleMainLooper() + shadowMap.clickMarker(marker) + assertThat(directFlowClicks).hasSize(2) + + flowJob.cancel() + } + + // ============================================================================================ + // Section 4: Spherical Geometry, Projection Alignment & Golden Visual Diffing + // ============================================================================================ + + /** + * Integrates [SphericalUtil], [PolyUtil], and KTX geometry extensions (`component1`, `component2`, + * `withSphericalOffset`, `computeSphericalOffsetOrigin`, `withSphericalLinearInterpolation`, + * `sphericalDistance`, `sphericalHeading`, `simplify`, `latLngListEncode`, `toLatLngList`) with + * [ShadowProjection] and [LatLngSubject] to verify round-trip geographic-to-screen accuracy. + */ + @Test + fun sphericalAndPolyUtils_alignWithShadowProjectionAndLatLngSubject() { + val origin = LatLng(37.7749, -122.4194) + val (lat, lng) = origin + assertThat(lat).isEqualTo(37.7749) + assertThat(lng).isEqualTo(-122.4194) + + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(origin, 15f)) + val projection = googleMap.projection + + // Center of 400x400 MapView must project to (200, 200) + val centerPoint = projection.toScreenLocation(origin) + assertThat(centerPoint).isEqualTo(Point(200, 200)) + + // Offset 500 meters East (heading 90 degrees) + val east500m = origin.withSphericalOffset(500.0, 90.0) + LatLngSubject.assertThat(east500m).isWithin(501.meters).of(origin) + assertThat(origin.sphericalDistance(east500m)).isWithin(0.5).of(500.0) + assertThat(origin.sphericalHeading(east500m)).isWithin(0.01).of(90.0) + + // Recover origin via computeSphericalOffsetOrigin + val recoveredOrigin = checkNotNull(east500m.computeSphericalOffsetOrigin(500.0, 90.0)) + LatLngSubject.assertThat(recoveredOrigin).isWithin(1.meters).of(origin) + + // Midpoint via Slerp (fraction = 0.5) + val midpoint = origin.withSphericalLinearInterpolation(east500m, 0.5) + LatLngSubject.assertThat(midpoint).isWithin(251.meters).of(origin) + LatLngSubject.assertThat(midpoint).isWithin(251.meters).of(east500m) + assertThat( listOf(origin, east500m).isLocationOnPath(midpoint, geodesic = true, tolerance = 1.0) ).isTrue() + + // Project east500m to screen coordinates and back via ShadowProjection + val eastScreen = projection.toScreenLocation(east500m) + assertThat(eastScreen.x).isGreaterThan(centerPoint.x) + assertThat(eastScreen.y).isEqualTo(centerPoint.y) + val roundTripLatLng = projection.fromScreenLocation(eastScreen) + LatLngSubject.assertThat(roundTripLatLng).isWithin(10.meters).of(east500m) + + // VisibleRegion bounds must contain origin and east500m at zoom 15 on a 400x400 viewport + val visibleRegion = projection.visibleRegion + assertThat(visibleRegion.latLngBounds.contains(origin)).isTrue() + assertThat(visibleRegion.latLngBounds.contains(east500m)).isTrue() + + // Polyline encoding/decoding and Douglas-Peucker simplification + val noisyPath = listOf( + origin, + origin.withSphericalLinearInterpolation(east500m, 0.25).withSphericalOffset(0.2, 0.0), + midpoint, + origin.withSphericalLinearInterpolation(east500m, 0.75).withSphericalOffset(0.2, 180.0), + east500m + ) + val simplified = noisyPath.simplify(tolerance = 2.0) + assertThat(simplified).containsExactly(origin, east500m).inOrder() + + val encoded = simplified.latLngListEncode() + val decoded = encoded.toLatLngList() + assertThat(decoded).hasSize(2) + LatLngSubject.assertThat(decoded[0]).isWithin(2.meters).of(origin) + LatLngSubject.assertThat(decoded[1]).isWithin(2.meters).of(east500m) + } + + /** + * Exercises `GoogleMap.enableGoldenTesting()`, `GoogleMap.awaitSnapshot()`, + * `GoogleMap.captureSnapshot()`, and [GoldenImageDiff] to verify pixel-accurate rendering + * of polygons, polylines, circles, markers, and ground overlays on [ShadowMapView]. + */ + @Test + fun goldenTestingAndSnapshots_detectVisualOverlayChangesAndMatchIdenticalScenes() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val center = LatLng(37.7749, -122.4194) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(center, 14f)) + + // Enable GoldenTesting with CoordinateGrid synthetic tiles + val goldenTileOverlay = googleMap.enableGoldenTesting( + strategy = GoldenTileStrategy.CoordinateGrid(), + tileSize = 256, + fadeIn = false + ) + assertThat(googleMap.mapType).isEqualTo(GoogleMap.MAP_TYPE_NONE) + assertThat(shadowMap.tileOverlays).contains(goldenTileOverlay) + + // Capture baseline empty scene snapshot via KTX awaitSnapshot() + val baselineJob = async(dispatcher) { googleMap.awaitSnapshot() } + ShadowLooper.idleMainLooper() + val baselineBitmap = checkNotNull(baselineJob.await()) + assertThat(baselineBitmap.width).isEqualTo(400) + assertThat(baselineBitmap.height).isEqualTo(400) + + // Capture a second snapshot of the identical scene via golden-testing captureSnapshot() + val secondJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val identicalBitmap = secondJob.await() + + // Pixel-by-pixel comparison must match with 0 differing pixels + GoldenImageDiff.assertMatches( + expected = baselineBitmap, + actual = identicalBitmap, + maxDiffPercentage = 0.0 + ) + + // Now render a closed polygon, polyline, circle, and marker onto the map + val square = listOf( + center.withSphericalOffset(600.0, 45.0), + center.withSphericalOffset(600.0, 135.0), + center.withSphericalOffset(600.0, 225.0), + center.withSphericalOffset(600.0, 315.0), + center.withSphericalOffset(600.0, 45.0) + ) + assertThat(square.isClosedPolygon()).isTrue() + assertThat(square.containsLocation(center, geodesic = true)).isTrue() + + val polygon = googleMap.addPolygon { + addAll(square) + fillColor(Color.argb(180, 255, 0, 0)) + strokeColor(Color.BLUE) + strokeWidth(5f) + } + assertThat(polygon.contains(center)).isTrue() + assertThat(polygon.isOnEdge(square[0], tolerance = 5.0)).isTrue() + assertThat( kotlin.math.abs(polygon.signedArea) ).isWithin(1.0).of(polygon.area) + + googleMap.addCircle { + center(center) + radius(200.0) + fillColor(Color.GREEN) + strokeColor(Color.BLACK) + strokeWidth(3f) + } + googleMap.addMarker { + position(center) + title("Center Pin") + } + + val renderedJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val renderedBitmap = renderedJob.await() + + // Compare baseline vs rendered scene: must diverge significantly and generate a diff bitmap + val diffResult = GoldenImageDiff.compare( + expected = baselineBitmap, + actual = renderedBitmap, + maxDiffPercentage = 0.001, + generateDiffBitmap = true + ) + assertThat(diffResult.matches).isFalse() + assertThat(diffResult.diffPixelCount).isGreaterThan(500) + assertThat(diffResult.diffPercentage).isGreaterThan(0.003) + assertThat(diffResult.diffBitmap).isNotNull() + } +} diff --git a/library/src/test/resources/robolectric.properties b/library/src/test/resources/robolectric.properties index 89a6c8b4c..aafb05cf4 100644 --- a/library/src/test/resources/robolectric.properties +++ b/library/src/test/resources/robolectric.properties @@ -1 +1,17 @@ -sdk=28 \ No newline at end of file +sdk=28 +shadows=com.google.android.maps.robolectric.shadows.ShadowMapView,\ +com.google.android.maps.robolectric.shadows.ShadowGoogleMap,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdate,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdateFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMarker,\ +com.google.android.maps.robolectric.shadows.ShadowPolygon,\ +com.google.android.maps.robolectric.shadows.ShadowPolyline,\ +com.google.android.maps.robolectric.shadows.ShadowProjection,\ +com.google.android.maps.robolectric.shadows.ShadowCircle,\ +com.google.android.maps.robolectric.shadows.ShadowUiSettings,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptor,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptorFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMapsInitializer,\ +com.google.android.maps.robolectric.shadows.ShadowSupportMapFragment,\ +com.google.android.maps.robolectric.shadows.ShadowGroundOverlay,\ +com.google.android.maps.robolectric.shadows.ShadowTileOverlay \ No newline at end of file diff --git a/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.aar b/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.aar new file mode 100644 index 000000000..72f0cfbf6 Binary files /dev/null and b/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.aar differ diff --git a/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.module b/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.module new file mode 100644 index 000000000..e03226d09 --- /dev/null +++ b/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.module @@ -0,0 +1,116 @@ +{ + "formatVersion": "1.1", + "component": { + "group": "com.google.android.maps.robolectric", + "module": "shadows", + "version": "1.2.0-rc01", + "attributes": { + "org.gradle.status": "release" + } + }, + "createdBy": { + "gradle": { + "version": "9.8.0" + } + }, + "variants": [ + { + "name": "releaseVariantReleaseApiPublication", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.libraryelements": "aar", + "org.gradle.usage": "java-api" + }, + "dependencies": [ + { + "group": "org.jetbrains.kotlin", + "module": "kotlin-stdlib", + "version": { + "requires": "2.4.20" + } + } + ], + "files": [ + { + "name": "shadows-1.2.0-rc01.aar", + "url": "shadows-1.2.0-rc01.aar", + "size": 52137, + "sha512": "17fa4d6ea393b76d2f08b7b7d36bfbceff898e142fcaeeb86cf05c1570267eb992baddfe9439f5698eeb4b7804a98ceb7201715048c96cf6ad0ce222625196f5", + "sha256": "96fb74d9516c13d974b022c0960ea72594129e91c4f3dd9e64248f9fedb11a9f", + "sha1": "7f2156c66812821a875951bb25038b43d94060bc", + "md5": "d3c2748f2afa2011711382d832b00881" + } + ] + }, + { + "name": "releaseVariantReleaseRuntimePublication", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.libraryelements": "aar", + "org.gradle.usage": "java-runtime" + }, + "dependencies": [ + { + "group": "org.jetbrains.kotlin", + "module": "kotlin-stdlib", + "version": { + "requires": "2.4.20" + } + } + ], + "files": [ + { + "name": "shadows-1.2.0-rc01.aar", + "url": "shadows-1.2.0-rc01.aar", + "size": 52137, + "sha512": "17fa4d6ea393b76d2f08b7b7d36bfbceff898e142fcaeeb86cf05c1570267eb992baddfe9439f5698eeb4b7804a98ceb7201715048c96cf6ad0ce222625196f5", + "sha256": "96fb74d9516c13d974b022c0960ea72594129e91c4f3dd9e64248f9fedb11a9f", + "sha1": "7f2156c66812821a875951bb25038b43d94060bc", + "md5": "d3c2748f2afa2011711382d832b00881" + } + ] + }, + { + "name": "releaseVariantReleaseSourcePublication", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "sources", + "org.gradle.usage": "java-runtime" + }, + "files": [ + { + "name": "shadows-1.2.0-rc01-sources.jar", + "url": "shadows-1.2.0-rc01-sources.jar", + "size": 33662, + "sha512": "0caceea78565a16cb86bc6dec74a15e433e46cb89b3f3f53707267eaece243d877bb9e6c4f2aca15c3fcdcba99e76ed4c4b20d5d31d3cd79255001e51ff17519", + "sha256": "15df543fcf57dae223cd63b5ae227bce0674c11bc662b7a7542e3bfdf1636794", + "sha1": "91a4429a454eda8cdc2af345c77e1d79dc520afc", + "md5": "c3a81a1b425bdad71954519252100df9" + } + ] + }, + { + "name": "releaseVariantReleaseJavaDocPublication", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "javadoc", + "org.gradle.usage": "java-runtime" + }, + "files": [ + { + "name": "shadows-1.2.0-rc01-javadoc.jar", + "url": "shadows-1.2.0-rc01-javadoc.jar", + "size": 512132, + "sha512": "70541c23be50617330177fb89d6b90b29fe433fae73ee58f3984f63635f1cc7c34d89a15d42983cfc78f0c4235e9de1e52f0358db1b39a68699c68cb3d7eb43d", + "sha256": "8e43f694c8cb5c99924af1c27a198da738b1556b33c28dac6f78ae871f573f13", + "sha1": "8ea09353a624786e4359930f78d44f1f53fc4dbd", + "md5": "16e68217a1115e909f0c1000d490e11e" + } + ] + } + ] +} diff --git a/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.pom b/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.pom new file mode 100644 index 000000000..78bc15f2d --- /dev/null +++ b/repo/com/google/android/maps/robolectric/shadows/1.2.0-rc01/shadows-1.2.0-rc01.pom @@ -0,0 +1,41 @@ + + + + + + + + 4.0.0 + com.google.android.maps.robolectric + shadows + 1.2.0-rc01 + aar + Android Maps Robolectric Shadows + Robolectric shadows and Truth testing utilities for Google Maps SDK on Android. + https://github.com/googlemaps/android-maps-robolectric + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Google Maps Platform Developer Relations + + + + scm:git:github.com/googlemaps/android-maps-robolectric.git + scm:git:ssh://github.com/googlemaps/android-maps-robolectric.git + https://github.com/googlemaps/android-maps-robolectric/tree/main + + + + org.jetbrains.kotlin + kotlin-stdlib + 2.4.20 + compile + + + diff --git a/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.aar b/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.aar new file mode 100644 index 000000000..ad149c128 Binary files /dev/null and b/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.aar differ diff --git a/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.module b/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.module new file mode 100644 index 000000000..5c8292a64 --- /dev/null +++ b/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.module @@ -0,0 +1,165 @@ +{ + "formatVersion": "1.1", + "component": { + "group": "com.google.android.maps.testing", + "module": "golden", + "version": "1.2.0-rc01", + "attributes": { + "org.gradle.status": "release" + } + }, + "createdBy": { + "gradle": { + "version": "9.8.0" + } + }, + "variants": [ + { + "name": "releaseVariantReleaseApiPublication", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.libraryelements": "aar", + "org.gradle.usage": "java-api" + }, + "dependencies": [ + { + "group": "com.google.android.gms", + "module": "play-services-maps", + "version": { + "requires": "20.0.0" + } + }, + { + "group": "com.google.android.maps.robolectric", + "module": "shadows", + "version": { + "requires": "1.2.0-rc01" + } + }, + { + "group": "org.jetbrains.kotlin", + "module": "kotlin-stdlib", + "version": { + "requires": "2.4.20" + } + } + ], + "files": [ + { + "name": "golden-1.2.0-rc01.aar", + "url": "golden-1.2.0-rc01.aar", + "size": 35141, + "sha512": "61761d8d38d78e4fa0664ec8bacc2b4489b43adc0a198eb1d242ace6e537b8676665a8541b2d5c688acbdeb132061b41753805ebe63b47cc749fa0e0c433ffc9", + "sha256": "facebac421f9a92db7f9107eb57890068dd893311163667b9574b7ff0ab7f1c1", + "sha1": "44d13a70059b3a757e665376902784be93e2cbb4", + "md5": "ddb57a94c96754165fbb185bfa3756fd" + } + ] + }, + { + "name": "releaseVariantReleaseRuntimePublication", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.libraryelements": "aar", + "org.gradle.usage": "java-runtime" + }, + "dependencies": [ + { + "group": "androidx.core", + "module": "core-ktx", + "version": { + "requires": "1.18.0" + } + }, + { + "group": "androidx.compose", + "module": "compose-bom", + "version": { + "requires": "2026.03.01" + }, + "attributes": { + "org.gradle.category": "platform" + } + }, + { + "group": "androidx.compose.ui", + "module": "ui" + }, + { + "group": "com.google.android.gms", + "module": "play-services-maps", + "version": { + "requires": "20.0.0" + } + }, + { + "group": "com.google.android.maps.robolectric", + "module": "shadows", + "version": { + "requires": "1.2.0-rc01" + } + }, + { + "group": "org.jetbrains.kotlin", + "module": "kotlin-stdlib", + "version": { + "requires": "2.4.20" + } + } + ], + "files": [ + { + "name": "golden-1.2.0-rc01.aar", + "url": "golden-1.2.0-rc01.aar", + "size": 35141, + "sha512": "61761d8d38d78e4fa0664ec8bacc2b4489b43adc0a198eb1d242ace6e537b8676665a8541b2d5c688acbdeb132061b41753805ebe63b47cc749fa0e0c433ffc9", + "sha256": "facebac421f9a92db7f9107eb57890068dd893311163667b9574b7ff0ab7f1c1", + "sha1": "44d13a70059b3a757e665376902784be93e2cbb4", + "md5": "ddb57a94c96754165fbb185bfa3756fd" + } + ] + }, + { + "name": "releaseVariantReleaseSourcePublication", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "sources", + "org.gradle.usage": "java-runtime" + }, + "files": [ + { + "name": "golden-1.2.0-rc01-sources.jar", + "url": "golden-1.2.0-rc01-sources.jar", + "size": 11874, + "sha512": "c33cba31cdb13557e58927a17ee98d67d7ce25ffcfdf2542d4ab47849670b24764e2e5f819e1a0e53137f70225a950564df27d3e12f79362dcd110b543a5efdc", + "sha256": "22cb8d3947ae4608dfc661aa902270b7d3490e544ae2b326059383316fdbf9cc", + "sha1": "b0de209b204f2ccaeed341e5a4e88ef44ce7c7bd", + "md5": "97eeb9eaaead0c944a21c6e8468e0a1c" + } + ] + }, + { + "name": "releaseVariantReleaseJavaDocPublication", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "javadoc", + "org.gradle.usage": "java-runtime" + }, + "files": [ + { + "name": "golden-1.2.0-rc01-javadoc.jar", + "url": "golden-1.2.0-rc01-javadoc.jar", + "size": 460055, + "sha512": "7fa09baaa311ba31e42368242022ca99d13a4821e71ecfa20eda804b81b461c3a96317929e782d0d3b6d2ba7fe85e43e3df3d9861a2d1e891aeda517e6f25aa4", + "sha256": "231d804a14463a383a7b0ab4710df5ac39fe3ab9249067709cbc207d1aa7f150", + "sha1": "e0b7b50335e80ad57ef4276d0c1547372099c358", + "md5": "7e5e9f3a6fe38f66df8d3ca2805e1419" + } + ] + } + ] +} diff --git a/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.pom b/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.pom new file mode 100644 index 000000000..eaea2e6b8 --- /dev/null +++ b/repo/com/google/android/maps/testing/golden/1.2.0-rc01/golden-1.2.0-rc01.pom @@ -0,0 +1,75 @@ + + + + + + + + 4.0.0 + com.google.android.maps.testing + golden + 1.2.0-rc01 + aar + Android Maps Golden Testing + Deterministic golden image and screenshot testing library for Google Maps SDK on Android. + https://github.com/googlemaps/android-maps-robolectric + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Google Maps Platform Developer Relations + + + + scm:git:github.com/googlemaps/android-maps-robolectric.git + scm:git:ssh://github.com/googlemaps/android-maps-robolectric.git + https://github.com/googlemaps/android-maps-robolectric/tree/main + + + + + androidx.compose + compose-bom + 2026.03.01 + pom + import + + + + + + com.google.android.gms + play-services-maps + 20.0.0 + compile + + + com.google.android.maps.robolectric + shadows + 1.2.0-rc01 + compile + + + org.jetbrains.kotlin + kotlin-stdlib + 2.4.20 + compile + + + androidx.core + core-ktx + 1.18.0 + runtime + + + androidx.compose.ui + ui + runtime + + + diff --git a/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.aar b/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.aar new file mode 100644 index 000000000..961e66710 Binary files /dev/null and b/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.aar differ diff --git a/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.module b/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.module new file mode 100644 index 000000000..55a41966f --- /dev/null +++ b/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.module @@ -0,0 +1,228 @@ +{ + "formatVersion": "1.1", + "component": { + "group": "com.google.android.maps.testing", + "module": "visual", + "version": "1.2.0-rc01", + "attributes": { + "org.gradle.status": "release" + } + }, + "createdBy": { + "gradle": { + "version": "9.8.0" + } + }, + "variants": [ + { + "name": "releaseVariantReleaseApiPublication", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.libraryelements": "aar", + "org.gradle.usage": "java-api" + }, + "dependencies": [ + { + "group": "com.google.android.gms", + "module": "play-services-maps", + "version": { + "requires": "20.0.0" + } + }, + { + "group": "com.google.android.maps.testing", + "module": "golden", + "version": { + "requires": "1.2.0-rc01" + } + }, + { + "group": "androidx.test", + "module": "core", + "version": { + "requires": "1.7.0" + } + }, + { + "group": "junit", + "module": "junit", + "version": { + "requires": "4.13.2" + } + }, + { + "group": "androidx.test.ext", + "module": "junit", + "version": { + "requires": "1.3.0" + } + }, + { + "group": "androidx.test.uiautomator", + "module": "uiautomator", + "version": { + "requires": "2.3.0" + } + }, + { + "group": "org.jetbrains.kotlin", + "module": "kotlin-stdlib", + "version": { + "requires": "2.4.20" + } + } + ], + "files": [ + { + "name": "visual-1.2.0-rc01.aar", + "url": "visual-1.2.0-rc01.aar", + "size": 114399, + "sha512": "1095832f7cb44ceda73839a900b5b71c079033719947de3cac72989282c68a9a4b060cbdfa0a61eed70fc79e33cae42ca33012eec49f0f7987ae987abce81a4b", + "sha256": "9abea432efa669f74f091a23f3f8e839c544d7f3017746746d96b0668c1a9433", + "sha1": "4e6dde1e53d974a5322e391d900483a22ca12f4c", + "md5": "3d9f4fd0b788a650e607d57eb7369f1e" + } + ] + }, + { + "name": "releaseVariantReleaseRuntimePublication", + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.libraryelements": "aar", + "org.gradle.usage": "java-runtime" + }, + "dependencies": [ + { + "group": "androidx.core", + "module": "core-ktx", + "version": { + "requires": "1.18.0" + } + }, + { + "group": "org.jetbrains.kotlinx", + "module": "kotlinx-serialization-json", + "version": { + "requires": "1.11.0" + } + }, + { + "group": "io.ktor", + "module": "ktor-client-core", + "version": { + "requires": "3.6.0" + } + }, + { + "group": "io.ktor", + "module": "ktor-client-cio", + "version": { + "requires": "3.6.0" + } + }, + { + "group": "com.google.android.gms", + "module": "play-services-maps", + "version": { + "requires": "20.0.0" + } + }, + { + "group": "com.google.android.maps.testing", + "module": "golden", + "version": { + "requires": "1.2.0-rc01" + } + }, + { + "group": "androidx.test", + "module": "core", + "version": { + "requires": "1.7.0" + } + }, + { + "group": "junit", + "module": "junit", + "version": { + "requires": "4.13.2" + } + }, + { + "group": "androidx.test.ext", + "module": "junit", + "version": { + "requires": "1.3.0" + } + }, + { + "group": "androidx.test.uiautomator", + "module": "uiautomator", + "version": { + "requires": "2.3.0" + } + }, + { + "group": "org.jetbrains.kotlin", + "module": "kotlin-stdlib", + "version": { + "requires": "2.4.20" + } + } + ], + "files": [ + { + "name": "visual-1.2.0-rc01.aar", + "url": "visual-1.2.0-rc01.aar", + "size": 114399, + "sha512": "1095832f7cb44ceda73839a900b5b71c079033719947de3cac72989282c68a9a4b060cbdfa0a61eed70fc79e33cae42ca33012eec49f0f7987ae987abce81a4b", + "sha256": "9abea432efa669f74f091a23f3f8e839c544d7f3017746746d96b0668c1a9433", + "sha1": "4e6dde1e53d974a5322e391d900483a22ca12f4c", + "md5": "3d9f4fd0b788a650e607d57eb7369f1e" + } + ] + }, + { + "name": "releaseVariantReleaseSourcePublication", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "sources", + "org.gradle.usage": "java-runtime" + }, + "files": [ + { + "name": "visual-1.2.0-rc01-sources.jar", + "url": "visual-1.2.0-rc01-sources.jar", + "size": 10080, + "sha512": "64a784bc3a9e8eb3778472bb34d2f6905a9761eec6c7fd53f17a29e075913c16a959b4ebcabddf2e347f727b29caaf4e6efaf316952811dd2cb8a8da7a0e19bf", + "sha256": "8439f68165a0b0443f751ad0ff9d70e79b22fbe07f3c4ac940cef7ec17318b54", + "sha1": "677f9c9a3a410bfa7dc5c72cd722c4175aff167a", + "md5": "ed24080a707e752b3d2f66b5d7b4540f" + } + ] + }, + { + "name": "releaseVariantReleaseJavaDocPublication", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "javadoc", + "org.gradle.usage": "java-runtime" + }, + "files": [ + { + "name": "visual-1.2.0-rc01-javadoc.jar", + "url": "visual-1.2.0-rc01-javadoc.jar", + "size": 466921, + "sha512": "134784dec44b17baccbfbb3706eff82035c85777ad46a949eb5decde73184d72293b2aa9a0ce864991310550b298f9d6d74289aaf35bb52503ec3f931afe6dcc", + "sha256": "7a802179e038582037ee0e3e19ba81a488cb4858420f0f402280bcf1a4ddc2ae", + "sha1": "b108e628c5256386a4ca6993d36fcc4ca1fcddb3", + "md5": "683e61c825c3f79ac3c421472d82870e" + } + ] + } + ] +} diff --git a/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.pom b/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.pom new file mode 100644 index 000000000..7084458dd --- /dev/null +++ b/repo/com/google/android/maps/testing/visual/1.2.0-rc01/visual-1.2.0-rc01.pom @@ -0,0 +1,101 @@ + + + + + + + + 4.0.0 + com.google.android.maps.testing + visual + 1.2.0-rc01 + aar + Android Maps Visual Testing + AI-powered visual verification and screenshot testing library for Google Maps SDK on Android. + https://github.com/googlemaps/android-maps-robolectric + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Google Maps Platform Developer Relations + + + + scm:git:github.com/googlemaps/android-maps-robolectric.git + scm:git:ssh://github.com/googlemaps/android-maps-robolectric.git + https://github.com/googlemaps/android-maps-robolectric/tree/main + + + + com.google.android.gms + play-services-maps + 20.0.0 + compile + + + com.google.android.maps.testing + golden + 1.2.0-rc01 + compile + + + androidx.test + core + 1.7.0 + compile + + + junit + junit + 4.13.2 + compile + + + androidx.test.ext + junit + 1.3.0 + compile + + + androidx.test.uiautomator + uiautomator + 2.3.0 + compile + + + org.jetbrains.kotlin + kotlin-stdlib + 2.4.20 + compile + + + androidx.core + core-ktx + 1.18.0 + runtime + + + org.jetbrains.kotlinx + kotlinx-serialization-json + 1.11.0 + runtime + + + io.ktor + ktor-client-core + 3.6.0 + runtime + + + io.ktor + ktor-client-cio + 3.6.0 + runtime + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index cb268f75b..52de35c61 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,13 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + maven { + url = uri(rootDir.resolve("repo")) + } + if (providers.gradleProperty("useMavenLocal").orNull == "true" || + providers.environmentVariable("USE_MAVEN_LOCAL").orNull == "true") { + mavenLocal() + } google() mavenCentral() } @@ -30,3 +37,20 @@ pluginManagement { } include("demo", "clustering", "heatmaps", "ui", "data", "lint-checks", "library", "visual-testing", "maps-utils") + +listOf( + file("../../android-maps-robolectric/main"), + file("../android-maps-robolectric/main"), + file("../android-maps-robolectric"), +).firstOrNull { it.resolve("settings.gradle.kts").exists() }?.let { mapsRobolectricDir -> + includeBuild(mapsRobolectricDir) { + dependencySubstitution { + substitute(module("com.google.android.maps.testing:golden")) + .using(project(":golden-testing")) + substitute(module("com.google.android.maps.robolectric:shadows")) + .using(project(":robolectric-testing")) + substitute(module("com.google.android.maps.testing:visual")) + .using(project(":visual-testing")) + } + } +} diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 0ebd79a15..2927abc81 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -79,6 +79,12 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) + testImplementation(libs.maps.robolectric.shadows) + testImplementation(libs.maps.testing.golden) + testImplementation(libs.maps.testing.visual) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.activity.compose) + testImplementation(libs.kotlinx.serialization.json) } tasks.register("instrumentTest") { diff --git a/ui/src/test/java/com/google/maps/android/ui/testing/MapsToolkitUiTest.kt b/ui/src/test/java/com/google/maps/android/ui/testing/MapsToolkitUiTest.kt new file mode 100644 index 000000000..517d0af20 --- /dev/null +++ b/ui/src/test/java/com/google/maps/android/ui/testing/MapsToolkitUiTest.kt @@ -0,0 +1,358 @@ +/* + * 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.ui.testing + +import android.app.Activity +import android.os.Bundle +import android.os.SystemClock +import android.view.View +import android.widget.TextView +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.maps.robolectric.annotation.EnableMapsShadows +import com.google.android.maps.robolectric.shadows.ShadowGoogleMap +import com.google.android.maps.robolectric.shadows.ShadowMarker +import com.google.android.maps.robolectric.truth.LatLngSubject +import com.google.android.maps.robolectric.truth.isWithin +import com.google.android.maps.robolectric.truth.kilometers +import com.google.android.maps.robolectric.truth.meters +import com.google.android.maps.testing.golden.GoldenImageDiff +import com.google.android.maps.testing.golden.GoldenTileStrategy +import com.google.android.maps.testing.golden.captureSnapshot +import com.google.android.maps.testing.golden.enableGoldenTesting +import com.google.android.maps.testing.visual.GeminiVisualTestHelper +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.ui.AnimationUtil +import com.google.maps.android.ui.IconGenerator +import com.google.maps.android.ui.RotationLayout +import com.google.maps.android.ui.SquareTextView +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertFailsWith +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLooper + +/** + * End-to-end JVM integration and adversarial verification suite for the `:ui` module + * ([IconGenerator], [RotationLayout], [SquareTextView], [AnimationUtil]) powered by the + * Android Maps Testing Toolkit (`v1.2.0-rc01`). + * + * ## Architectural Scope + * 1. **IconGenerator & Marker Snapshot Verification**: Generates bubble icons across styles + * (`STYLE_DEFAULT`, `STYLE_RED`, `STYLE_BLUE`, `STYLE_GREEN`, `STYLE_PURPLE`, `STYLE_ORANGE`), + * rotations (`0°`, `90°`, `180°`, `270°`), and content rotations, attaches them via + * [BitmapDescriptorFactory.fromBitmap] to markers on [ShadowGoogleMap], and verifies visual + * rendering on [MapView] via [GoldenImageDiff]. + * 2. **Visual Testing Toolkit (`com.google.android.maps.testing.visual`) Integration**: + * Exercises [GeminiVisualTestHelper] model resolution hierarchy (`preferredModel`, + * `gemini.model` system property, and `resetSessionModel()`). + * 3. **Marker Animation Stepping Across the Antimeridian**: Steps [AnimationUtil.animateMarkerTo] + * frame-by-frame via [ShadowLooper] across the 180th meridian (`+179.0°` to `-179.0°`), + * verifying intermediate and final coordinates with [LatLngSubject]. + * 4. **Adversarial Defect Reproductions (Bug #10)**: + * - **Bug #10A**: `AnimationUtil.animateMarkerTo(marker, finalPosition, durationInMs = 0)` + * computes `t = 0 / 0.0f = Float.NaN` on the initial frame and corrupts `marker.position` + * to `LatLng(Double.NaN, Double.NaN)`. + * - **Bug #10B**: `IconGenerator.setRotation(degrees)` uses `(degrees + 360) % 360 / 90`, + * which produces a negative index for negative angles `<= -450°` (e.g. `-450°`), causing + * `getAnchorU()` / `getAnchorV()` to crash with `IllegalStateException`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +@EnableMapsShadows +class MapsToolkitUiTest { + + private lateinit var activity: Activity + private lateinit var mapView: MapView + private lateinit var googleMap: GoogleMap + private lateinit var shadowMap: ShadowGoogleMap + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).create().get() + mapView = MapView(activity).apply { + onCreate(Bundle()) + onStart() + onResume() + measure( + View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, 300, 300) + } + + val mapRef = AtomicReference() + mapView.getMapAsync { mapRef.set(it) } + ShadowLooper.idleMainLooper() + + googleMap = checkNotNull(mapRef.get()) + shadowMap = Shadow.extract(googleMap) as ShadowGoogleMap + } + + // ============================================================================================ + // Section 1: IconGenerator Styles, Rotations, GoldenImageDiff & Visual Helper + // ============================================================================================ + + /** + * Verifies that [IconGenerator] produces distinct, deterministic bitmaps across styles and + * 90-degree rotations, swaps width/height at 90° and 270°, computes rotated anchor coordinates, + * and renders visibly onto a [MapView] snapshot verified by [GoldenImageDiff]. + */ + @Test + fun iconGenerator_rendersDistinctStylesAndRotationsVerifiedByGoldenImageDiff() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val center = LatLng(37.7749, -122.4194) + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(center, 15f)) + googleMap.enableGoldenTesting(strategy = GoldenTileStrategy.SolidColorHash()) + + val iconGen = IconGenerator(activity) + val defaultBmp1 = iconGen.makeIcon("Coffee") + val defaultBmp2 = iconGen.makeIcon("Coffee") + + // Deterministic rendering: two calls with same state produce 0.0% pixel diff + GoldenImageDiff.assertMatches( + expected = defaultBmp1, + actual = defaultBmp2, + maxDiffPercentage = 0.0 + ) + + // Changing style from STYLE_DEFAULT (white) to STYLE_RED alters bubble pixels + iconGen.setStyle(IconGenerator.STYLE_RED) + val redBmp = iconGen.makeIcon("Coffee") + val styleDiff = GoldenImageDiff.compare(expected = defaultBmp1, actual = redBmp) + assertThat(styleDiff.matches).isFalse() + assertThat(styleDiff.diffPixelCount).isGreaterThan(100) + + // Verify all other built-in styles render non-empty bitmaps + for (style in listOf( + IconGenerator.STYLE_WHITE, + IconGenerator.STYLE_BLUE, + IconGenerator.STYLE_GREEN, + IconGenerator.STYLE_PURPLE, + IconGenerator.STYLE_ORANGE + )) { + iconGen.setStyle(style) + val bmp = iconGen.makeIcon("Tag") + assertThat(bmp.width).isGreaterThan(0) + assertThat(bmp.height).isGreaterThan(0) + } + + // Verify 0°, 90°, 180°, 270° rotations swap dimensions and rotate anchors accurately + iconGen.setRotation(0) + assertThat(iconGen.getAnchorU()).isEqualTo(0.5f) + assertThat(iconGen.getAnchorV()).isEqualTo(1.0f) + val unrotated = iconGen.makeIcon("Wide Banner Text") + + iconGen.setRotation(90) + assertThat(iconGen.getAnchorU()).isEqualTo(0.0f) + assertThat(iconGen.getAnchorV()).isEqualTo(0.5f) + val rotated90 = iconGen.makeIcon("Wide Banner Text") + assertThat(rotated90.width).isEqualTo(unrotated.height) + assertThat(rotated90.height).isEqualTo(unrotated.width) + + iconGen.setRotation(180) + assertThat(iconGen.getAnchorU()).isEqualTo(0.5f) + assertThat(iconGen.getAnchorV()).isEqualTo(0.0f) + + iconGen.setRotation(270) + assertThat(iconGen.getAnchorU()).isEqualTo(1.0f) + assertThat(iconGen.getAnchorV()).isEqualTo(0.5f) + + // Attach the generated IconGenerator bitmap to a Marker on GoogleMap and verify via snapshot + iconGen.setRotation(0) + val emptyMapJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val emptyMapSnapshot = emptyMapJob.await() + + val marker = checkNotNull( + googleMap.addMarker( + MarkerOptions() + .position(center) + .icon(BitmapDescriptorFactory.fromBitmap(redBmp)) + .anchor(iconGen.getAnchorU(), iconGen.getAnchorV()) + ) + ) + val shadowMarker = Shadow.extract(marker) as ShadowMarker + assertThat(shadowMarker.iconBitmap).isSameInstanceAs(redBmp) + + val mapWithIconJob = async(dispatcher) { googleMap.captureSnapshot() } + ShadowLooper.idleMainLooper() + val mapWithIconSnapshot = mapWithIconJob.await() + + val mapDiff = GoldenImageDiff.compare( + expected = emptyMapSnapshot, + actual = mapWithIconSnapshot + ) + assertThat(mapDiff.matches).isFalse() + assertThat(mapDiff.diffPixelCount).isGreaterThan(100) + } + + /** + * Verifies [SquareTextView] square dimension enforcement, [RotationLayout] dimension swapping, + * and [GeminiVisualTestHelper] model resolution hierarchy from `maps-testing-visual`. + */ + @Test + fun squareTextViewRotationLayoutAndGeminiVisualTestHelper_behaveDeterministically() = runTest { + // 1. SquareTextView always measures to max(width, height) x max(width, height) + val squareTextView = SquareTextView(activity).apply { + text = "12345" + } + squareTextView.measure( + View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(40, View.MeasureSpec.EXACTLY) + ) + assertThat(squareTextView.measuredWidth).isEqualTo(120) + assertThat(squareTextView.measuredHeight).isEqualTo(120) + + // 2. RotationLayout swaps width and height when rotated by 90 or 270 degrees + val rotationLayout = RotationLayout(activity) + val child = TextView(activity) + rotationLayout.addView(child) + rotationLayout.setViewRotation(90) + rotationLayout.measure( + View.MeasureSpec.makeMeasureSpec(200, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(80, View.MeasureSpec.EXACTLY) + ) + assertThat(rotationLayout.measuredWidth).isEqualTo(80) + assertThat(rotationLayout.measuredHeight).isEqualTo(200) + + // 3. GeminiVisualTestHelper model resolution priority + GeminiVisualTestHelper.resetSessionModel() + val explicitHelper = GeminiVisualTestHelper(preferredModel = "gemini-2.5-flash") + assertThat(explicitHelper.getOrResolveModel("dummy-key")).isEqualTo("gemini-2.5-flash") + + System.setProperty("gemini.model", "gemini-2.5-pro-preview") + try { + val propHelper = GeminiVisualTestHelper() + assertThat(propHelper.getOrResolveModel("dummy-key")).isEqualTo("gemini-2.5-pro-preview") + } finally { + System.clearProperty("gemini.model") + GeminiVisualTestHelper.resetSessionModel() + } + } + + // ============================================================================================ + // Section 2: Marker Animation Across Antimeridian & Bug #10 Reproductions + // ============================================================================================ + + /** + * Verifies that [AnimationUtil.animateMarkerTo] smoothly interpolates a [com.google.android.gms.maps.model.Marker] + * across the 180th meridian (International Date Line) using shortest-path longitude wrapping + * (`+179.0°` to `-179.0°` via `±180.0°` rather than traversing 358° the wrong way around the globe). + */ + @Test + fun animationUtil_animateMarkerTo_interpolatesShortestPathAcrossAntimeridian() { + val start = LatLng(0.0, 179.0) + val end = LatLng(2.0, -179.0) + val marker = checkNotNull(googleMap.addMarker(MarkerOptions().position(start))) + + // Default durationInMs = 2000L (125 * 16ms = 2000ms) + AnimationUtil.animateMarkerTo(marker, end, durationInMs = 2000L) + + // Advance clock to midpoint (1000ms, 62 frames * 16ms = 992ms): marker crosses the antimeridian + // near (lat = 1.0, lng = ±180.0), within 2km of (1.0, 180.0) (vs ~20,000km if it went the wrong way!) + ShadowLooper.idleMainLooper(1000, TimeUnit.MILLISECONDS) + LatLngSubject.assertThat(marker.position) + .isWithin(2.kilometers) + .of(LatLng(1.0, 180.0)) + + // Advance clock to completion (2000ms): marker reaches final destination (2.0, -179.0) + ShadowLooper.idleMainLooper(1100, TimeUnit.MILLISECONDS) + LatLngSubject.assertThat(marker.position) + .isWithin(1.meters) + .of(end) + } + + /** + * **BUG #10A REPRODUCTION (`AnimationUtil.animateMarkerTo` With `durationInMs = 0` Corrupts + * `Marker.position` to `LatLng(NaN, NaN)`)**: + * + * - When a caller invokes `AnimationUtil.animateMarkerTo(marker, finalPosition, durationInMs = 0)` + * (for example, when user preferences or test settings disable animation duration by passing `0L`), + * `AnimationUtil.kt:54-57` executes on the first posted frame (`elapsed = 0L`): + * ```kotlin + * val elapsed = SystemClock.uptimeMillis() - start + * val t = elapsed / durationInMs.toFloat() // 0 / 0.0f == Float.NaN! + * val v = interpolator.getInterpolation(t) // Float.NaN! + * marker.position = latLngInterpolator.interpolate(v, startPosition, finalPosition) + * ``` + * - Because `0 / 0.0f` is `Float.NaN` (and `t < 1` is `false` for `NaN`), the animation loop + * terminates immediately after setting `marker.position = LatLng(Double.NaN, Double.NaN)`! + * + * **BUG #10B REPRODUCTION (`AnimationUtil.animateMarkerTo` Does Not Clamp `t` to `1.0f`, Causing + * `AccelerateDecelerateInterpolator` to Rebound Backward on Non-Multiple-of-16ms Durations or Frame Drops)**: + * + * - `AnimationUtil.kt:55-63` computes `val t = elapsed / durationInMs.toFloat()` without clamping + * `t.coerceAtMost(1.0f)`. + * - Because `AccelerateDecelerateInterpolator.getInterpolation(t)` evaluates + * `cos((t + 1) * PI) / 2 + 0.5`, whenever `elapsed > durationInMs` on the final tick (for example, + * `durationInMs = 1000L` where `63 * 16ms = 1008ms`, or when the main thread drops frames), + * `t > 1.0f` causes the cosine curve to **rebound backward** (`v < 1.0f`) and terminate without + * ever snapping `marker.position` to `finalPosition`! + * + * **BUG #10C REPRODUCTION (`IconGenerator.setRotation` Crashes `getAnchorU()` / `getAnchorV()` + * on Negative Angles `<= -450°`)**: + * + * - `IconGenerator.kt:135-137` computes `rotation = (degrees + 360) % 360 / 90`. + * - Because Kotlin's `%` operator is remainder (preserving the dividend's sign) rather than + * `Math.floorMod`, passing `degrees = -450` yields `(-450 + 360) % 360 / 90 == -1`, which + * subsequently crashes `rotateAnchor` with `IllegalStateException`! + */ + @Test + fun bug10_animationUtilZeroDurationAndClampedOvershootAndIconGeneratorNegativeRotation() { + val start = LatLng(0.0, 179.0) + val destination = LatLng(2.0, -179.0) + val marker = checkNotNull(googleMap.addMarker(MarkerOptions().position(start))) + + // BUG #10A VERIFIED FIXED: durationInMs = 0L snaps directly to destination without NaN! + AnimationUtil.animateMarkerTo(marker, destination, durationInMs = 0L) + ShadowLooper.idleMainLooper() + LatLngSubject.assertThat(marker.position) + .isWithin(1.meters) + .of(destination) + + // BUG #10B VERIFIED FIXED: With durationInMs = 1000L, clamping t to 1.0f lands on exact destination! + marker.position = start + AnimationUtil.animateMarkerTo(marker, destination, durationInMs = 1000L) + ShadowLooper.idleMainLooper(1100, TimeUnit.MILLISECONDS) + LatLngSubject.assertThat(marker.position) + .isWithin(1.meters) + .of(destination) + + // BUG #10C VERIFIED FIXED: IconGenerator.setRotation(-450) normalizes via floorMod to 270° (rotation = 3)! + val iconGenerator = IconGenerator(activity) + iconGenerator.setRotation(-450) + assertThat(iconGenerator.getAnchorU()).isWithin(0.001f).of(1.0f) + assertThat(iconGenerator.getAnchorV()).isWithin(0.001f).of(0.5f) + } +} diff --git a/ui/src/test/resources/robolectric.properties b/ui/src/test/resources/robolectric.properties index 89a6c8b4c..aafb05cf4 100644 --- a/ui/src/test/resources/robolectric.properties +++ b/ui/src/test/resources/robolectric.properties @@ -1 +1,17 @@ -sdk=28 \ No newline at end of file +sdk=28 +shadows=com.google.android.maps.robolectric.shadows.ShadowMapView,\ +com.google.android.maps.robolectric.shadows.ShadowGoogleMap,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdate,\ +com.google.android.maps.robolectric.shadows.ShadowCameraUpdateFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMarker,\ +com.google.android.maps.robolectric.shadows.ShadowPolygon,\ +com.google.android.maps.robolectric.shadows.ShadowPolyline,\ +com.google.android.maps.robolectric.shadows.ShadowProjection,\ +com.google.android.maps.robolectric.shadows.ShadowCircle,\ +com.google.android.maps.robolectric.shadows.ShadowUiSettings,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptor,\ +com.google.android.maps.robolectric.shadows.ShadowBitmapDescriptorFactory,\ +com.google.android.maps.robolectric.shadows.ShadowMapsInitializer,\ +com.google.android.maps.robolectric.shadows.ShadowSupportMapFragment,\ +com.google.android.maps.robolectric.shadows.ShadowGroundOverlay,\ +com.google.android.maps.robolectric.shadows.ShadowTileOverlay \ No newline at end of file