From fbc5cb11e11769bccfbdcf1f98fdaf394f821d21 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 18:02:54 -0700 Subject: [PATCH 1/2] Test that a ground image survives a map design change Instrumented: a real MapView loads two local style documents through setMapDesignType, and the ground image is checked in the new style (unchanged image, same design set again, removal after the change). The styles have no sources, so a placeholder access token is enough. Android is not affected by the iOS bug (ios-for-maplibre#12): the style-loaded subscription calls groundImageController.reapplyStyle(). Removing that call makes all three fail. --- build.gradle.kts | 6 + .../MapboxGroundImageStyleChangeTest.kt | 142 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/androidTest/java/com/mapconductor/mapbox/MapboxGroundImageStyleChangeTest.kt 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/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 + } +} From 97ca8154647ba29b932ccf0d784d940f571bdf3e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 26 Oct 2026 19:12:46 -0700 Subject: [PATCH 2/2] Declare the test libraries in this repo's own catalog CI builds the repository on its own, against gradle/libs.versions.toml here, which had no androidx-runner or ui-test entries; they resolved only in the android-sdk monorepo's catalog the tests were first built with. Compose test artifacts follow this repo's composeLibs (1.12.1). --- gradle/libs.versions.toml | 4 ++++ 1 file changed, 4 insertions(+) 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" }