Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,42 +14,59 @@

package com.google.maps.android.compose

import android.content.pm.ActivityInfo
import android.graphics.Point
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.common.truth.Truth.assertThat
import com.google.maps.android.ImperialUnitsConverter
import com.google.maps.android.MetricUnitsConverter
import com.google.maps.android.NumberFormatSymbols
import com.google.maps.android.compose.widgets.ScaleBar
import com.google.maps.android.meters
import com.google.maps.android.miles
import com.google.maps.android.sphericalDistance
import com.google.maps.android.toDistanceString
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.math.abs

// These constants are used for converting between metric and imperial units
// to ensure the scale bar displays distances correctly in both systems.
private const val CENTIMETERS_IN_METER: Double = 100.0
private const val METERS_IN_KILOMETER: Double = 1000.0
private const val CENTIMETERS_IN_INCH: Double = 2.54
private const val INCHES_IN_FOOT: Double = 12.0
private const val FEET_IN_MILE: Double = 5280.0
private val scaleBarImperialConverter = ImperialUnitsConverter(milesThreshold = 1.miles)

class ScaleBarTests {

@get:Rule
val composeTestRule = createComposeRule()
val composeTestRule = createAndroidComposeRule<ComponentActivity>()

private lateinit var cameraPositionState: CameraPositionState
private lateinit var density: Density

private fun initScaleBar(initialZoom: Float, initialPosition: LatLng) {
@After
fun tearDown() {
runCatching {
composeTestRule.activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}

private fun initScaleBar(initialZoom: Float, initialPosition: LatLng, measureAtCenter: Boolean = true) {
assumeValidApiKey()

val countDownLatch = CountDownLatch(1)
Expand All @@ -67,7 +84,10 @@ class ScaleBarTests {
countDownLatch.countDown()
},
)
ScaleBar(cameraPositionState = cameraPositionState)
ScaleBar(
cameraPositionState = cameraPositionState,
measureAtCenter = measureAtCenter,
)
}
}
val mapLoaded = countDownLatch.await(MAP_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)
Expand All @@ -89,45 +109,210 @@ class ScaleBarTests {
assertScaleBarLabels(initialZoom = 10f)
}

private fun assertScaleBarLabels(initialZoom: Float) {
val initialPosition = LatLng(37.7749, -122.4194) // San Francisco
initScaleBar(initialZoom, initialPosition)
@Test
fun testScaleBarUpdatesWhenLatitudeChangesAtSameZoom() {
val equator = LatLng(0.0, 0.0)
val iceland = LatLng(64.9631, -19.0208)
initScaleBar(initialZoom = 10f, initialPosition = equator)
composeTestRule.waitForIdle()

val (_, equatorMetric, equatorMeters) = computeExpectedLabels()
composeTestRule.onNodeWithText(equatorMetric).assertExists()

composeTestRule.runOnIdle {
cameraPositionState.move(CameraUpdateFactory.newLatLngZoom(iceland, 10f))
}
composeTestRule.waitUntil(timeoutMillis = 5_000) {
!cameraPositionState.isMoving &&
abs(cameraPositionState.position.target.latitude - iceland.latitude) < 0.1
}
composeTestRule.waitForIdle()

val (icelandImperial, icelandMetric, icelandMeters) = computeExpectedLabels()
assertThat(icelandMeters).isLessThan(equatorMeters / 2)
composeTestRule.onNodeWithText(icelandImperial).assertExists()
composeTestRule.onNodeWithText(icelandMetric).assertExists()
}

@Test
fun testScaleBarAcrossDeviceRotation() {
val iceland = LatLng(64.9631, -19.0208)
composeTestRule.activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()

initScaleBar(initialZoom = 5f, initialPosition = iceland)
composeTestRule.waitForIdle()

val (portraitImperial, portraitMetric, portraitMeters) = computeExpectedLabels()
composeTestRule.onNodeWithText(portraitImperial).assertExists()
composeTestRule.onNodeWithText(portraitMetric).assertExists()

// Rotate the device to landscape
composeTestRule.activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
composeTestRule.waitForIdle()

val (landscapeImperial, landscapeMetric, landscapeMeters) = computeExpectedLabels()
assertThat(landscapeImperial).isEqualTo(portraitImperial)
assertThat(landscapeMetric).isEqualTo(portraitMetric)
assertThat(abs(landscapeMeters - portraitMeters)).isAtMost(5)
composeTestRule.onNodeWithText(landscapeImperial).assertExists()
composeTestRule.onNodeWithText(landscapeMetric).assertExists()

// Rotate the device back to portrait
composeTestRule.activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
composeTestRule.waitForIdle()

val (restoredImperial, restoredMetric, restoredMeters) = computeExpectedLabels()
assertThat(restoredImperial).isEqualTo(portraitImperial)
assertThat(restoredMetric).isEqualTo(portraitMetric)
assertThat(abs(restoredMeters - portraitMeters)).isAtMost(5)
composeTestRule.onNodeWithText(restoredImperial).assertExists()
composeTestRule.onNodeWithText(restoredMetric).assertExists()
}

@Test
fun testScaleBarAcrossMapBearingRotation() {
val iceland = LatLng(64.9631, -19.0208)
initScaleBar(initialZoom = 10f, initialPosition = iceland)
composeTestRule.waitForIdle()

val (northUpImperial, northUpMetric, northUpMeters) = computeExpectedLabels()
composeTestRule.onNodeWithText(northUpImperial).assertExists()
composeTestRule.onNodeWithText(northUpMetric).assertExists()

for (bearing in listOf(45f, 90f, 180f, 270f)) {
composeTestRule.runOnIdle {
cameraPositionState.move(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(iceland)
.zoom(10f)
.bearing(bearing)
.build(),
),
)
}
composeTestRule.waitUntil(timeoutMillis = 5_000) {
!cameraPositionState.isMoving &&
abs(cameraPositionState.position.bearing - bearing) < 1f
}
composeTestRule.waitForIdle()

val (rotatedImperial, rotatedMetric, rotatedMeters) = computeExpectedLabels()
assertThat(rotatedImperial).isEqualTo(northUpImperial)
assertThat(rotatedMetric).isEqualTo(northUpMetric)
assertThat(abs(rotatedMeters - northUpMeters)).isAtMost(10)
composeTestRule.onNodeWithText(rotatedImperial).assertExists()
composeTestRule.onNodeWithText(rotatedMetric).assertExists()
}
}

@Test
fun testScaleBarActivitySurvivesDeviceRotation() {
assumeValidApiKey()
ActivityScenario.launch(ScaleBarActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
composeTestRule.waitUntil(timeoutMillis = MAP_LOAD_TIMEOUT_SECONDS * 1_000) {
composeTestRule.onAllNodesWithText("4.4 km").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("2.7 mi").fetchSemanticsNodes().isNotEmpty()
}

scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
composeTestRule.waitUntil(timeoutMillis = MAP_LOAD_TIMEOUT_SECONDS * 1_000) {
composeTestRule.onAllNodesWithText("4.4 km").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("2.7 mi").fetchSemanticsNodes().isNotEmpty()
}

scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
composeTestRule.waitUntil(timeoutMillis = MAP_LOAD_TIMEOUT_SECONDS * 1_000) {
composeTestRule.onAllNodesWithText("4.4 km").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("2.7 mi").fetchSemanticsNodes().isNotEmpty()
}
}
}

@Test
fun testScaleBarLegacyTopLeftMeasurementWhenMeasureAtCenterIsFalse() {
val iceland = LatLng(64.9631, -19.0208)
initScaleBar(initialZoom = 5f, initialPosition = iceland, measureAtCenter = false)
composeTestRule.waitForIdle()

val (legacyImperial, legacyMetric, legacyMeters) = computeExpectedLabels(measureAtCenter = false)
val (_, _, centerMeters) = computeExpectedLabels(measureAtCenter = true)
assertThat(legacyMeters).isLessThan(centerMeters)
composeTestRule.onNodeWithText(legacyImperial).assertExists()
composeTestRule.onNodeWithText(legacyMetric).assertExists()
}

private fun computeExpectedLabels(measureAtCenter: Boolean = true): Triple<String, String, Int> {
var imperialText = ""
var metricText = ""
var meters = 0

composeTestRule.runOnIdle {
// We use a `let` block to safely handle the projection, which can be null.
// If the projection is null, the test will fail explicitly, preventing
// any potential NullPointerExceptions and ensuring the test is robust.
val projection = cameraPositionState.projection
projection?.let { proj ->
val widthInDp = 65.dp
val widthInPixels = with(density) {
widthInDp.toPx().toInt()
}

val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0))
val upperRightLatLng = proj.fromScreenLocation(Point(widthInPixels, 0))
val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng)
val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt()

metricText = if (horizontalLineWidthMeters >= METERS_IN_KILOMETER) {
"${formatTestDistance(horizontalLineWidthMeters / METERS_IN_KILOMETER)} km"
} else {
"$horizontalLineWidthMeters m"
}
val feet = horizontalLineWidthMeters.toDouble().toFeet()
imperialText = if (feet >= FEET_IN_MILE) {
"${formatTestDistance(feet.toMiles())} mi"
val startPoint: Point
val endPoint: Point
if (measureAtCenter) {
val centerPoint = proj.toScreenLocation(cameraPositionState.position.target)
val halfWidthInPixels = widthInPixels / 2
startPoint = Point(centerPoint.x - halfWidthInPixels, centerPoint.y)
endPoint = Point(startPoint.x + widthInPixels, centerPoint.y)
} else {
"${feet.roundToInt()} ft"
startPoint = Point(0, 0)
endPoint = Point(widthInPixels, 0)
}
val startLatLng = proj.fromScreenLocation(startPoint)
val endLatLng = proj.fromScreenLocation(endPoint)
val canvasWidthMeters = startLatLng.sphericalDistance(endLatLng)
val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt()
meters = horizontalLineWidthMeters

metricText = horizontalLineWidthMeters.meters.toDistanceString(
MetricUnitsConverter,
NumberFormatSymbols.COMPACT,
)
imperialText = horizontalLineWidthMeters.meters.toDistanceString(
scaleBarImperialConverter,
NumberFormatSymbols.COMPACT,
)
} ?: fail("Projection should not be null")
}

