diff --git a/build.gradle.kts b/build.gradle.kts index 7b581a1..8071159 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -93,6 +93,12 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + // A real MapView loading real (local) styles: what a design change does to + // the overlays only shows on a map that actually swaps its style. + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.test.manifest) } // Set project version for NMCP plugin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fed587d..65aa5f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ ktLint = "14.2.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +runner = "1.7.0" [libraries] # Jetpack Compose (with BOM) @@ -42,6 +43,9 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", vers junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "composeLibs" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeLibs" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt b/src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt new file mode 100644 index 0000000..3ad3192 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt @@ -0,0 +1,142 @@ +package com.mapconductor.mapbox + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.mapbox.common.MapboxOptions +import com.mapbox.maps.Style +import com.mapconductor.compose.groundimage.GroundImage +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.map.AttributionRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A ground image survives the map design changing under it. + * + * Replacing the style throws away every source and layer added at runtime, so + * the SDK has to put the image back into the new style. iOS kept the handle it + * had made for the old style and skipped an unchanged image (ios-for-maplibre + * #12); this checks the Android path — `setMapDesignType` → `loadStyle` → + * `subscribeStyleLoaded` — on a real MapView loading two local style documents. + * + * The styles have no sources, so nothing is fetched and no real access token + * is needed; a placeholder satisfies the SDK's check. + */ +@RunWith(AndroidJUnit4::class) +class MapboxGroundImageStyleChangeTest { + @get:Rule + val compose = createComposeRule() + + private class LocalStyle(override val id: String) : MapboxDesignType { + override val attributionRules: List = emptyList() + + override fun getValue(): String = + """{"version": 8, "name": "$id", "sources": {}, + "layers": [{"id": "background-$id", "type": "background"}]}""" + } + + private val first = LocalStyle("first") + private val second = LocalStyle("second") + + private fun groundImage(): GroundImageState { + val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources + return GroundImageState( + bounds = GeoRectBounds(GeoPoint(35.6, 139.6), GeoPoint(35.8, 139.9)), + image = BitmapDrawable(resources, bitmap), + id = "tokyo", + ) + } + + /** The loaded style, when it is the one the given design describes. */ + private fun styleOf(state: MapboxViewState, design: LocalStyle): Style? { + var style: Style? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + style = state.getMapViewHolder()?.map?.style + ?.takeIf { it.isStyleLoaded() && it.styleLayerExists("background-${design.id}") } + } + return style + } + + private fun hasGroundImage(style: Style?): Boolean { + var found = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + found = style != null && + style.styleSourceExists("mc-gimg-src-tokyo") && + style.styleLayerExists("mc-gimg-lyr-tokyo") + } + return found + } + + private var shown by mutableStateOf(true) + + private fun show(state: MapboxViewState, image: GroundImageState) { + compose.setContent { + MapboxMapView( + state = state, + modifier = Modifier.fillMaxSize(), + sdkInitialize = { + MapboxOptions.accessToken = "pk.placeholder-for-local-styles" + true + }, + ) { + if (shown) GroundImage(image) + } + } + } + + @Test + fun anUnchangedGroundImageIsInTheNewStyle() { + val state = MapboxViewState(mapDesignType = first, id = "unchanged") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { styleOf(state, second) != null } + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, second)) } + } + + @Test + fun theSameDesignSetAgainKeepsTheImage() { + val state = MapboxViewState(mapDesignType = first, id = "same") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + + compose.runOnUiThread { state.mapDesignType = first } + Thread.sleep(SETTLE_MS) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + } + + @Test + fun aGroundImageRemovedAfterTheChangeLeavesTheNewStyle() { + val state = MapboxViewState(mapDesignType = first, id = "removed") + show(state, groundImage()) + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, first)) } + + compose.runOnUiThread { state.mapDesignType = second } + compose.waitUntil(TIMEOUT) { hasGroundImage(styleOf(state, second)) } + + compose.runOnUiThread { shown = false } + compose.waitUntil(TIMEOUT) { + val style = styleOf(state, second) + style != null && !hasGroundImage(style) + } + } + + private companion object { + const val TIMEOUT = 20_000L + const val SETTLE_MS = 1_500L + } +}