diff --git a/map-sample/app/build.gradle.kts b/map-sample/app/build.gradle.kts index 0add43e..b2fd204 100644 --- a/map-sample/app/build.gradle.kts +++ b/map-sample/app/build.gradle.kts @@ -14,13 +14,29 @@ * limitations under the License. */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.powerassert.gradle.PowerAssertCompilationFilter plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.power.assert) alias(libs.plugins.maps.secrets) } +@OptIn(ExperimentalKotlinGradlePluginApi::class) +powerAssert { + functions = listOf( + "kotlin.assert", + "kotlin.require", + "kotlin.check", + "com.google.maps.android.robolectric.powerassert.assertGeo", + "com.google.maps.android.robolectric.powerassert.assertMap", + "com.google.maps.android.robolectric.powerassert.assertMarker", + ) + compilationFilter = PowerAssertCompilationFilter.ALL +} + android { namespace = "com.example.mapdemo" compileSdk = 37 @@ -41,6 +57,21 @@ android { // for all Navigation SDK supported languages. androidResources.localeFilters += "en" multiDexEnabled = true + manifestPlaceholders["MAPS_API_KEY"] = "DEFAULT_API_KEY" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { + it.systemProperty( + "maps.golden.record", + System.getProperty("maps.golden.record") ?: "false", + ) + } + } } buildTypes { @@ -112,6 +143,29 @@ dependencies { annotationProcessor(libs.glide.compiler) coreLibraryDesugaring(libs.desugar.jdk.libs) + + // Unit tests: JUnit, Kotlin Power-Assert, Google Truth, Robolectric, and android-maps-testing + testImplementation(libs.junit) + testImplementation(libs.truth) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockito.core) + testImplementation(libs.maps.testing.robolectric) + testImplementation(libs.maps.testing.golden) + + // Instrumented & visual tests + androidTestImplementation(libs.junit) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.maps.testing.golden) + androidTestImplementation(libs.maps.testing.visual) } secrets { diff --git a/map-sample/app/src/androidTest/java/com/example/mapdemo/MapSampleSmokeTest.java b/map-sample/app/src/androidTest/java/com/example/mapdemo/MapSampleSmokeTest.java new file mode 100644 index 0000000..2c6d641 --- /dev/null +++ b/map-sample/app/src/androidTest/java/com/example/mapdemo/MapSampleSmokeTest.java @@ -0,0 +1,88 @@ +/* + * 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.example.mapdemo; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.widget.ListView; +import android.widget.ToggleButton; +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.HashSet; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Connected-device instrumentation smoke tests for {@code map-sample} using Google Truth. + * + *

Sits near the top of the testing pyramid to verify that: + * + *

    + *
  1. Every demo activity listed in {@link DemoDetailsList#DEMOS} is registered in {@code + * AndroidManifest.xml} on the target device. + *
  2. {@link MainActivity} launches cleanly, populates the full catalog adapter, and toggles the + * Maps vs. Navigation flavor switch without error. + *
+ */ +@RunWith(AndroidJUnit4.class) +public class MapSampleSmokeTest { + + @Test + public void manifest_registersAll46DemoActivitiesFromDemoDetailsList() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + PackageInfo packageInfo = + context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.GET_ACTIVITIES); + + Set registeredActivities = new HashSet<>(); + if (packageInfo.activities != null) { + for (ActivityInfo info : packageInfo.activities) { + registeredActivities.add(info.name); + } + } + + assertThat(DemoDetailsList.DEMOS).hasLength(47); + for (DemoDetails demo : DemoDetailsList.DEMOS) { + assertThat(registeredActivities).contains(demo.activityClass.getName()); + } + } + + @Test + public void mainActivity_launchesAndTogglesNavigationFlavorSwitch() { + try (ActivityScenario scenario = ActivityScenario.launch(MainActivity.class)) { + scenario.onActivity( + activity -> { + ListView demoListView = activity.findViewById(R.id.demoListView); + assertThat(demoListView).isNotNull(); + assertThat(demoListView.getAdapter().getCount()).isEqualTo(DemoDetailsList.DEMOS.length); + ToggleButton flavorToggle = activity.findViewById(R.id.view_source_toggle_btn); + assertThat(flavorToggle).isNotNull(); + assertThat(flavorToggle.isChecked()).isFalse(); + + flavorToggle.performClick(); + assertThat(flavorToggle.isChecked()).isTrue(); + }); + } + } +} diff --git a/map-sample/app/src/androidTest/java/com/example/mapdemo/MapSampleVisualTest.kt b/map-sample/app/src/androidTest/java/com/example/mapdemo/MapSampleVisualTest.kt new file mode 100644 index 0000000..d7b7920 --- /dev/null +++ b/map-sample/app/src/androidTest/java/com/example/mapdemo/MapSampleVisualTest.kt @@ -0,0 +1,50 @@ +/* + * 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.example.mapdemo + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.maps.android.testing.visual.BaseVisualTest +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Top-of-pyramid multimodal AI visual and hybrid golden test for `map-sample`. + * + * Extends [BaseVisualTest] from `com.google.maps.android:maps-testing-visual` to verify the visual + * presentation of the `MainActivity` demo catalog and flavor toggle switch via + * `verifyScreenshotWithGoldenFallback`. + */ +@RunWith(AndroidJUnit4::class) +class MapSampleVisualTest : BaseVisualTest() { + + @Test + fun mainActivity_rendersMapDemoCatalogWithFlavorToggle() = runBlocking { + ActivityScenario.launch(MainActivity::class.java).use { + val screenshotBitmap = captureScreenshot("map_sample_main_catalog.png") + verifyScreenshotWithGoldenFallback( + testName = "map_sample_main_catalog", + screenshotBitmap = screenshotBitmap, + prompt = + "Verify that the Map Demo catalog list is displayed with a flavor switch at the top " + + "and entries such as Basic Map and Camera, and reply PASSED if all criteria are met.", + tolerancePercent = 2.0, + ) + } + } +} diff --git a/map-sample/app/src/test/java/com/example/mapdemo/ConversionUtilsAndMoveDirectionTest.kt b/map-sample/app/src/test/java/com/example/mapdemo/ConversionUtilsAndMoveDirectionTest.kt new file mode 100644 index 0000000..69e2b95 --- /dev/null +++ b/map-sample/app/src/test/java/com/example/mapdemo/ConversionUtilsAndMoveDirectionTest.kt @@ -0,0 +1,190 @@ +/* + * 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.example.mapdemo + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.os.Parcel +import androidx.test.core.app.ApplicationProvider +import com.example.mapdemo.testing.NavDelegateReflection +import com.example.mapdemo.testing.NavMapsRobolectricTest +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap.OnCameraMoveStartedListener +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.robolectric.powerassert.assertGeo +import com.google.maps.android.robolectric.powerassert.assertMap +import com.google.maps.android.robolectric.powerassert.distanceTo +import com.google.maps.android.robolectric.powerassert.meters +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Fast base-of-pyramid unit tests for `map-sample` utilities, coordinate/tile math, parcelables, + * and demo catalog integrity using **Kotlin Power-Assert** (`assert`, `assertGeo`, `assertMap`). + * + * Verifies: + * 1. [ConversionUtils.toAnimationReasonString] across all [OnCameraMoveStartedListener] constants + * and unknown reason codes. + * 2. [MoveDirection] camera scroll directions (`UP`, `DOWN`, `LEFT`, `RIGHT`) and directional camera + * target displacement when applied to a `GoogleMap`. + * 3. [SaveStateDemoActivity.MarkerInfo] `Parcelable` round-trip serialization. + * 4. [MoonTileProvider.getTileUrl] reversed-Y lunar tile URL coordinate calculation. + * 5. [TileCoordsTileProvider.getTile] PNG raster tile generation and density scaling. + * 6. [DemoDetailsList.DEMOS] registry completeness and `AndroidManifest.xml` registration. + */ +@RunWith(RobolectricTestRunner::class) +class ConversionUtilsAndMoveDirectionTest : NavMapsRobolectricTest() { + + @Test + fun toAnimationReasonString_mapsAllKnownReasonsAndFallback() { + assert( + ConversionUtils.toAnimationReasonString(OnCameraMoveStartedListener.REASON_GESTURE) == + "REASON_GESTURE" + ) + assert( + ConversionUtils.toAnimationReasonString(OnCameraMoveStartedListener.REASON_API_ANIMATION) == + "REASON_API_ANIMATION" + ) + assert( + ConversionUtils.toAnimationReasonString( + OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION + ) == "REASON_DEVELOPER_ANIMATION" + ) + assert(ConversionUtils.toAnimationReasonString(-999) == "Unknown") + } + + @Test + fun moveDirection_calculatesStepDistancesAndTranslatesPointListsAndNestedLists() { + val stepSize = 2.5 + assertGeo(MoveDirection.UP.getLatDistance(stepSize) == 2.5 && MoveDirection.UP.getLngDistance(stepSize) == 0.0) + assertGeo(MoveDirection.DOWN.getLatDistance(stepSize) == -2.5 && MoveDirection.DOWN.getLngDistance(stepSize) == 0.0) + assertGeo(MoveDirection.LEFT.getLatDistance(stepSize) == 0.0 && MoveDirection.LEFT.getLngDistance(stepSize) == -2.5) + assertGeo(MoveDirection.RIGHT.getLatDistance(stepSize) == 0.0 && MoveDirection.RIGHT.getLngDistance(stepSize) == 2.5) + + val origin = LatLng(-33.0, 151.0) + val second = LatLng(-34.0, 152.0) + val movedList = + MoveDirection.movePointsInList( + listOf(origin, second), + MoveDirection.UP.getLatDistance(1.0), + MoveDirection.RIGHT.getLngDistance(2.0), + ) + + assertGeo(movedList.size == 2) + assertGeo(movedList[0] == LatLng(-32.0, 153.0) && movedList[1] == LatLng(-33.0, 154.0)) + assertGeo(origin.distanceTo(movedList[0]) > 100.meters) + + val nestedMoved = + MoveDirection.movePointsInNestedList( + listOf(listOf(origin), listOf(second)), + MoveDirection.DOWN.getLatDistance(0.5), + MoveDirection.LEFT.getLngDistance(0.5), + ) + assertGeo(nestedMoved.size == 2) + assertGeo(nestedMoved[0][0] == LatLng(-33.5, 150.5) && nestedMoved[1][0] == LatLng(-34.5, 151.5)) + + val map = NavDelegateReflection.createGoogleMapInstance() + map.moveCamera(CameraUpdateFactory.newLatLngZoom(origin, 10f)) + map.assertMap(map.cameraPosition.zoom == 10f) + } + + @Test + fun markerInfo_parcelableRoundTripPreservesHue() { + val original = SaveStateDemoActivity.MarkerInfo(BitmapDescriptorFactory.HUE_AZURE) + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = SaveStateDemoActivity.MarkerInfo.CREATOR.createFromParcel(parcel) + assert(restored.hue == BitmapDescriptorFactory.HUE_AZURE) + assert(restored.describeContents() == 0) + + val array = SaveStateDemoActivity.MarkerInfo.CREATOR.newArray(3) + assert(array.size == 3) + } finally { + parcel.recycle() + } + } + + @Test + fun moonTileProvider_reversesYCoordinateAcrossZoomLevels() { + val provider = MoonTileProvider() + + // At zoom = 0: reversedY = (1 << 0) - 0 - 1 = 0 + val urlZoom0 = provider.getTileUrl(0, 0, 0) + assert( + urlZoom0.toString() == "http://mw1.google.com/mw-planetary/lunar/lunarmaps_v1/clem_bw/0/0/0.jpg" + ) + + // At zoom = 2, x = 1, y = 0: reversedY = (1 << 2) - 0 - 1 = 3 + val urlZoom2 = provider.getTileUrl(1, 0, 2) + assert( + urlZoom2.toString() == "http://mw1.google.com/mw-planetary/lunar/lunarmaps_v1/clem_bw/2/1/3.jpg" + ) + + // At zoom = 4, x = 5, y = 10: reversedY = 16 - 10 - 1 = 5 + val urlZoom4 = provider.getTileUrl(5, 10, 4) + assert( + urlZoom4.toString() == "http://mw1.google.com/mw-planetary/lunar/lunarmaps_v1/clem_bw/4/5/5.jpg" + ) + } + + @Test + fun tileCoordsTileProvider_generatesValidPngTileWithScaledDimensions() { + val density = 2.0f + val provider = TileCoordsTileProvider(density) + val tile = provider.getTile(3, 7, 5) + + // Expected size: (int) (256 * 2.0f * 0.6f) = 307 + val expectedDimension = (256 * density * 0.6f).toInt() + assert(tile.width == expectedDimension) + assert(tile.height == expectedDimension) + assert(tile.data != null && tile.data!!.isNotEmpty()) + + val decodedBitmap = BitmapFactory.decodeByteArray(tile.data, 0, tile.data!!.size) + assert(decodedBitmap != null) + assert(decodedBitmap.width == expectedDimension) + assert(decodedBitmap.height == expectedDimension) + } + + @Test + fun demoDetailsList_allDemosHaveValidResourcesAndAreRegisteredInManifest() { + val context = ApplicationProvider.getApplicationContext() + val packageInfo = + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES) + val manifestActivities = + packageInfo.activities?.map { activityInfo -> activityInfo.name }?.toSet().orEmpty() + + val demos = DemoDetailsList.DEMOS + assert(demos.size == 47) + + val uniqueClasses = demos.map { demo -> demo.activityClass }.toSet() + assert(uniqueClasses.size == demos.size) + + for (demo in demos) { + val title = context.getString(demo.titleId) + val description = context.getString(demo.descriptionId) + assert(title.isNotBlank()) + assert(description.isNotBlank()) + assert(manifestActivities.contains(demo.activityClass.name)) + } + } +} diff --git a/map-sample/app/src/test/java/com/example/mapdemo/MapActivitiesPowerAssertTest.kt b/map-sample/app/src/test/java/com/example/mapdemo/MapActivitiesPowerAssertTest.kt new file mode 100644 index 0000000..1810333 --- /dev/null +++ b/map-sample/app/src/test/java/com/example/mapdemo/MapActivitiesPowerAssertTest.kt @@ -0,0 +1,880 @@ +/* + * 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.example.mapdemo + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.widget.CheckBox +import android.widget.EditText +import android.widget.ImageView +import android.widget.ListView +import android.widget.RadioGroup +import android.widget.SeekBar +import android.widget.Spinner +import android.widget.TextView +import android.widget.ToggleButton +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ApplicationProvider +import com.example.mapdemo.testing.NavDelegateReflection +import com.example.mapdemo.testing.NavMapsRobolectricTest +import com.example.mapdemo.testing.ShadowSupportNavigationFragment +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.GoogleMap.OnCameraMoveStartedListener +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.PointOfInterest +import com.google.android.libraries.navigation.SupportNavigationFragment +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.robolectric.ktx.cameraBearing +import com.google.maps.android.robolectric.ktx.cameraTarget +import com.google.maps.android.robolectric.ktx.cameraTilt +import com.google.maps.android.robolectric.ktx.cameraZoom +import com.google.maps.android.robolectric.ktx.circles +import com.google.maps.android.robolectric.ktx.findMarker +import com.google.maps.android.robolectric.ktx.groundOverlays +import com.google.maps.android.robolectric.ktx.markers +import com.google.maps.android.robolectric.ktx.polygons +import com.google.maps.android.robolectric.ktx.polylines +import com.google.maps.android.robolectric.ktx.shadow +import com.google.maps.android.robolectric.ktx.tileOverlays +import com.google.maps.android.robolectric.powerassert.assertGeo +import com.google.maps.android.robolectric.powerassert.assertMap +import com.google.maps.android.robolectric.powerassert.assertMarker +import com.google.maps.android.robolectric.powerassert.connects +import com.google.maps.android.robolectric.powerassert.contains +import com.google.maps.android.robolectric.powerassert.distanceTo +import com.google.maps.android.robolectric.powerassert.hasMarker +import com.google.maps.android.robolectric.powerassert.hasVertexNear +import com.google.maps.android.robolectric.powerassert.isCenteredOn +import com.google.maps.android.robolectric.powerassert.isNear +import com.google.maps.android.robolectric.powerassert.kilometers +import com.google.maps.android.robolectric.powerassert.meters +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLooper +import org.robolectric.shadows.ShadowToast + +/** + * Middle-tier headless Robolectric JVM tests for `map-sample` demo activities using **Kotlin + * Power-Assert** (`assert`, `assertGeo`, `assertMap`, `assertMarker`) and `android-maps-testing` + * extensions. + * + * Every demo activity in `map-sample` supports two rendering flavors controlled by + * [ActivityIntents.EXTRA_SHOULD_USE_NAVIGATION_FLAVOR_FOR_DEMO]: + * - **Maps flavor** (`false`): inflates a [SupportMapFragment]. + * - **Navigation flavor** (`true`): inflates a [SupportNavigationFragment]. + * + * This test suite verifies both flavors as well as interactive user journeys across camera + * movement, camera clamping, circles, polygons, polylines, markers, tags, tile overlays, UI + * settings, visible regions, Z-index ordering, map styling, layers, events, and launcher routing. + */ +@Suppress("DEPRECATION") +@RunWith(RobolectricTestRunner::class) +class MapActivitiesPowerAssertTest : NavMapsRobolectricTest() { + + private inline fun launchDemo( + useNavFlavor: Boolean = false + ): Pair { + val intent = + Intent(ApplicationProvider.getApplicationContext(), T::class.java).apply { + putExtra(ActivityIntents.EXTRA_SHOULD_USE_NAVIGATION_FLAVOR_FOR_DEMO, useNavFlavor) + } + val activity = Robolectric.buildActivity(T::class.java, intent).setup().get() + val fragment = + activity.supportFragmentManager.findFragmentById(R.id.map) + ?: activity.supportFragmentManager.findFragmentByTag("map") + val map = NavDelegateReflection.extractActivityMap(fragment) + return activity to map + } + + @Test + fun basicMapDemoActivity_addsMarkerAtOriginInBothMapsAndNavigationFlavors() { + for (useNavFlavor in listOf(false, true)) { + val (activity, map) = launchDemo(useNavFlavor = useNavFlavor) + val fragment = activity.supportFragmentManager.findFragmentById(R.id.map) + if (useNavFlavor) { + assertGeo(fragment is SupportNavigationFragment) + } else { + assertGeo(fragment is SupportMapFragment) + } + + with(map) { + assertMap(markers.size == 1 && hasMarker(title = "Marker", near = LatLng(0.0, 0.0))) + } + val marker = checkNotNull(map.findMarker(title = "Marker")) + marker.assertMarker( + marker.title == "Marker" && marker.position.isNear(LatLng(0.0, 0.0), 1.meters) + ) + } + } + + @Test + fun backgroundColorCustomizationDemoActivity_togglesMapTypeBetweenNoneAndNormal() { + for (useNavFlavor in listOf(false, true)) { + val (activity, map) = + launchDemo(useNavFlavor = useNavFlavor) + val checkbox = activity.findViewById(R.id.map_type_toggle) + + with(map) { + assertMap( + mapType == GoogleMap.MAP_TYPE_NONE && + !checkbox.isChecked && + hasMarker(title = "Marker", near = LatLng(0.0, 0.0)) + ) + } + + checkbox.isChecked = true + assertGeo(map.mapType == GoogleMap.MAP_TYPE_NORMAL) + + checkbox.isChecked = false + assertGeo(map.mapType == GoogleMap.MAP_TYPE_NONE) + } + } + + @Test + fun cameraDemoActivity_animatesBetweenBondiAndSydneyAndScrollsAndZooms() { + val bondi = LatLng(-33.891614, 151.276417) + val sydney = LatLng(-33.87365, 151.20689) + + val (activity, map) = launchDemo(useNavFlavor = true) + + // Initial onMapReady moves camera to Sydney at zoom 10. + with(map) { + assertMap(isCenteredOn(sydney, expectedZoom = 10f, tolerance = 5.meters)) + } + + // Go to Bondi (zoom 15.5, bearing 300, tilt 50) + activity.onGoToBondi(null) + with(map) { + assertMap( + isCenteredOn(bondi, expectedZoom = 15.5f, tolerance = 5.meters) && + cameraBearing == 300f && + cameraTilt == 50f + ) + } + + // Go to Sydney (zoom 15.5, bearing 0, tilt 25) + activity.onGoToSydney(null) + with(map) { + assertMap( + isCenteredOn(sydney, expectedZoom = 15.5f, tolerance = 5.meters) && + cameraTilt == 25f + ) + } + + // Zoom in & zoom out + val zoomBefore = map.cameraZoom + activity.onZoomIn(null) + assertGeo(map.cameraZoom == zoomBefore + 1.0f) + activity.onZoomOut(null) + assertGeo(map.cameraZoom == zoomBefore) + + // Scroll directions displace camera target + val targetBeforeScroll = map.cameraTarget + activity.onScrollUp(null) + assertGeo(map.cameraTarget.latitude > targetBeforeScroll.latitude) + activity.onScrollDown(null) + activity.onScrollLeft(null) + assertGeo(map.cameraTarget.longitude < targetBeforeScroll.longitude) + activity.onScrollRight(null) + + // Go back to Bondi + activity.onGoToBondi(null) + with(map) { + assertMap(isCenteredOn(bondi, expectedZoom = 15.5f, tolerance = 5.meters)) + } + + // Camera move lifecycle callbacks trace a polyline across the camera path + activity.onCameraMoveStarted(OnCameraMoveStartedListener.REASON_GESTURE) + activity.onCameraMove() + activity.onCameraIdle() + assertGeo(map.polylines.isNotEmpty()) + } + + @Test + fun cameraClampingDemoActivity_clampsBoundsAndMinMaxZoomPreferences() { + val adelaide = LatLng(-34.92873, 138.59995) + val pacificTarget = LatLng(0.0, -180.0) + + val (activity, map) = launchDemo(useNavFlavor = false) + + activity.onClampToAdelaide(null) + with(map) { + assertMap( + isCenteredOn(adelaide, expectedZoom = 20.0f, tolerance = 5.meters) && + shadow.latLngBoundsForCameraTarget != null && + adelaide in checkNotNull(shadow.latLngBoundsForCameraTarget) + ) + } + + activity.onClampToPacific(null) + with(map) { + assertMap( + cameraZoom == 4.0f && + shadow.latLngBoundsForCameraTarget != null && + pacificTarget.latitude == cameraTarget.latitude + ) + } + + activity.onLatLngClampReset(null) + assertGeo(map.shadow.latLngBoundsForCameraTarget == null) + + // Min/max zoom preference clamping (default min=2.0, max=22.0, delta=2.0) + activity.onSetMinZoomClamp(null) + assertGeo(map.minZoomLevel == 4.0f) + + activity.onSetMaxZoomClamp(null) + assertGeo(map.maxZoomLevel == 20.0f) + + activity.onMinMaxZoomClampReset(null) + assertGeo(map.minZoomLevel == 2.0f && map.maxZoomLevel == 21.0f) + + // Camera move started updates camera_text with animation reason and target + activity.onCameraMoveStarted(OnCameraMoveStartedListener.REASON_GESTURE) + val cameraText = activity.findViewById(R.id.camera_text).text.toString() + assertGeo(cameraText.contains("REASON_GESTURE")) + } + + @Test + fun circleDemoActivity_createsDraggableCircleUpdatesStyleAndHandlesLongClick() { + val sydney = LatLng(-33.87365, 151.20689) + val (activity, map) = launchDemo(useNavFlavor = true) + + with(map) { + assertMap( + circles.size == 1 && + markers.size == 2 && + polylines.size == 1 && + isCenteredOn(sydney, expectedZoom = 4.0f, tolerance = 5.meters) + ) + } + + val initialCircle = map.circles.first() + assertGeo( + initialCircle.center.isNear(sydney, 5.meters) && + initialCircle.radius == 1_000_000.0 && + sydney in initialCircle + ) + + // Changing widthSeekBar updates circle strokeWidth + val widthSeekBar = activity.findViewById(R.id.widthSeekBar) + widthSeekBar.progress = 35 + activity.onProgressChanged(widthSeekBar, 35, true) + assertGeo(initialCircle.strokeWidth == 35f) + + // Toggling clickability checkbox updates circle clickability + val clickCheckbox = activity.findViewById(R.id.toggleClickability) + clickCheckbox.isChecked = true + activity.toggleClickability(clickCheckbox) + assertGeo(initialCircle.isClickable) + + // Clicking the circle flips the RGB bits of its strokeColor + val colorBeforeClick = initialCircle.strokeColor + map.shadow.clickCircle(initialCircle) + assertGeo(initialCircle.strokeColor == (colorBeforeClick xor 0x00ffffff)) + + // Dragging the center marker moves the circle's center + val centerMarker = map.markers.first { it.position.isNear(sydney, 5.meters) } + val newCenter = LatLng(-30.0, 150.0) + centerMarker.position = newCenter + activity.onMarkerDragEnd(centerMarker) + assertGeo(initialCircle.center.isNear(newCenter, 5.meters)) + + // Long-clicking on the map adds a second DraggableCircle + activity.onMapLongClick(LatLng(-25.0, 135.0)) + assertGeo(map.circles.size == 2 && map.markers.size == 4 && map.polylines.size == 2) + } + + @Test + fun polygonDemoActivity_addsFourPolygonsFlipsStrokeColorOnClickAndSwitchesSelection() { + val sydney = LatLng(-33.87365, 151.20689) + val (activity, map) = launchDemo(useNavFlavor = false) + + with(map) { + assertMap( + polygons.size == 4 && + isCenteredOn(sydney, tolerance = 5.meters) + ) + } + + val northwestPolygon = map.polygons[0] + assertGeo( + northwestPolygon.holes.size == 2 && + northwestPolygon.fillColor == Color.CYAN && + northwestPolygon.strokeColor == Color.BLUE && + northwestPolygon.isClickable && + northwestPolygon.hasVertexNear(LatLng(-25.0, 125.0), 10.meters) + ) + + // Clicking polygon flips RGB bits of its strokeColor + val originalStroke = northwestPolygon.strokeColor + activity.onPolygonClick(northwestPolygon) + assertGeo(northwestPolygon.strokeColor == (originalStroke xor 0x00ffffff)) + + // Radio group switches selectedPolygon without throwing + val radioGroup = activity.findViewById(R.id.polygon_radio) + activity.onCheckedChanged(radioGroup, R.id.polygon_radio_northwest) + activity.onCheckedChanged(radioGroup, R.id.polygon_radio_southeast) + activity.onCheckedChanged(radioGroup, R.id.polygon_radio_world_wrapped) + activity.onCheckedChanged(radioGroup, R.id.polygon_radio_central) + } + + @Test + fun polylineDemoActivity_addsFourPolylinesVerifiesEndpointsAndFlipsColorOnClick() { + val perth = LatLng(-31.95285, 115.85734) + val melbourne = LatLng(-37.81319, 144.96298) + val london = LatLng(51.471547, -0.460052) + + val (activity, map) = launchDemo(useNavFlavor = true) + + with(map) { + assertMap(polylines.size == 4) + } + + val australiaPolyline = map.polylines[0] + val worldPolyline = map.polylines[1] + + assertGeo( + australiaPolyline.connects(perth, melbourne, tolerance = 5.meters) && + australiaPolyline.color == Color.MAGENTA && + australiaPolyline.zIndex == 1.0f && + worldPolyline.connects(london, london, tolerance = 5.meters) && + worldPolyline.isGeodesic && + worldPolyline.isClickable + ) + + val initialColor = worldPolyline.color + activity.onPolylineClick(worldPolyline) + assertGeo(worldPolyline.color == (initialColor xor 0x00ffffff)) + + val radioGroup = activity.findViewById(R.id.polyline_radio) + activity.onCheckedChanged(radioGroup, R.id.polyline_radio_sydney) + activity.onCheckedChanged(radioGroup, R.id.polyline_radio_melbourne) + activity.onCheckedChanged(radioGroup, R.id.polyline_radio_world) + } + + @Test + fun markerDemoActivity_addsAustralianCityMarkersHandlesClicksClearAndReset() { + val brisbane = LatLng(-27.47093, 153.0235) + val sydney = LatLng(-33.87365, 151.20689) + val perth = LatLng(-31.952854, 115.857342) + + val (activity, map) = launchDemo(useNavFlavor = false) + + // 5 named city markers + Perth + 11 rainbow Victoria markers = 17 markers total + with(map) { + assertMap( + markers.size == 17 && + hasMarker(title = "Brisbane", near = brisbane) && + hasMarker(title = "Sydney", near = sydney) && + hasMarker(near = perth) + ) + } + + // Clicking Brisbane shifts its latitude northward by +2.0 degrees + val brisbaneMarker = checkNotNull(map.findMarker(title = "Brisbane")) + activity.onMarkerClick(brisbaneMarker) + brisbaneMarker.assertMarker( + brisbaneMarker.position.isNear(LatLng(brisbane.latitude + 2.0, brisbane.longitude), 5.meters) + ) + + // Showing and hiding Sydney's info window toggles its infoWindow state + val sydneyMarker = checkNotNull(map.findMarker(title = "Sydney")) + activity.onShowInfoWindow(null) + sydneyMarker.assertMarker(sydneyMarker.isInfoWindowShown) + activity.onHideInfoWindow(null) + sydneyMarker.assertMarker(!sydneyMarker.isInfoWindowShown) + + // Dragging a marker updates top_text with drag coordinates + activity.onMarkerDrag(sydneyMarker) + val topText = activity.findViewById(R.id.top_text).text.toString() + assertGeo(topText.isNotEmpty()) + + // Toggling flat checkbox and rotation seekbar updates the 12 rainbow markers + val flatBox = activity.findViewById(R.id.flat) + flatBox.isChecked = true + activity.onToggleFlat(flatBox) + assertGeo(map.markers.count { it.isFlat } == 12) + + val rotationBar = activity.findViewById(R.id.rotationSeekBar) + rotationBar.progress = 90 + activity.onProgressChanged(rotationBar, 90, true) + assertGeo(map.markers.count { it.rotation == 90f } == 12) + + // Clear and reset map + activity.onClearMap(null) + assertGeo(map.markers.isEmpty()) + + activity.onResetMap(null) + assertGeo(map.markers.size == 17) + } + + @Test + fun markerCloseInfoWindowOnRetapDemoActivity_closesInfoWindowWhenSelectedMarkerRetapped() { + val (_, map) = launchDemo(useNavFlavor = true) + + with(map) { + assertMap(markers.size == 5 && !uiSettings.isZoomControlsEnabled) + } + + val sydneyMarker = checkNotNull(map.findMarker(title = "Sydney")) + val brisbaneMarker = checkNotNull(map.findMarker(title = "Brisbane")) + + // First tap on Sydney returns false (default behavior opens info window) + val firstTapConsumed = map.shadow.clickMarker(sydneyMarker) + assertGeo(!firstTapConsumed && sydneyMarker.isInfoWindowShown) + + // Second tap (retap) on Sydney returns true (consumes event so info window stays closed) + val retapConsumed = map.shadow.clickMarker(sydneyMarker) + assertGeo(retapConsumed) + + // Tapping Brisbane selects Brisbane, then clicking the map clears selection + assertGeo(!map.shadow.clickMarker(brisbaneMarker)) + map.shadow.clickMap(LatLng(-30.0, 140.0)) + // After map click cleared selectedMarker, tapping Brisbane again is treated as a fresh tap + assertGeo(!map.shadow.clickMarker(brisbaneMarker)) + } + + @Test + fun markerZIndexDemoActivity_assignsDistinctZIndicesAndTogglesGroundOverlayTransparency() { + val (activity, map) = launchDemo(useNavFlavor = false) + + with(map) { + assertMap(markers.size == 4 && groundOverlays.size == 1) + } + + val z1 = checkNotNull(map.findMarker(title = "Marker z1")) + val z2 = checkNotNull(map.findMarker(title = "Marker z2")) + val z3 = checkNotNull(map.findMarker(title = "Marker z3")) + val z4 = checkNotNull(map.findMarker(title = "Marker z4")) + + assertGeo(z1.zIndex == 1.0f && z2.zIndex == 2.0f && z3.zIndex == 3.0f && z4.zIndex == 4.0f) + + val overlay = map.groundOverlays.first() + assertGeo(overlay.zIndex == 5.0f && overlay.isClickable && overlay.transparency == 0.0f) + + activity.onGroundOverlayClick(overlay) + assertGeo(overlay.transparency == 0.5f) + + activity.onGroundOverlayClick(overlay) + assertGeo(overlay.transparency == 0.0f) + } + + @Test + fun tagsDemoActivity_disablesGesturesAttachesCustomTagsAndCountsClicksAcrossAllOverlays() { + val (activity, map) = launchDemo(useNavFlavor = true) + val tagText = activity.findViewById(R.id.tag_text) + + with(map) { + assertMap(!uiSettings.isZoomControlsEnabled && !uiSettings.isScrollGesturesEnabled) + assertMap(!uiSettings.isZoomGesturesEnabled && !uiSettings.isTiltGesturesEnabled) + assertMap(!uiSettings.isRotateGesturesEnabled) + assertMap(circles.size == 1 && groundOverlays.size == 1 && markers.size == 1) + assertMap(polygons.size == 1 && polylines.size == 1) + } + + activity.onCircleClick(map.circles.first()) + assertGeo(tagText.text.toString() == "The Adelaide circle has been clicked 1 times.") + + activity.onCircleClick(map.circles.first()) + assertGeo(tagText.text.toString() == "The Adelaide circle has been clicked 2 times.") + + activity.onGroundOverlayClick(map.groundOverlays.first()) + assertGeo(tagText.text.toString() == "The Sydney ground overlay has been clicked 1 times.") + + assertGeo(activity.onMarkerClick(map.markers.first())) + assertGeo(tagText.text.toString() == "The Hobart marker has been clicked 1 times.") + + activity.onPolygonClick(map.polygons.first()) + assertGeo(tagText.text.toString() == "The Darwin polygon has been clicked 1 times.") + + activity.onPolylineClick(map.polylines.first()) + assertGeo(tagText.text.toString() == "The Perth to Brisbane polyline has been clicked 1 times.") + } + + @Test + fun tileOverlayDemoActivity_configuresMoonTilesFadeInAndTransparency() { + val (activity, map) = launchDemo(useNavFlavor = false) + + with(map) { + assertMap(mapType == GoogleMap.MAP_TYPE_NONE && tileOverlays.size == 1) + } + + val moonOverlay = map.tileOverlays.first() + val fadeInBox = CheckBox(activity).apply { isChecked = false } + activity.setFadeIn(fadeInBox) + assertGeo(!moonOverlay.fadeIn) + + fadeInBox.isChecked = true + activity.setFadeIn(fadeInBox) + assertGeo(moonOverlay.fadeIn) + + val transparencyBar = activity.findViewById(R.id.transparencySeekBar) + activity.onProgressChanged(transparencyBar, 50, true) + assertGeo(moonOverlay.transparency == 0.5f) + + activity.clearCache(null) + } + + @Test + fun uiSettingsDemoActivity_synchronizesAllGestureAndControlTogglesWithMapUiSettings() { + val (activity, map) = launchDemo(useNavFlavor = true) + val uiSettings = map.uiSettings + + val toggleCheckbox = CheckBox(activity).apply { isChecked = false } + + activity.setZoomButtonsEnabled(toggleCheckbox) + assertGeo(!uiSettings.isZoomControlsEnabled) + + activity.setCompassEnabled(toggleCheckbox) + assertGeo(!uiSettings.isCompassEnabled) + + activity.setScrollGesturesEnabled(toggleCheckbox) + assertGeo(!uiSettings.isScrollGesturesEnabled) + + activity.setZoomGesturesEnabled(toggleCheckbox) + assertGeo(!uiSettings.isZoomGesturesEnabled) + + activity.setTiltGesturesEnabled(toggleCheckbox) + assertGeo(!uiSettings.isTiltGesturesEnabled) + + activity.setRotateGesturesEnabled(toggleCheckbox) + assertGeo(!uiSettings.isRotateGesturesEnabled) + + activity.setScrollGesturesEnabledDuringRotateOrZoom(toggleCheckbox) + assertGeo(!uiSettings.isScrollGesturesEnabledDuringRotateOrZoom) + } + + @Test + fun visibleRegionDemoActivity_disablesNavCardsInNavFlavorAndMovesBetweenLandmarks() { + val sfo = LatLng(37.614631, -122.385153) + val operaHouse = LatLng(-33.85704, 151.21522) + + val (activity, map) = launchDemo(useNavFlavor = true) + val navFragment = + activity.supportFragmentManager.findFragmentById(R.id.map) as SupportNavigationFragment + val shadowNav = Shadow.extract(navFragment) + + // In Navigation flavor, VisibleRegionDemoActivity disables header and ETA card so padding works + assertGeo(!shadowNav.isHeaderEnabledState && !shadowNav.isEtaCardEnabledState) + + with(map) { + assertMap( + isCenteredOn(sfo, expectedZoom = 18f, tolerance = 5.meters) && + hasMarker(title = "Sydney Opera House", near = operaHouse) && + shadow.paddingLeft == 150 + ) + } + + activity.moveToOperaHouse(null) + with(map) { + assertMap(isCenteredOn(operaHouse, expectedZoom = 16f, tolerance = 5.meters)) + } + + activity.moveToAUS(null) + assertGeo(map.cameraTarget.distanceTo(operaHouse) < 4000.kilometers) + + activity.moveToSFO(null) + with(map) { + assertMap(isCenteredOn(sfo, expectedZoom = 18f, tolerance = 5.meters)) + } + } + + @Test + fun zIndexDemoActivity_managesVisibilityClickabilityZIndexAndTapLoggingForEightOverlays() { + val (activity, map) = launchDemo(useNavFlavor = false) + + with(map) { + assertMap(tileOverlays.size == 2 && circles.size == 1 && groundOverlays.size == 1) + assertMap(markers.size == 2 && polygons.size == 1 && polylines.size == 1) + } + + val zIndexPicker = activity.findViewById(R.id.z_index_picker) + val visibilityBox = activity.findViewById(R.id.visibility_box) + val clickabilityBox = activity.findViewById(R.id.clickability_box) + val tapLog = activity.findViewById(R.id.tap_log) + + // Initially Circle (index 0) is selected + zIndexPicker.setText("42.5") + activity.onEditorAction(zIndexPicker, 0, null) + assertGeo(map.circles.first().zIndex == 42.5f) + + // Invalid float in zIndexPicker logs an error message to tapLog + zIndexPicker.setText("not-a-float") + activity.onEditorAction(zIndexPicker, 0, null) + assertGeo(tapLog.text.toString().contains("invalid float")) + + // Toggle visibility and clickability of Circle + visibilityBox.isChecked = false + activity.onVisibilityCheckboxClicked(visibilityBox) + assertGeo(!map.circles.first().isVisible) + + clickabilityBox.isChecked = false + activity.onClickabilityCheckboxClicked(clickabilityBox) + assertGeo(!map.circles.first().isClickable) + + // Click events append entries to tapLog + activity.onCircleClick(map.circles.first()) + activity.onGroundOverlayClick(map.groundOverlays.first()) + activity.onMarkerClick(map.markers.first()) + activity.onPolygonClick(map.polygons.first()) + activity.onPolylineClick(map.polylines.first()) + val logContent = tapLog.text.toString() + assertGeo( + logContent.contains("Circle was clicked") && + logContent.contains("GroundOverlay was clicked") && + logContent.contains("Marker was clicked") && + logContent.contains("Polygon was clicked") && + logContent.contains("Polyline was clicked") + ) + } + + @Test + fun eventsLayersMapStylingSaveStateAndMainActivity_handleUserInteractionsCorrectly() { + // 1. EventsDemoActivity + val (eventsActivity, _) = launchDemo(useNavFlavor = false) + val clickPoint = LatLng(37.422, -122.084) + eventsActivity.onMapClick(clickPoint) + val tapText = eventsActivity.findViewById(R.id.tap_text) + assertGeo(tapText.text.toString().contains(clickPoint.toString())) + eventsActivity.onMapLongClick(clickPoint) + assertGeo(tapText.text.toString().contains("long pressed")) + + // 2. LayersDemoActivity + val (layersActivity, layersMap) = launchDemo(useNavFlavor = true) + val trafficBox = layersActivity.findViewById(R.id.traffic) + val buildingsBox = layersActivity.findViewById(R.id.buildings) + val styledBox = layersActivity.findViewById(R.id.styled_map) + + trafficBox.isChecked = true + layersActivity.onTrafficToggled(trafficBox) + buildingsBox.isChecked = true + layersActivity.onBuildingsToggled(buildingsBox) + styledBox.isChecked = true + layersActivity.onStyleMapToggled(styledBox) + + assertGeo( + layersMap.isTrafficEnabled && + layersMap.isBuildingsEnabled && + layersMap.shadow.mapStyle != null + ) + + val savedState = Bundle() + layersActivity.saveInstanceState(savedState) + assertGeo( + savedState.getBoolean("trafic") && + savedState.getBoolean("building") && + savedState.getBoolean("style_map") + ) + + // 3. MapStylingDemoActivity + val (stylingActivity, stylingMap) = launchDemo(useNavFlavor = false) + assertGeo(stylingMap.shadow.mapStyle != null) + + stylingActivity.showAdelaide(null) + with(stylingMap) { + assertMap(isCenteredOn(LatLng(-34.92873, 138.59995), expectedZoom = 10f, tolerance = 5.meters)) + } + stylingActivity.showAustralia(null) + with(stylingMap) { + assertMap(isCenteredOn(LatLng(-26.0, 134.0), expectedZoom = 4f, tolerance = 5.meters)) + } + stylingActivity.showSouthChinaSea(null) + with(stylingMap) { + assertMap(isCenteredOn(LatLng(13.870237, 111.470760), expectedZoom = 4f, tolerance = 5.meters)) + } + stylingActivity.setMapStyleTextOutline(null) + assertGeo(stylingMap.shadow.mapStyle != null) + stylingActivity.setMapStyleNoStyle(null) + assertGeo(stylingMap.shadow.mapStyle == null) + + // 4. SaveStateDemoActivity (both Maps and Navigation fragments) + val (saveActivity, saveMap) = launchDemo(useNavFlavor = true) + val paris = LatLng(48.858179, 2.294576) + with(saveMap) { + assertMap(markers.size == 1 && isCenteredOn(paris, tolerance = 5.meters)) + } + val navSaveFragment = + saveActivity.supportFragmentManager.findFragmentById(R.id.map) + as SaveStateDemoActivity.SaveStateNavFragment + val draggedPos = LatLng(48.860, 2.300) + val saveMarker = saveMap.markers.first() + saveMarker.position = draggedPos + navSaveFragment.onMarkerDragEnd(saveMarker) + navSaveFragment.onMarkerClick(saveMarker) + val fragmentOut = Bundle() + navSaveFragment.onSaveInstanceState(fragmentOut) + assertGeo(fragmentOut.getParcelable("markerPosition") == draggedPos) + + // 5. MainActivity launcher flavor toggle button & item click intent + val mainActivity = Robolectric.buildActivity(MainActivity::class.java).setup().get() + val flavorToggle = mainActivity.findViewById(R.id.view_source_toggle_btn) + val demoListView = mainActivity.findViewById(R.id.demoListView) + assertGeo(!flavorToggle.isChecked && demoListView.adapter.count == DemoDetailsList.DEMOS.size) + + flavorToggle.isChecked = true + demoListView.performItemClick( + demoListView.adapter.getView(0, null, demoListView), + 0, + 0L, + ) + val launchedIntent = shadowOf(mainActivity as Activity).nextStartedActivity + assertGeo( + launchedIntent != null && + launchedIntent.component?.className == BasicMapDemoActivity::class.java.name && + launchedIntent.getBooleanExtra( + ActivityIntents.EXTRA_SHOULD_USE_NAVIGATION_FLAVOR_FOR_DEMO, + false, + ) + ) + } + + @Test + fun backgroundColorAndDayNightCircleAndStyledTileOverlayDemos_updateMapTypeCircleAndStyles() { + // 1. BackgroundColorCustomizationDemoActivity + val (bgActivity, bgMap) = + launchDemo(useNavFlavor = true) + with(bgMap) { + assertMap( + mapType == GoogleMap.MAP_TYPE_NONE && + markers.size == 1 && + hasMarker(title = "Marker", near = LatLng(0.0, 0.0)) + ) + } + val mapTypeToggle = bgActivity.findViewById(R.id.map_type_toggle) + mapTypeToggle.isChecked = true + assertGeo(bgMap.mapType == GoogleMap.MAP_TYPE_NORMAL) + mapTypeToggle.isChecked = false + assertGeo(bgMap.mapType == GoogleMap.MAP_TYPE_NONE) + + // 2. DayNightCircleDemoActivity + val (dayNightActivity, dayNightMap) = + launchDemo(useNavFlavor = false) + assertGeo(dayNightMap.circles.size == 1) + val terminatorCircle = dayNightMap.circles.first() + assertGeo(terminatorCircle.radius > 10_000_000.0 && terminatorCircle.center.longitude > 0.0) + dayNightActivity.onDestroy() + + // 3. TopStylesDemoActivity + val (topStylesActivity, topStylesMap) = + launchDemo(useNavFlavor = false) + val stylesSpinner = topStylesActivity.findViewById(R.id.styles_spinner) + stylesSpinner.setSelection(1) + topStylesActivity.onItemSelected(stylesSpinner, null, 1, 1L) + assertGeo(topStylesMap.shadow.mapStyle != null) + + val topTrafficBox = topStylesActivity.findViewById(R.id.traffic) + topTrafficBox.isChecked = true + topStylesActivity.onTrafficToggled(topTrafficBox) + assertGeo(topStylesMap.isTrafficEnabled) + + // 4. TileOverlayOnStyledMapDemoActivity + val (styledTileActivity, styledTileMap) = + launchDemo(useNavFlavor = true) + assertGeo( + styledTileMap.shadow.mapStyle != null && styledTileMap.tileOverlays.size == 1 + ) + val moonOverlay = styledTileMap.tileOverlays.first() + val transparencySeek = styledTileActivity.findViewById(R.id.transparencySeekBar) + styledTileActivity.onProgressChanged(transparencySeek, 40, true) + assertGeo(moonOverlay.transparency == 0.4f) + val fadeCheck = CheckBox(styledTileActivity).apply { isChecked = false } + styledTileActivity.setFadeIn(fadeCheck) + assertGeo(!moonOverlay.fadeIn) + styledTileActivity.clearCache(null) + } + + @Test + fun markerAnchorsPoiClickProgrammaticToolbarAndSnapshotDemos_handleAllInteractions() { + // 1. MarkerAnchorsDemoActivity + val (anchorsActivity, anchorsMap) = + launchDemo(useNavFlavor = false) + with(anchorsMap) { + assertMap( + markers.size == 2 && + hasMarker(title = "Adelaide", near = LatLng(-34.92873, 138.59995)) && + hasMarker(title = "Sydney", near = LatLng(-33.87365, 151.20689)) + ) + } + val rotationSeek = anchorsActivity.findViewById(R.id.rotationSeekBar) + rotationSeek.progress = 135 + anchorsActivity.onProgressChanged(rotationSeek, 135, true) + assertGeo(anchorsMap.markers.all { it.rotation == 135f }) + + // 2. OnPoiClickDemoActivity + val (poiActivity, poiMap) = launchDemo(useNavFlavor = true) + with(poiMap) { + assertMap(isCenteredOn(LatLng(-33.87365, 151.20689), expectedZoom = 16f, tolerance = 5.meters)) + } + val poi = PointOfInterest(LatLng(-33.87365, 151.20689), "place_id_123", "Queen Victoria Building") + poiActivity.onPoiClick(poi) + assertThat(ShadowToast.getTextOfLatestToast()).contains("Queen Victoria Building") + + // 3. ProgrammaticDemoActivity (Maps & Navigation flavors) + val (progMapsActivity, progMaps) = + launchDemo(useNavFlavor = false) + assertGeo( + progMapsActivity.supportFragmentManager.findFragmentByTag("map") is SupportMapFragment && + progMaps.markers.size == 1 + ) + val (progNavActivity, progNav) = + launchDemo(useNavFlavor = true) + assertGeo( + progNavActivity.supportFragmentManager.findFragmentByTag("map") is SupportNavigationFragment && + progNav.markers.size == 1 + ) + + // 4. ToolbarDemoActivity + val (toolbarActivity, toolbarMap) = + launchDemo(useNavFlavor = false) + with(toolbarMap) { + assertMap(markers.isEmpty() && polygons.size == 1 && polylines.size == 1) + } + val toolbarToggle = toolbarActivity.findViewById(R.id.enable_toolbar_globally) + toolbarToggle.isChecked = false + toolbarToggle.performClick() + toolbarActivity.onMapLongClick(LatLng(-30.0, 140.0)) + assertGeo(toolbarMap.markers.size == 1) + + // 5. SnapshotDemoActivity + val snapshotIntent = + Intent(ApplicationProvider.getApplicationContext(), SnapshotDemoActivity::class.java) + val snapshotActivity = + Robolectric.buildActivity(SnapshotDemoActivity::class.java, snapshotIntent).setup().get() + ShadowLooper.idleMainLooper() + val snapshotHolder = snapshotActivity.findViewById(R.id.snapshot_holder) + assertGeo(snapshotHolder.drawable == null) + snapshotActivity.onScreenshot(null) + ShadowLooper.idleMainLooper() + assertGeo(snapshotHolder.drawable != null) + snapshotActivity.onClearScreenshot(null) + assertGeo(snapshotHolder.drawable == null) + } + + private fun LayersDemoActivity.saveInstanceState(outState: Bundle) { + val method = + LayersDemoActivity::class.java.getDeclaredMethod("onSaveInstanceState", Bundle::class.java) + method.isAccessible = true + method.invoke(this, outState) + } +} diff --git a/map-sample/app/src/test/java/com/example/mapdemo/MapActivitiesTruthTest.java b/map-sample/app/src/test/java/com/example/mapdemo/MapActivitiesTruthTest.java new file mode 100644 index 0000000..e9a8e3b --- /dev/null +++ b/map-sample/app/src/test/java/com/example/mapdemo/MapActivitiesTruthTest.java @@ -0,0 +1,161 @@ +/* + * 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.example.mapdemo; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.maps.android.robolectric.truth.MapsTruth.assertThat; + +import android.content.Intent; +import android.widget.CheckBox; +import androidx.appcompat.app.AppCompatActivity; +import androidx.test.core.app.ApplicationProvider; +import com.example.mapdemo.testing.NavDelegateReflection; +import com.example.mapdemo.testing.NavMapsRobolectricTest; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.robolectric.truth.Distance; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +/** + * Java headless Robolectric JVM tests for {@code map-sample} demo activities using Google + * Truth ({@link com.google.common.truth.Truth#assertThat}) and {@code android-maps-testing}'s + * {@link com.google.maps.android.robolectric.truth.MapsTruth#assertThat(GoogleMap)} domain + * subjects. + * + *

Validates Java interoperability across {@link BasicMapDemoActivity}, {@link + * CameraDemoActivity}, {@link CameraClampingDemoActivity}, {@link CircleDemoActivity}, {@link + * MarkerDemoActivity}, {@link PolygonDemoActivity}, {@link PolylineDemoActivity}, {@link + * TagsDemoActivity}, {@link UiSettingsDemoActivity}, and {@link LayersDemoActivity}. + */ +@RunWith(RobolectricTestRunner.class) +public class MapActivitiesTruthTest extends NavMapsRobolectricTest { + + private static T launchActivity( + Class activityClass, boolean useNavFlavor) { + Intent intent = new Intent(ApplicationProvider.getApplicationContext(), activityClass); + intent.putExtra(ActivityIntents.EXTRA_SHOULD_USE_NAVIGATION_FLAVOR_FOR_DEMO, useNavFlavor); + return Robolectric.buildActivity(activityClass, intent).setup().get(); + } + + private static GoogleMap extractMap(AppCompatActivity activity) { + Object fragment = activity.getSupportFragmentManager().findFragmentById(R.id.map); + return NavDelegateReflection.extractActivityMap(fragment); + } + + @Test + public void basicMapDemo_hasMarkerAtOriginWithMapsTruthSubject() { + BasicMapDemoActivity activity = launchActivity(BasicMapDemoActivity.class, false); + GoogleMap map = extractMap(activity); + + assertThat(map).hasMarkerCount(1); + assertThat(map).hasMarkerWithTitle("Marker").isAt(new LatLng(0, 0)).isVisible(); + } + + @Test + public void cameraDemo_movesBetweenBondiAndSydneyWithExpectedZoomBearingAndTilt() { + LatLng bondi = new LatLng(-33.891614, 151.276417); + LatLng sydney = new LatLng(-33.87365, 151.20689); + + CameraDemoActivity activity = launchActivity(CameraDemoActivity.class, false); + GoogleMap map = extractMap(activity); + + assertThat(map).isCenteredOn(sydney, new Distance(5.0)).hasZoom(10f); + + activity.onGoToBondi(null); + assertThat(map).isCenteredOn(bondi, new Distance(5.0)).hasZoom(15.5f); + assertThat(map.getCameraPosition().bearing).isEqualTo(300f); + assertThat(map.getCameraPosition().tilt).isEqualTo(50f); + + activity.onGoToSydney(null); + assertThat(map).isCenteredOn(sydney, new Distance(5.0)).hasZoom(15.5f); + assertThat(map.getCameraPosition().tilt).isEqualTo(25f); + } + + @Test + public void circlePolygonAndPolylineDemos_populateExpectedOverlaysOnMap() { + CircleDemoActivity circleActivity = launchActivity(CircleDemoActivity.class, false); + GoogleMap circleMap = extractMap(circleActivity); + assertThat(circleMap).hasCircleCount(1); + assertThat(circleMap).hasMarkerCount(2); + assertThat(circleMap).hasPolylineCount(1); + assertThat(circleMap).hasContentDescription("Google Map with circles."); + + PolygonDemoActivity polygonActivity = launchActivity(PolygonDemoActivity.class, true); + GoogleMap polygonMap = extractMap(polygonActivity); + assertThat(polygonMap).hasPolygonCount(4); + assertThat(polygonMap).hasContentDescription("Google Map with polygons."); + + PolylineDemoActivity polylineActivity = launchActivity(PolylineDemoActivity.class, false); + GoogleMap polylineMap = extractMap(polylineActivity); + assertThat(polylineMap).hasPolylineCount(4); + assertThat(polylineMap).hasContentDescription("Google Map with polylines."); + } + + @Test + public void tagsAndUiSettingsDemos_configureUiSettingsAndOverlaysViaTruthSubjects() { + LatLng hobart = new LatLng(-42.8823388, 147.311042); + TagsDemoActivity tagsActivity = launchActivity(TagsDemoActivity.class, false); + GoogleMap tagsMap = extractMap(tagsActivity); + + assertThat(tagsMap.getUiSettings().isZoomControlsEnabled()).isFalse(); + assertThat(tagsMap.getUiSettings().isScrollGesturesEnabled()).isFalse(); + assertThat(tagsMap.getUiSettings().isZoomGesturesEnabled()).isFalse(); + assertThat(tagsMap.getUiSettings().isTiltGesturesEnabled()).isFalse(); + assertThat(tagsMap.getUiSettings().isRotateGesturesEnabled()).isFalse(); + assertThat(tagsMap).hasCircleCount(1); + assertThat(tagsMap).hasGroundOverlayCount(1); + assertThat(tagsMap).hasMarkerNear(hobart); + assertThat(tagsMap).hasPolygonCount(1); + assertThat(tagsMap).hasPolylineCount(1); + + UiSettingsDemoActivity uiActivity = launchActivity(UiSettingsDemoActivity.class, false); + GoogleMap uiMap = extractMap(uiActivity); + assertThat(uiMap.getUiSettings().isZoomControlsEnabled()).isTrue(); + assertThat(uiMap.getUiSettings().isCompassEnabled()).isTrue(); + + CheckBox unchecked = new CheckBox(uiActivity); + unchecked.setChecked(false); + uiActivity.setZoomButtonsEnabled(unchecked); + uiActivity.setCompassEnabled(unchecked); + + assertThat(uiMap.getUiSettings().isZoomControlsEnabled()).isFalse(); + assertThat(uiMap.getUiSettings().isCompassEnabled()).isFalse(); + } + + @Test + public void layersDemo_togglesTrafficAndBuildingsLayersViaMapsTruth() { + LayersDemoActivity activity = launchActivity(LayersDemoActivity.class, false); + GoogleMap map = extractMap(activity); + + assertThat(map.isTrafficEnabled()).isFalse(); + assertThat(map.isBuildingsEnabled()).isTrue(); + + CheckBox trafficCheckbox = activity.findViewById(R.id.traffic); + CheckBox buildingsCheckbox = activity.findViewById(R.id.buildings); + + trafficCheckbox.setChecked(true); + activity.onTrafficToggled(trafficCheckbox); + buildingsCheckbox.setChecked(false); + activity.onBuildingsToggled(buildingsCheckbox); + + assertThat(map.isTrafficEnabled()).isTrue(); + assertThat(map.isBuildingsEnabled()).isFalse(); + } +} diff --git a/map-sample/app/src/test/java/com/example/mapdemo/MapSampleGoldenTest.kt b/map-sample/app/src/test/java/com/example/mapdemo/MapSampleGoldenTest.kt new file mode 100644 index 0000000..24687a3 --- /dev/null +++ b/map-sample/app/src/test/java/com/example/mapdemo/MapSampleGoldenTest.kt @@ -0,0 +1,105 @@ +/* + * 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.example.mapdemo + +import android.content.Intent +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ApplicationProvider +import com.example.mapdemo.testing.NavDelegateReflection +import com.example.mapdemo.testing.NavMapsRobolectricTest +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.robolectric.powerassert.assertGeo +import com.google.maps.android.robolectric.powerassert.hasOverlayPixelAt +import com.google.maps.android.testing.golden.GoldenTileStrategy +import com.google.maps.android.testing.golden.assertMatchesGolden +import com.google.maps.android.testing.golden.enableGoldenTesting +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * Deterministic offline golden screenshot tests for `map-sample` demo activities using + * `com.google.maps.android:maps-testing-golden` (`enableGoldenTesting`, `assertMatchesGolden`) and + * committed `.png` baseline images under `src/test/resources/goldens/`. + * + * Sits above fast unit/Robolectric tests in the testing pyramid to verify pixel-deterministic + * rendering of actual sample activities ([CircleDemoActivity], [PolygonDemoActivity], and + * [TileCoordinateDemoActivity]). Pass `-Dmaps.golden.record=true` to record or update baselines. + */ +@RunWith(RobolectricTestRunner::class) +class MapSampleGoldenTest : NavMapsRobolectricTest() { + + private inline fun launchActivityWithSizedMap(): GoogleMap { + val intent = + Intent(ApplicationProvider.getApplicationContext(), T::class.java).apply { + putExtra(ActivityIntents.EXTRA_SHOULD_USE_NAVIGATION_FLAVOR_FOR_DEMO, false) + } + val activity = Robolectric.buildActivity(T::class.java, intent).setup().get() + val fragment = activity.supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment + val fragmentView = checkNotNull(fragment.view) + fragmentView.measure( + View.MeasureSpec.makeMeasureSpec(256, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(256, View.MeasureSpec.EXACTLY), + ) + fragmentView.layout(0, 0, 256, 256) + return NavDelegateReflection.extractActivityMap(fragment) + } + + @Test + fun circleDemoActivity_rendersSydneyDraggableCircleMatchingCommittedGolden() = runTest { + val sydney = LatLng(-33.87365, 151.20689) + val map = launchActivityWithSizedMap() + map.enableGoldenTesting(strategy = GoldenTileStrategy.CoordinateGrid()) + + assertGeo(map.hasOverlayPixelAt(sydney)) + + map.assertMatchesGolden( + goldenFile = File("src/test/resources/goldens/circle_demo_sydney.png"), + maxDiffPercentage = 0.0, + message = "CircleDemoActivity rendered map must match committed golden baseline PNG", + ) + } + + @Test + fun polygonDemoActivity_rendersAustralianPolygonsMatchingCommittedGolden() = runTest { + val map = launchActivityWithSizedMap() + map.enableGoldenTesting(strategy = GoldenTileStrategy.SolidColorHash()) + + map.assertMatchesGolden( + goldenFile = File("src/test/resources/goldens/polygon_demo_australia.png"), + maxDiffPercentage = 0.0, + message = "PolygonDemoActivity rendered map must match committed golden baseline PNG", + ) + } + + @Test + fun tileCoordinateDemoActivity_rendersCustomTileOverlayMatchingCommittedGolden() = runTest { + val map = launchActivityWithSizedMap() + + map.assertMatchesGolden( + goldenFile = File("src/test/resources/goldens/tile_coordinate_demo.png"), + maxDiffPercentage = 0.0, + message = "TileCoordinateDemoActivity custom tile overlay must match committed golden PNG", + ) + } +} diff --git a/map-sample/app/src/test/java/com/example/mapdemo/MapUtilitiesJavaTest.java b/map-sample/app/src/test/java/com/example/mapdemo/MapUtilitiesJavaTest.java new file mode 100644 index 0000000..19a152c --- /dev/null +++ b/map-sample/app/src/test/java/com/example/mapdemo/MapUtilitiesJavaTest.java @@ -0,0 +1,293 @@ +/* + * 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.example.mapdemo; + +import static com.google.common.truth.Truth.assertThat; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup.MarginLayoutParams; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.test.core.app.ApplicationProvider; +import com.example.mapdemo.EdgeToEdgeUtil.EdgeToEdgeMarginConfig; +import com.example.mapdemo.testing.NavDelegateReflection; +import com.example.mapdemo.testing.NavMapsRobolectricTest; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.libraries.navigation.SupportNavigationFragment; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; + +/** + * Java unit tests for {@code map-sample} utility and infrastructure classes using Google + * Truth ({@link com.google.common.truth.Truth#assertThat}). + * + *

Covers: + * + *

    + *
  • {@link EdgeToEdgeUtil} and {@link EdgeToEdgeMarginConfig} AutoValue builder defaults and + * system-bar window inset margin application. + *
  • {@link FeatureView} custom widget inflation and title/description binding. + *
  • {@link AnimationManager} frame scheduling, start/stop idempotency, and FPS updates. + *
  • {@link OnMapAndViewReadyListener} synchronization between layout completion and {@code + * getMapAsync} for both {@link SupportMapFragment} and {@link SupportNavigationFragment}. + *
+ */ +@RunWith(RobolectricTestRunner.class) +public class MapUtilitiesJavaTest extends NavMapsRobolectricTest { + + /** Test double for {@link SupportMapFragment} with a configurable view and deferred map ready. */ + private static class FakeSupportMapFragment extends SupportMapFragment { + private final View fragmentView; + + FakeSupportMapFragment(View fragmentView) { + this.fragmentView = fragmentView; + } + + @Override + public View getView() { + return fragmentView; + } + + @Override + public void getMapAsync(OnMapReadyCallback callback) { + // Deferred until test explicitly invokes listener.onMapReady(map) + } + } + + /** + * Test double for {@link SupportNavigationFragment} with a configurable view and deferred map + * ready. + */ + private static class FakeSupportNavigationFragment extends SupportNavigationFragment { + private final View fragmentView; + + FakeSupportNavigationFragment(View fragmentView) { + this.fragmentView = fragmentView; + } + + @Override + public View getView() { + return fragmentView; + } + + @Override + public void getMapAsync(OnMapReadyCallback callback) { + // Deferred until test explicitly invokes listener.onMapReady(map) + } + } + + @Test + public void edgeToEdgeMarginConfig_defaultsAllMarginsToTrueAndAppliesSelectedInsets() { + Context context = ApplicationProvider.getApplicationContext(); + View targetView = new View(context); + MarginLayoutParams initialParams = new MarginLayoutParams(200, 200); + targetView.setLayoutParams(initialParams); + + EdgeToEdgeMarginConfig defaultConfig = + EdgeToEdgeMarginConfig.builder().setView(targetView).build(); + assertThat(defaultConfig.view()).isSameInstanceAs(targetView); + assertThat(defaultConfig.shouldSetTopMargin()).isTrue(); + assertThat(defaultConfig.shouldSetBottomMargin()).isTrue(); + assertThat(defaultConfig.shouldSetLeftMargin()).isTrue(); + assertThat(defaultConfig.shouldSetRightMargin()).isTrue(); + + EdgeToEdgeMarginConfig customConfig = + EdgeToEdgeMarginConfig.builder() + .setView(targetView) + .setShouldSetTopMargin(true) + .setShouldSetBottomMargin(false) + .setShouldSetLeftMargin(true) + .setShouldSetRightMargin(false) + .build(); + EdgeToEdgeUtil.setMarginForEdgeToEdgeSupport(customConfig); + + WindowInsetsCompat windowInsets = + new WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.of(12, 24, 36, 48)) + .build(); + ViewCompat.onApplyWindowInsets(targetView, windowInsets); + + MarginLayoutParams updatedParams = (MarginLayoutParams) targetView.getLayoutParams(); + assertThat(updatedParams.leftMargin).isEqualTo(12); + assertThat(updatedParams.topMargin).isEqualTo(24); + assertThat(updatedParams.rightMargin).isEqualTo(0); + assertThat(updatedParams.bottomMargin).isEqualTo(0); + } + + @Test + public void featureView_inflatesLayoutAndUpdatesTitleAndDescriptionText() { + Context context = ApplicationProvider.getApplicationContext(); + FeatureView featureView = new FeatureView(context); + + featureView.setTitleId(R.string.basic_map_demo_label); + featureView.setDescriptionId(R.string.basic_map_demo_description); + + TextView titleView = featureView.findViewById(R.id.title); + TextView descriptionView = featureView.findViewById(R.id.description); + + assertThat(titleView).isNotNull(); + assertThat(descriptionView).isNotNull(); + assertThat(titleView.getText().toString()) + .isEqualTo(context.getString(R.string.basic_map_demo_label)); + assertThat(descriptionView.getText().toString()) + .isEqualTo(context.getString(R.string.basic_map_demo_description)); + } + + @Test + public void animationManager_schedulesFramesOnMainLooperAndStopsCleanly() { + AtomicInteger frameCount = new AtomicInteger(0); + AnimationManager manager = new AnimationManager(frameCount::incrementAndGet); + + assertThat(manager.getFrameRateFps()).isEqualTo(60.0); + manager.setFrameRateFps(20.0); + assertThat(manager.getFrameRateFps()).isEqualTo(20.0); + + manager.startAnimation(); + // Starting an already running animation is a no-op. + manager.startAnimation(); + + Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(160)); + int countAfterRun = frameCount.get(); + assertThat(countAfterRun).isAtLeast(2); + + manager.stopAnimation(); + Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(200)); + assertThat(frameCount.get()).isEqualTo(countAfterRun); + } + + @Test + public void onMapAndViewReadyListener_waitsForBothLayoutAndMapReadyBeforeFiringCallback() { + Context context = ApplicationProvider.getApplicationContext(); + // An un-laid-out view has width=0 and height=0 + FrameLayout unlaidOutView = new FrameLayout(context); + FakeSupportMapFragment mapFragment = new FakeSupportMapFragment(unlaidOutView); + + AtomicReference receivedMap = new AtomicReference<>(); + OnMapAndViewReadyListener listener = + new OnMapAndViewReadyListener( + mapFragment, + (OnMapAndViewReadyListener.OnGlobalLayoutAndMapReadyListener) receivedMap::set); + + GoogleMap expectedMap = NavDelegateReflection.createGoogleMapInstance(); + // Map arrives first while view dimensions are still 0x0 -> callback should not fire yet. + listener.onMapReady(expectedMap); + assertThat(receivedMap.get()).isNull(); + + // Global layout completes -> callback fires with the ready GoogleMap. + listener.onGlobalLayout(); + assertThat(receivedMap.get()).isSameInstanceAs(expectedMap); + } + + @Test + public void onMapAndViewReadyListener_firesImmediatelyWhenNavigationFragmentViewAlreadyLaidOut() { + Context context = ApplicationProvider.getApplicationContext(); + FrameLayout laidOutView = new FrameLayout(context); + laidOutView.layout(0, 0, 400, 400); + FakeSupportNavigationFragment navFragment = new FakeSupportNavigationFragment(laidOutView); + + AtomicReference receivedMap = new AtomicReference<>(); + OnMapAndViewReadyListener listener = + new OnMapAndViewReadyListener(navFragment, (OnMapReadyCallback) receivedMap::set); + + GoogleMap expectedMap = NavDelegateReflection.createGoogleMapInstance(); + listener.onMapReady(expectedMap); + assertThat(receivedMap.get()).isSameInstanceAs(expectedMap); + } + + @Test + public void permissionUtils_evaluatesGrantResultsAndCreatesDialogs() { + String fineLocation = Manifest.permission.ACCESS_FINE_LOCATION; + String coarseLocation = Manifest.permission.ACCESS_COARSE_LOCATION; + + assertThat( + PermissionUtils.isPermissionGranted( + new String[] {coarseLocation, fineLocation}, + new int[] {PackageManager.PERMISSION_DENIED, PackageManager.PERMISSION_GRANTED}, + fineLocation)) + .isTrue(); + + assertThat( + PermissionUtils.isPermissionGranted( + new String[] {fineLocation}, + new int[] {PackageManager.PERMISSION_DENIED}, + fineLocation)) + .isFalse(); + + assertThat( + PermissionUtils.isPermissionGranted( + new String[] {coarseLocation}, + new int[] {PackageManager.PERMISSION_GRANTED}, + fineLocation)) + .isFalse(); + + PermissionUtils.PermissionDeniedDialog deniedDialog = + PermissionUtils.PermissionDeniedDialog.newInstance(true); + assertThat(deniedDialog.getArguments().getBoolean("finish")).isTrue(); + + PermissionUtils.RationaleDialog rationaleDialog = + PermissionUtils.RationaleDialog.newInstance(42, true); + assertThat(rationaleDialog.getArguments().getInt("requestCode")).isEqualTo(42); + assertThat(rationaleDialog.getArguments().getBoolean("finish")).isTrue(); + } + + @Test + public void polygonAndPolylinePagerAdapters_instantiateExpectedControlFragmentsAndTitles() { + AppCompatActivity activity = Robolectric.buildActivity(AppCompatActivity.class).setup().get(); + + PolylineControlFragmentPagerAdapter polylineAdapter = + new PolylineControlFragmentPagerAdapter(activity.getSupportFragmentManager()); + assertThat(polylineAdapter.getCount()).isEqualTo(8); + assertThat(polylineAdapter.getPageTitle(0).toString()).isEqualTo("Color"); + assertThat(polylineAdapter.getPageTitle(1).toString()).isEqualTo("Width"); + assertThat(polylineAdapter.getPageTitle(2).toString()).isEqualTo("Cap"); + assertThat(polylineAdapter.getPageTitle(3).toString()).isEqualTo("Joint"); + assertThat(polylineAdapter.getPageTitle(4).toString()).isEqualTo("Pattern"); + assertThat(polylineAdapter.getPageTitle(5).toString()).isEqualTo("Points"); + assertThat(polylineAdapter.getPageTitle(6).toString()).isEqualTo("Spans"); + assertThat(polylineAdapter.getPageTitle(7).toString()).isEqualTo("Other Options"); + assertThat(polylineAdapter.getItem(0)).isInstanceOf(PolylineColorControlFragment.class); + assertThat(polylineAdapter.getItem(7)).isInstanceOf(PolylineOtherOptionsControlFragment.class); + + PolygonControlFragmentPagerAdapter polygonAdapter = + new PolygonControlFragmentPagerAdapter(activity.getSupportFragmentManager()); + assertThat(polygonAdapter.getCount()).isEqualTo(6); + assertThat(polygonAdapter.getPageTitle(0).toString()).isEqualTo("Stroke/fill color"); + assertThat(polygonAdapter.getPageTitle(1).toString()).isEqualTo("Stroke width"); + assertThat(polygonAdapter.getPageTitle(2).toString()).isEqualTo("Stroke joint"); + assertThat(polygonAdapter.getPageTitle(3).toString()).isEqualTo("Stroke pattern"); + assertThat(polygonAdapter.getPageTitle(4).toString()).isEqualTo("Points"); + assertThat(polygonAdapter.getPageTitle(5).toString()).isEqualTo("Other Options"); + assertThat(polygonAdapter.getItem(0)).isInstanceOf(PolygonColorControlFragment.class); + assertThat(polygonAdapter.getItem(5)).isInstanceOf(PolygonOtherOptionsControlFragment.class); + } +} diff --git a/map-sample/app/src/test/java/com/example/mapdemo/testing/NavMapsTestShadows.kt b/map-sample/app/src/test/java/com/example/mapdemo/testing/NavMapsTestShadows.kt new file mode 100644 index 0000000..91ee7ff --- /dev/null +++ b/map-sample/app/src/test/java/com/example/mapdemo/testing/NavMapsTestShadows.kt @@ -0,0 +1,574 @@ +/* + * 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.example.mapdemo.testing + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Point +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.RelativeLayout +import androidx.fragment.app.FragmentActivity +import com.google.android.gms.maps.CameraUpdate +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.OnMapReadyCallback +import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.UiSettings +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.libraries.navigation.NavigationApi +import com.google.android.libraries.navigation.NavigationApi.NavigatorListener +import com.google.android.libraries.navigation.NavigationView +import com.google.android.libraries.navigation.Navigator +import com.google.android.libraries.navigation.SupportNavigationFragment +import com.google.maps.android.robolectric.shadows.ShadowBitmapDescriptor +import com.google.maps.android.robolectric.shadows.ShadowCameraUpdate +import com.google.maps.android.robolectric.shadows.ShadowCircle +import com.google.maps.android.robolectric.shadows.ShadowGoogleMap +import com.google.maps.android.robolectric.shadows.ShadowGroundOverlay +import com.google.maps.android.robolectric.shadows.ShadowMapView +import com.google.maps.android.robolectric.shadows.ShadowMarker +import com.google.maps.android.robolectric.shadows.ShadowPolygon +import com.google.maps.android.robolectric.shadows.ShadowPolyline +import com.google.maps.android.robolectric.shadows.ShadowProjection +import com.google.maps.android.robolectric.shadows.ShadowSupportMapFragment +import com.google.maps.android.robolectric.shadows.ShadowTileOverlay +import com.google.maps.android.robolectric.shadows.ShadowUiSettings +import java.lang.reflect.Proxy +import org.mockito.Mockito +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.Shadows +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowViewGroup +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** + * Base class for Robolectric tests in `map-sample` that configures SDK 34, native graphics, and + * Robolectric shadows for both the Navigation SDK's bundled Maps classes and the Navigation SDK UI + * containers (`SupportNavigationFragment`, `NavigationView`, `NavigationApi`). + * + * Because Robolectric's `ConfigMerger` inspects `@Config` on the test class and its superclass + * hierarchy (rather than meta-annotations), extending [NavMapsRobolectricTest] guarantees that all + * custom shadows are active for every test method. + */ +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config( + sdk = [34], + shadows = + [ + NavShadowGoogleMap::class, + NavShadowMapView::class, + NavShadowSupportMapFragment::class, + ShadowMarker::class, + ShadowPolyline::class, + ShadowPolygon::class, + ShadowCircle::class, + ShadowGroundOverlay::class, + ShadowTileOverlay::class, + ShadowCameraUpdate::class, + NavShadowCameraUpdateFactory::class, + ShadowBitmapDescriptor::class, + NavShadowBitmapDescriptorFactory::class, + NavShadowUiSettings::class, + ShadowProjection::class, + ShadowSupportNavigationFragment::class, + ShadowNavigationView::class, + ShadowNavigationApi::class, + ], +) +open class NavMapsRobolectricTest + +/** + * Reflective constructor helper that creates instances of Navigation SDK's bundled Maps classes by + * supplying a dynamic proxy for their single-argument obfuscated internal delegate interface. + */ +object NavDelegateReflection { + + /** + * Constructs an instance of [clazz] using its single-argument constructor and a no-op dynamic + * proxy implementing that parameter's interface. + */ + @JvmStatic + fun constructWithDummyDelegate(clazz: Class): T { + val constructor = + clazz.declaredConstructors.firstOrNull { it.parameterCount == 1 } + ?: throw IllegalStateException("No single-arg constructor found on ${clazz.name}") + constructor.isAccessible = true + val paramType = constructor.parameterTypes[0] + val dummyDelegate = + if (paramType.isInterface) { + Proxy.newProxyInstance(paramType.classLoader, arrayOf(paramType)) { _, method, _ -> + when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 0L + Float::class.javaPrimitiveType -> 0f + Double::class.javaPrimitiveType -> 0.0 + else -> null + } + } + } else { + Mockito.mock(paramType) + } + @Suppress("UNCHECKED_CAST") + return constructor.newInstance(dummyDelegate) as T + } + + /** Creates a [GoogleMap] wired to a [ShadowGoogleMap]. */ + @JvmStatic + fun createGoogleMapInstance(): GoogleMap { + return constructWithDummyDelegate(GoogleMap::class.java) + } + + /** Extracts the [GoogleMap] held by either a [SupportMapFragment] or [SupportNavigationFragment]. */ + @JvmStatic + fun extractActivityMap(fragment: Any?): GoogleMap { + Shadows.shadowOf(Looper.getMainLooper()).idle() + return when (fragment) { + is SupportNavigationFragment -> { + val shadow = Shadow.extract(fragment) + shadow.googleMap + ?: throw IllegalStateException("GoogleMap was not initialized on SupportNavigationFragment") + } + is SupportMapFragment -> { + val shadow = Shadow.extract(fragment) + shadow.resolveGoogleMap() + ?: throw IllegalStateException("GoogleMap was not initialized on SupportMapFragment") + } + else -> throw IllegalArgumentException("Unexpected fragment type: $fragment") + } + } +} + +/** + * Subclass of [ShadowGoogleMap] that handles Navigation SDK's obfuscated `UiSettings` and + * `Projection` constructors. + */ +@Implements(GoogleMap::class) +class NavShadowGoogleMap : ShadowGoogleMap() { + private var navUiSettings: UiSettings? = null + + @Implementation + override fun getUiSettings(): UiSettings { + if (navUiSettings == null) { + navUiSettings = NavDelegateReflection.constructWithDummyDelegate(UiSettings::class.java) + } + return navUiSettings!! + } + + @Implementation + override fun getProjection(): Projection { + val projection = NavDelegateReflection.constructWithDummyDelegate(Projection::class.java) + val shadow = Shadow.extract(projection) + val width = if (mapView != null && mapView.width > 0) mapView.width else viewportWidth + val height = if (mapView != null && mapView.height > 0) mapView.height else viewportHeight + shadow.setMapState(cameraPosition, width, height) + return projection + } +} + +/** + * Subclass of [ShadowUiSettings] adding full state tracking for + * `isScrollGesturesEnabledDuringRotateOrZoom`, which `UiSettingsDemoActivity` toggles. + */ +@Implements(UiSettings::class) +class NavShadowUiSettings : ShadowUiSettings() { + private var scrollGesturesEnabledDuringRotateOrZoom: Boolean = true + + @Implementation + fun setScrollGesturesEnabledDuringRotateOrZoom(enabled: Boolean) { + scrollGesturesEnabledDuringRotateOrZoom = enabled + } + + @Implementation + fun isScrollGesturesEnabledDuringRotateOrZoom(): Boolean = + scrollGesturesEnabledDuringRotateOrZoom +} + +/** Subclass of [ShadowMapView] that creates a [GoogleMap] compatible with Navigation SDK's classes. */ +@Implements(MapView::class) +class NavShadowMapView : ShadowMapView() { + @RealObject private lateinit var realMapViewInstance: MapView + + @Implementation + override fun getMapAsync(callback: OnMapReadyCallback) { + if (getGoogleMap() == null) { + val map = NavDelegateReflection.createGoogleMapInstance() + val shadowMap = Shadow.extract(map) + shadowMap.setMapView(realMapViewInstance) + val field = ShadowMapView::class.java.getDeclaredField("googleMap") + field.isAccessible = true + field.set(this, map) + } + val map = getGoogleMap() + Handler(Looper.getMainLooper()).post { callback.onMapReady(map) } + } +} + +/** + * Subclass of [ShadowSupportMapFragment] that creates a [GoogleMap] compatible with Navigation + * SDK's bundled Maps classes and lays out its container view with default 400x400 bounds so + * `OnMapAndViewReadyListener` and screen-projection calculations work out of the box. + */ +@Implements(SupportMapFragment::class) +class NavShadowSupportMapFragment : ShadowSupportMapFragment() { + @Implementation + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val view = super.onCreateView(inflater, container, savedInstanceState) + view.layout(0, 0, 400, 400) + return view + } + + @Implementation + override fun getMapAsync(callback: OnMapReadyCallback) { + val view = getMapView() + if (view != null) { + view.getMapAsync(callback) + return + } + val field = ShadowSupportMapFragment::class.java.getDeclaredField("googleMap") + field.isAccessible = true + var currentMap = field.get(this) as? GoogleMap + if (currentMap == null) { + currentMap = NavDelegateReflection.createGoogleMapInstance() + field.set(this, currentMap) + } + callback.onMapReady(currentMap) + } + + fun resolveGoogleMap(): GoogleMap? { + val view = getMapView() + if (view != null) { + val shadowView = Shadow.extract(view) + if (shadowView.getGoogleMap() != null) { + return shadowView.getGoogleMap() + } + } + val field = ShadowSupportMapFragment::class.java.getDeclaredField("googleMap") + field.isAccessible = true + return field.get(this) as? GoogleMap + } +} + +/** + * Shadow for [CameraUpdateFactory] that creates [CameraUpdate] instances via Navigation SDK's + * single-argument delegate constructor and configures their [ShadowCameraUpdate] state. + */ +@Implements(CameraUpdateFactory::class) +class NavShadowCameraUpdateFactory { + companion object { + private fun createUpdate(configure: (ShadowCameraUpdate) -> Unit): CameraUpdate { + val update = NavDelegateReflection.constructWithDummyDelegate(CameraUpdate::class.java) + val shadow = Shadow.extract(update) + configure(shadow) + return update + } + + @Implementation + @JvmStatic + fun newCameraPosition(cameraPosition: CameraPosition): CameraUpdate = createUpdate { shadow -> + shadow.cameraPosition = cameraPosition + } + + @Implementation + @JvmStatic + fun newLatLng(latLng: LatLng): CameraUpdate = createUpdate { shadow -> + shadow.latLng = latLng + } + + @Implementation + @JvmStatic + fun newLatLngZoom(latLng: LatLng, zoom: Float): CameraUpdate = createUpdate { shadow -> + shadow.latLng = latLng + shadow.zoom = zoom + } + + @Implementation + @JvmStatic + fun newLatLngBounds(bounds: LatLngBounds, padding: Int): CameraUpdate = createUpdate { shadow -> + shadow.setLatLngBounds(bounds, padding) + } + + @Implementation + @JvmStatic + fun newLatLngBounds( + bounds: LatLngBounds, + width: Int, + height: Int, + padding: Int, + ): CameraUpdate = createUpdate { shadow -> + shadow.setLatLngBounds(bounds, width, height, padding) + } + + @Implementation + @JvmStatic + fun zoomIn(): CameraUpdate = zoomBy(1.0f) + + @Implementation + @JvmStatic + fun zoomOut(): CameraUpdate = zoomBy(-1.0f) + + @Implementation + @JvmStatic + fun zoomTo(zoom: Float): CameraUpdate = createUpdate { shadow -> + shadow.zoom = zoom + } + + @Implementation + @JvmStatic + fun zoomBy(amount: Float): CameraUpdate = createUpdate { shadow -> + shadow.zoomBy = amount + } + + @Implementation + @JvmStatic + fun zoomBy(amount: Float, focus: Point): CameraUpdate = zoomBy(amount) + + @Implementation + @JvmStatic + fun scrollBy(xPixel: Float, yPixel: Float): CameraUpdate = createUpdate { shadow -> + shadow.setScroll(xPixel, yPixel) + } + } +} + +/** + * Shadow for [BitmapDescriptorFactory] that constructs [BitmapDescriptor] instances via Navigation + * SDK's single-argument delegate constructor and configures their [ShadowBitmapDescriptor]. + */ +@Implements(BitmapDescriptorFactory::class) +class NavShadowBitmapDescriptorFactory { + companion object { + const val HUE_RED = 0.0f + + private fun createDescriptor(bitmap: Bitmap?, identifier: String): BitmapDescriptor { + val descriptor = + NavDelegateReflection.constructWithDummyDelegate(BitmapDescriptor::class.java) + val shadow = Shadow.extract(descriptor) + shadow.bitmap = bitmap + shadow.identifier = identifier + return descriptor + } + + @Implementation + @JvmStatic + fun defaultMarker(): BitmapDescriptor = defaultMarker(HUE_RED) + + @Implementation + @JvmStatic + fun defaultMarker(hue: Float): BitmapDescriptor = + createDescriptor(null, "defaultMarker:hue=$hue") + + @Implementation + @JvmStatic + fun fromResource(resourceId: Int): BitmapDescriptor = + createDescriptor(null, "resource:$resourceId") + + @Implementation + @JvmStatic + fun fromBitmap(image: Bitmap?): BitmapDescriptor = + createDescriptor(image, "bitmap:${System.identityHashCode(image)}") + + @Implementation + @JvmStatic + fun fromAsset(assetName: String): BitmapDescriptor = + createDescriptor(null, "asset:$assetName") + + @Implementation + @JvmStatic + fun fromFile(fileName: String): BitmapDescriptor = + createDescriptor(null, "file:$fileName") + + @Implementation + @JvmStatic + fun fromPath(absolutePath: String): BitmapDescriptor = + createDescriptor(null, "path:$absolutePath") + } +} + +/** + * Robolectric shadow for [SupportNavigationFragment] so activities using the Navigation SDK flavor + * (`SupportNavigationFragment.getMapAsync(...)`) can be tested headlessly on the JVM alongside + * `SupportMapFragment`. + */ +@Implements(SupportNavigationFragment::class) +class ShadowSupportNavigationFragment { + @RealObject private lateinit var realFragment: SupportNavigationFragment + + var googleMap: GoogleMap? = null + private set + + private var pendingCallback: OnMapReadyCallback? = null + var isHeaderEnabledState: Boolean = true + private set + var isEtaCardEnabledState: Boolean = true + private set + + @Implementation + protected fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val view = FrameLayout(realFragment.requireContext()) + view.layout(0, 0, 400, 400) + pendingCallback?.let { cb -> + triggerOnMapReady(cb) + pendingCallback = null + } + return view + } + + @Implementation + fun getMapAsync(callback: OnMapReadyCallback) { + if (googleMap == null) { + googleMap = NavDelegateReflection.createGoogleMapInstance() + } + if (realFragment.view != null || realFragment.activity != null) { + triggerOnMapReady(callback) + } else { + pendingCallback = callback + } + } + + @Implementation + fun setHeaderEnabled(isEnabled: Boolean) { + isHeaderEnabledState = isEnabled + } + + @Implementation + fun setEtaCardEnabled(isEnabled: Boolean) { + isEtaCardEnabledState = isEnabled + } + + private fun triggerOnMapReady(callback: OnMapReadyCallback) { + if (googleMap == null) { + googleMap = NavDelegateReflection.createGoogleMapInstance() + } + val map = googleMap!! + Handler(Looper.getMainLooper()).post { callback.onMapReady(map) } + } +} + +/** Robolectric shadow for [NavigationView]. */ +@Implements(NavigationView::class) +class ShadowNavigationView : ShadowViewGroup() { + @RealObject private lateinit var realNavigationView: NavigationView + + var googleMap: GoogleMap? = null + private set + + @Implementation + protected fun __constructor__(context: Context) { + Shadow.invokeConstructor( + RelativeLayout::class.java, + realNavigationView, + ClassParameter.from(Context::class.java, context), + ) + } + + @Implementation + protected fun __constructor__(context: Context, attrs: AttributeSet?) { + Shadow.invokeConstructor( + RelativeLayout::class.java, + realNavigationView, + ClassParameter.from(Context::class.java, context), + ClassParameter.from(AttributeSet::class.java, attrs), + ) + } + + @Implementation + protected fun __constructor__(context: Context, attrs: AttributeSet?, defStyleAttr: Int) { + Shadow.invokeConstructor( + RelativeLayout::class.java, + realNavigationView, + ClassParameter.from(Context::class.java, context), + ClassParameter.from(AttributeSet::class.java, attrs), + ClassParameter.from(Int::class.javaPrimitiveType, defStyleAttr), + ) + } + + @Implementation fun onCreate(savedInstanceState: Bundle?) {} + + @Implementation fun onStart() {} + + @Implementation fun onResume() {} + + @Implementation fun onPause() {} + + @Implementation fun onStop() {} + + @Implementation fun onDestroy() {} + + @Implementation fun onSaveInstanceState(bundle: Bundle?) {} + + @Implementation + fun getMapAsync(callback: OnMapReadyCallback) { + if (googleMap == null) { + googleMap = NavDelegateReflection.createGoogleMapInstance() + } + callback.onMapReady(googleMap!!) + } +} + +/** Robolectric shadow for [NavigationApi]. */ +@Implements(NavigationApi::class) +class ShadowNavigationApi { + companion object { + @JvmStatic var mockNavigator: Navigator = Mockito.mock(Navigator::class.java) + + @Implementation + @JvmStatic + fun getNavigator(application: Application, listener: NavigatorListener) { + listener.onNavigatorReady(mockNavigator) + } + + @Implementation + @JvmStatic + fun getNavigator(activity: Activity, listener: NavigatorListener) { + listener.onNavigatorReady(mockNavigator) + } + + @Implementation + @JvmStatic + fun getNavigator(activity: FragmentActivity, listener: NavigatorListener) { + listener.onNavigatorReady(mockNavigator) + } + } +} diff --git a/map-sample/app/src/test/resources/goldens/circle_demo_sydney.png b/map-sample/app/src/test/resources/goldens/circle_demo_sydney.png new file mode 100644 index 0000000..e1579e4 Binary files /dev/null and b/map-sample/app/src/test/resources/goldens/circle_demo_sydney.png differ diff --git a/map-sample/app/src/test/resources/goldens/polygon_demo_australia.png b/map-sample/app/src/test/resources/goldens/polygon_demo_australia.png new file mode 100644 index 0000000..0bac98a Binary files /dev/null and b/map-sample/app/src/test/resources/goldens/polygon_demo_australia.png differ diff --git a/map-sample/app/src/test/resources/goldens/tile_coordinate_demo.png b/map-sample/app/src/test/resources/goldens/tile_coordinate_demo.png new file mode 100644 index 0000000..70802fd Binary files /dev/null and b/map-sample/app/src/test/resources/goldens/tile_coordinate_demo.png differ diff --git a/map-sample/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/map-sample/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..d71325d --- /dev/null +++ b/map-sample/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-proxy diff --git a/map-sample/build.gradle.kts b/map-sample/build.gradle.kts index d9f89f7..acc657a 100644 --- a/map-sample/build.gradle.kts +++ b/map-sample/build.gradle.kts @@ -18,6 +18,8 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.power.assert) apply false alias(libs.plugins.maps.secrets) apply false } diff --git a/map-sample/gradle/libs.versions.toml b/map-sample/gradle/libs.versions.toml index 74516c8..67c0831 100644 --- a/map-sample/gradle/libs.versions.toml +++ b/map-sample/gradle/libs.versions.toml @@ -1,11 +1,17 @@ [versions] agp = "9.2.0" androidxLifecycle = "2.10.0" +androidxTestCore = "1.7.0" +androidxTestEspresso = "3.7.0" +androidxTestExt = "1.3.0" +androidxTestRules = "1.7.0" +androidxTestRunner = "1.7.0" annotation = "1.10.0" appcompat = "1.8.0" autoValue = "1.11.1" cardview = "1.0.0" constraintlayout = "2.2.2" +coroutines = "1.11.0" cronetFallback = "143.7445.0" customview = "1.2.0" datatransport = "4.1.1" @@ -14,16 +20,23 @@ errorProneAnnotations = "2.51.0" glide = "5.0.9" guava = "33.5.0-android" jodaTime = "2.15.0" -kotlin = "2.3.0" +junit = "4.13.2" +# Aligned with android-maps-testing (1.0.0-rc01) and org.jetbrains.kotlin.plugin.power-assert. +kotlin = "2.4.21" leakcanary = "2.14" legacySupportV4 = "1.0.0" +mapsTesting = "1.0.0-rc01" material = "1.14.0" mediarouter = "1.8.1" +mockito = "5.23.0" navSdk = "8.0.0" preference = "1.2.1" recyclerview = "1.4.0" +robolectric = "4.17" secretsPlugin = "2.0.1" transition = "1.7.2" +truth = "1.4.5" +uiautomator = "2.4.0" viewpager2 = "1.1.0" window = "1.5.1" @@ -39,7 +52,13 @@ androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-p androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "mediarouter" } androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxTestEspresso" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-transition = { group = "androidx.transition", name = "transition", version.ref = "transition" } +androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" } androidx-window = { group = "androidx.window", name = "window", version.ref = "window" } auto-value-annotations = { group = "com.google.auto.value", name = "auto-value-annotations", version.ref = "autoValue" } @@ -56,9 +75,19 @@ google-maps-navigation = { group = "com.google.android.libraries.navigation", na google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } guava = { group = "com.google.guava", name = "guava", version.ref = "guava" } joda-time = { group = "joda-time", name = "joda-time", version.ref = "jodaTime" } +junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" } +maps-testing-golden = { group = "com.google.maps.android", name = "maps-testing-golden", version.ref = "mapsTesting" } +maps-testing-robolectric = { group = "com.google.maps.android", name = "maps-testing-robolectric", version.ref = "mapsTesting" } +maps-testing-visual = { group = "com.google.maps.android", name = "maps-testing-visual", version.ref = "mapsTesting" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-power-assert = { id = "org.jetbrains.kotlin.plugin.power-assert", version.ref = "kotlin" } maps-secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" } diff --git a/navigation-sample/app/build.gradle.kts b/navigation-sample/app/build.gradle.kts index 8a47608..81d306e 100644 --- a/navigation-sample/app/build.gradle.kts +++ b/navigation-sample/app/build.gradle.kts @@ -14,13 +14,29 @@ * limitations under the License. */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.powerassert.gradle.PowerAssertCompilationFilter plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.power.assert) alias(libs.plugins.maps.secrets) } +@OptIn(ExperimentalKotlinGradlePluginApi::class) +powerAssert { + functions = listOf( + "kotlin.assert", + "kotlin.require", + "kotlin.check", + "com.google.maps.android.robolectric.powerassert.assertGeo", + "com.google.maps.android.robolectric.powerassert.assertMap", + "com.google.maps.android.robolectric.powerassert.assertMarker", + ) + compilationFilter = PowerAssertCompilationFilter.ALL +} + android { namespace = "com.example.navigationapidemo" compileSdk = 37 @@ -36,6 +52,21 @@ android { targetSdk = 37 versionCode = 1 versionName = "1.0" + manifestPlaceholders["MAPS_API_KEY"] = "DEFAULT_API_KEY" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { + it.systemProperty( + "maps.golden.record", + System.getProperty("maps.golden.record") ?: "false", + ) + } + } } androidResources { @@ -124,6 +155,29 @@ dependencies { implementation(libs.guava) coreLibraryDesugaring(libs.desugar.jdk.libs) + + // Unit tests: JUnit, Kotlin Power-Assert, Google Truth, Robolectric, and android-maps-testing + testImplementation(libs.junit) + testImplementation(libs.truth) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockito.core) + testImplementation(libs.maps.testing.robolectric) + testImplementation(libs.maps.testing.golden) + + // Instrumented & visual tests + androidTestImplementation(libs.junit) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.maps.testing.golden) + androidTestImplementation(libs.maps.testing.visual) } secrets { diff --git a/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationSampleSmokeTest.kt b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationSampleSmokeTest.kt new file mode 100644 index 0000000..bdb083d --- /dev/null +++ b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationSampleSmokeTest.kt @@ -0,0 +1,74 @@ +/* + * 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.example.navigationapidemo + +import android.content.Context +import android.content.pm.PackageManager +import android.view.View +import android.widget.ListView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Connected-device instrumentation smoke tests for the `navigation-sample` application. + * + * Sitting near the top of the testing pyramid, this compact suite verifies that: + * 1. All 5 navigation sample activities are properly declared in `AndroidManifest.xml` and + * resolvable by the Android `PackageManager` on a real device or emulator. + * 2. The launcher (`MainActivity`) inflates its layout and populates the chooser list with all 3 + * navigation demo entries cleanly without crashing. + */ +@RunWith(AndroidJUnit4::class) +class NavigationSampleSmokeTest { + + @Test + fun manifest_declaresAllNavigationSampleActivities() { + val context = ApplicationProvider.getApplicationContext() + val packageInfo = + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES) + val declaredClassNames = + packageInfo.activities?.map { activityInfo -> activityInfo.name }.orEmpty() + + assertThat(declaredClassNames) + .containsAtLeast( + MainActivity::class.java.name, + SplashScreenActivity::class.java.name, + NavViewActivity::class.java.name, + NavFragmentActivity::class.java.name, + SwappingMapAndNavActivity::class.java.name, + ) + } + + @Test + fun mainActivity_launchesAndDisplaysChooserList() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val listView = activity.findViewById(R.id.list_view) + assertThat(listView).isNotNull() + assertThat(listView.visibility).isEqualTo(View.VISIBLE) + assertThat(listView.adapter.count).isEqualTo(3) + assertThat(listView.adapter.getItem(0)).isEqualTo("NavViewActivity") + assertThat(listView.adapter.getItem(1)).isEqualTo("NavFragmentActivity") + assertThat(listView.adapter.getItem(2)).isEqualTo("SwappingMapAndNavActivity") + } + } + } +} diff --git a/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationVisualTest.kt b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationVisualTest.kt new file mode 100644 index 0000000..17f6bfd --- /dev/null +++ b/navigation-sample/app/src/androidTest/java/com/example/navigationapidemo/NavigationVisualTest.kt @@ -0,0 +1,54 @@ +/* + * 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.example.navigationapidemo + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.maps.android.testing.visual.BaseVisualTest +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Top-of-pyramid multimodal AI visual and hybrid golden test for `navigation-sample`. + * + * Extends [BaseVisualTest] from `com.google.maps.android:maps-testing-visual` to verify the visual + * presentation of the launcher chooser screen using `verifyScreenshotWithGoldenFallback`: + * - When a committed golden exists under `src/androidTest/assets/goldens/`, compares against it + * offline with zero API quota usage. + * - When visual drift exceeds tolerance or when a golden is not yet recorded and `GEMINI_API_KEY` + * is configured, falls back to Gemini Vision semantic verification. + */ +@RunWith(AndroidJUnit4::class) +class NavigationVisualTest : BaseVisualTest() { + + @Test + fun mainActivity_rendersNavigationSampleChooserScreen() = runBlocking { + ActivityScenario.launch(MainActivity::class.java).use { + val screenshotBitmap = captureScreenshot("navigation_sample_main_chooser.png") + verifyScreenshotWithGoldenFallback( + testName = "navigation_sample_main_chooser", + screenshotBitmap = screenshotBitmap, + prompt = + "Verify that the Google Maps Navigation API Demo launcher screen is visible with " + + "entries for NavViewActivity, NavFragmentActivity, and SwappingMapAndNavActivity, " + + "and reply PASSED if all criteria are met.", + tolerancePercent = 2.0, + ) + } + } +} diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt b/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt index 544a3e1..6e3e419 100644 --- a/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt +++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/CustomizationPanelsDelegate.kt @@ -128,7 +128,7 @@ internal object CustomizationPanelsDelegate { nightModeSpinner.adapter = nightModeAdapter nightModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { when (position) { 0 -> {} 1 -> onNightModeOptionSelected(ForceNightMode.AUTO) @@ -172,7 +172,7 @@ internal object CustomizationPanelsDelegate { override var lastSetNonZeroPosition = 1 private set - override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { when (position) { 0 -> {} 1 -> { diff --git a/navigation-sample/app/src/test/java/com/example/navigationapidemo/CustomizationPanelsDelegateTest.kt b/navigation-sample/app/src/test/java/com/example/navigationapidemo/CustomizationPanelsDelegateTest.kt new file mode 100644 index 0000000..01bedd6 --- /dev/null +++ b/navigation-sample/app/src/test/java/com/example/navigationapidemo/CustomizationPanelsDelegateTest.kt @@ -0,0 +1,306 @@ +/* + * 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.example.navigationapidemo + +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Spinner +import android.widget.ToggleButton +import androidx.appcompat.app.AppCompatActivity +import com.example.navigationapidemo.testing.NavMapsRobolectricTest +import com.example.navigationapidemo.testing.NavShadowGoogleMap +import com.example.navigationapidemo.testing.NavShadowNavigationApi +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.android.libraries.navigation.ForceNightMode +import com.google.android.libraries.navigation.Navigator +import com.google.android.libraries.navigation.OnNavigationUiChangedListener +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.robolectric.ktx.cameraBearing +import com.google.maps.android.robolectric.ktx.cameraTarget +import com.google.maps.android.robolectric.ktx.cameraZoom +import com.google.maps.android.robolectric.ktx.createTestMap +import com.google.maps.android.robolectric.powerassert.assertGeo +import com.google.maps.android.robolectric.powerassert.assertMap +import com.google.maps.android.robolectric.powerassert.distanceTo +import com.google.maps.android.robolectric.powerassert.isCenteredOn +import com.google.maps.android.robolectric.powerassert.meters +import com.google.maps.android.robolectric.truth.assertThat as assertThatMap +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadow.api.Shadow + +/** + * Headless JVM Robolectric unit tests for [CustomizationPanelsDelegate]. + * + * Verifies mutual exclusion of control panels, spinner callbacks for night mode and camera + * perspectives, bidirectional synchronization with [GoogleMap] location-following state, and + * camera movement to Melbourne using both **Kotlin Power-Assert** (`assertMap`, `assertGeo`) and + * **Google Truth** (`assertThatMap`). + */ +@RunWith(RobolectricTestRunner::class) +class CustomizationPanelsDelegateTest : NavMapsRobolectricTest() { + + private lateinit var activity: AppCompatActivity + private lateinit var googleMap: GoogleMap + + @Before + fun setUp() { + NavShadowNavigationApi.reset() + NavShadowNavigationApi.autoDeliverResult = false + activity = Robolectric.buildActivity(NavViewActivity::class.java).create().get() + googleMap = createTestMap(width = 400, height = 400) + } + + @After + fun tearDown() { + NavShadowNavigationApi.reset() + } + + @Test + fun initializeCustomizationPanels_enforcesMutualExclusionAcrossThreePanels() { + val navToggle = activity.findViewById