return Triple(imperialText, metricText, meters)
}

private fun assertScaleBarLabels(initialZoom: Float) {
val initialPosition = LatLng(37.7749, -122.4194) // San Francisco
initScaleBar(initialZoom, initialPosition)

composeTestRule.waitForIdle()

val (imperialText, metricText, _) = computeExpectedLabels()

composeTestRule.onNodeWithText(
text = imperialText,
).assertExists()
Expand All @@ -136,13 +321,3 @@ class ScaleBarTests {
).assertExists()
}
}

internal fun Double.toFeet(): Double = this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT

internal fun Double.toMiles(): Double = this / FEET_IN_MILE

private fun formatTestDistance(distance: Double): String {
if (distance >= 10.0) return distance.roundToInt().toString()
val scaled = (distance * 10.0).roundToInt()
return if (scaled % 10 == 0) "${scaled / 10}" else "${scaled / 10}.${scaled % 10}"
}
4 changes: 3 additions & 1 deletion maps-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@
android:exported="true" />

<!-- Used by createComponentActivity() for unit testing -->
<activity android:name="androidx.activity.ComponentActivity" />
<activity
android:name="androidx.activity.ComponentActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This applies to every test in maps-app that uses createComposeRule(), not only the scale bar ones: none of them will be recreated on rotation anymore. Could we use a dedicated test activity with these configChanges for the in-place rotation test instead?


</application>
</manifest>
Loading
Loading