From d06f011f82a41b0e2d4aea7e23c09995cc193468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 6 Nov 2026 11:47:07 +0100 Subject: [PATCH 1/3] fix(visual): harden the Gemini golden fallback --- .agents/skills/android-maps-testing/SKILL.md | 3 +- .../assets/templates/VisualMapTestTemplate.kt | 1 - .../ai_visual_testing_and_gemini.md | 23 +++-- .agents/skills/maps-visual-testing/SKILL.md | 15 ++- README.md | 5 +- .../VisualMapSampleTest.kt | 1 - gradle/libs.versions.toml | 1 + visual-testing/README.md | 22 +++-- visual-testing/api/visual-testing.api | 3 +- visual-testing/build.gradle.kts | 1 + .../android/testing/visual/BaseVisualTest.kt | 40 ++++++-- .../testing/visual/GeminiVisualTestHelper.kt | 49 +++++++--- .../android/testing/visual/VisualVerdict.kt | 29 ++++++ .../visual/GeminiModelResolutionTest.kt | 98 ++++++++++++++++--- .../testing/visual/GoldenFallbackLogicTest.kt | 78 +++++++++++++++ .../testing/visual/MockGeminiClient.kt | 40 ++++++++ .../testing/visual/VisualVerdictTest.kt | 59 +++++++++++ 17 files changed, 400 insertions(+), 68 deletions(-) create mode 100644 visual-testing/src/main/java/com/google/maps/android/testing/visual/VisualVerdict.kt create mode 100644 visual-testing/src/test/java/com/google/maps/android/testing/visual/MockGeminiClient.kt create mode 100644 visual-testing/src/test/java/com/google/maps/android/testing/visual/VisualVerdictTest.kt diff --git a/.agents/skills/android-maps-testing/SKILL.md b/.agents/skills/android-maps-testing/SKILL.md index 93e7dea..40e1cee 100644 --- a/.agents/skills/android-maps-testing/SKILL.md +++ b/.agents/skills/android-maps-testing/SKILL.md @@ -620,7 +620,7 @@ Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTes Extend `BaseVisualTest` and call `verifyScreenshotWithGoldenFallback(...)`. On clean emulators or CI, `BaseVisualTest` checks bundled test APK assets (`src/androidTest/assets/goldens/.png`) first for an instant zero-quota fast pass, and only consults Gemini Vision when no baseline exists yet or pixel diff exceeds `tolerancePercent`: -Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L72) +Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) ```kotlin // [START maps_visual_test_sample] @@ -663,7 +663,6 @@ class VisualMapSampleTest : BaseVisualTest() { testName = "store_locator_initial_state", screenshotBitmap = screenshotBitmap, prompt = prompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, ) } } diff --git a/.agents/skills/android-maps-testing/assets/templates/VisualMapTestTemplate.kt b/.agents/skills/android-maps-testing/assets/templates/VisualMapTestTemplate.kt index fe471c6..27e0d56 100644 --- a/.agents/skills/android-maps-testing/assets/templates/VisualMapTestTemplate.kt +++ b/.agents/skills/android-maps-testing/assets/templates/VisualMapTestTemplate.kt @@ -69,7 +69,6 @@ class VisualMapTestTemplate : BaseVisualTest() { testName = "map_visual_integrity_baseline", screenshotBitmap = screenshot, prompt = verificationPrompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, tolerancePercent = 0.5, updateGoldenOnPass = true, ) diff --git a/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md b/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md index 9d1f6e0..d98ad9b 100644 --- a/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md +++ b/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md @@ -39,13 +39,15 @@ Traditional screenshot testing fails on complex map apps for multiple reasons: --- -## 2. Dynamic Gemini Flash Model Discovery +## 2. Gemini Model Selection -Instead of hardcoding a model string that becomes deprecated or obsolete over time, `GeminiVisualTestHelper` dynamically queries `https://generativelanguage.googleapis.com/v1beta/models` on first run to resolve the newest stable `flash` model (e.g. `gemini-2.5-flash`). +`GeminiVisualTestHelper` uses the pinned `DEFAULT_FALLBACK_MODEL` unless a model is configured, so a new model release cannot change test verdicts on its own. Opting into `latest` queries `https://generativelanguage.googleapis.com/v1beta/models` once per session for the newest stable `flash` model and caches it in memory. The API key is sent in the `x-goog-api-key` header, never in the URL. -Once resolved, the model name is cached in-memory across the entire test session. You can also override the model: +Model resolution order: - Explicit parameter: `GeminiVisualTestHelper(preferredModel = "gemini-2.5-flash")` - System property: `-Dgemini.model="gemini-2.5-flash"` +- Latest stable Flash model, looked up once per session: `GeminiVisualTestHelper.LATEST_MODEL` or `-Dgemini.model=latest` +- Neither set: the pinned `GeminiVisualTestHelper.DEFAULT_FALLBACK_MODEL`, without a network call --- @@ -75,7 +77,7 @@ If no key is configured and no matching golden baseline is found, tests using Ge ``` 3. **Fast Zero-Quota CI & Fresh Emulator Runs**: On clean CI emulators, `BaseVisualTest` automatically loads `src/androidTest/assets/goldens/.png` from the test APK assets. When the screenshot matches within `tolerancePercent`, the test passes in milliseconds with **zero Gemini API calls** (and doesn't even require `GEMINI_API_KEY` to be set). -Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L147-L218) +Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L160-L237) ```kotlin // [START maps_visual_golden_fallback] @@ -83,7 +85,7 @@ Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../ testName: String, screenshotBitmap: Bitmap, prompt: String, - passCondition: (String) -> Boolean = { it.contains("PASSED", ignoreCase = true) }, + passCondition: (String) -> Boolean = { isPassedVerdict(it) }, goldensDirectory: File = File(context.getExternalFilesDir(null) ?: context.filesDir, "goldens"), tolerancePercent: Double = 0.5, updateGoldenOnPass: Boolean = true, @@ -132,7 +134,13 @@ Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../ passed, ) - if (updateGoldenOnPass) { + if (goldenBitmap != null) { + Log.w( + "BaseVisualTest", + "Gemini approved '$testName', but the screenshot differs from its golden baseline. The golden was " + + "not updated. If the change is intended, replace the golden with the new screenshot.", + ) + } else if (updateGoldenOnPass) { try { goldensDirectory.mkdirs() FileOutputStream(goldenFile).use { out -> @@ -156,7 +164,7 @@ Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../ ## 5. End-to-End Instrumented Test -Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L72) +Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) ```kotlin // [START maps_visual_test_sample] @@ -199,7 +207,6 @@ class VisualMapSampleTest : BaseVisualTest() { testName = "store_locator_initial_state", screenshotBitmap = screenshotBitmap, prompt = prompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, ) } } diff --git a/.agents/skills/maps-visual-testing/SKILL.md b/.agents/skills/maps-visual-testing/SKILL.md index 217f8e6..29f75c4 100644 --- a/.agents/skills/maps-visual-testing/SKILL.md +++ b/.agents/skills/maps-visual-testing/SKILL.md @@ -49,7 +49,7 @@ dependencies { ## 3. End-to-End Sample Test -Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L72) +Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) ```kotlin // [START maps_visual_test_sample] @@ -92,7 +92,6 @@ class VisualMapSampleTest : BaseVisualTest() { testName = "store_locator_initial_state", screenshotBitmap = screenshotBitmap, prompt = prompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, ) } } @@ -103,7 +102,7 @@ class VisualMapSampleTest : BaseVisualTest() { ## 4. Hybrid Golden Fallback Implementation Reference -Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L147-L218) +Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L160-L237) ```kotlin // [START maps_visual_golden_fallback] @@ -111,7 +110,7 @@ Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../ testName: String, screenshotBitmap: Bitmap, prompt: String, - passCondition: (String) -> Boolean = { it.contains("PASSED", ignoreCase = true) }, + passCondition: (String) -> Boolean = { isPassedVerdict(it) }, goldensDirectory: File = File(context.getExternalFilesDir(null) ?: context.filesDir, "goldens"), tolerancePercent: Double = 0.5, updateGoldenOnPass: Boolean = true, @@ -160,7 +159,13 @@ Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../ passed, ) - if (updateGoldenOnPass) { + if (goldenBitmap != null) { + Log.w( + "BaseVisualTest", + "Gemini approved '$testName', but the screenshot differs from its golden baseline. The golden was " + + "not updated. If the change is intended, replace the golden with the new screenshot.", + ) + } else if (updateGoldenOnPass) { try { goldensDirectory.mkdirs() FileOutputStream(goldenFile).use { out -> diff --git a/README.md b/README.md index 0dde411..6529f5d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This repository provides modular testing libraries tailored for different testin | :--- | :--- | :--- | | [**:robolectric-testing**](robolectric-testing) | **Robolectric Shadows & JVM Utilities**
High-fidelity shadows for `MapView`, `GoogleMap`, `SupportMapFragment`, markers, and shapes. Features visual canvas drawing, lifecycle simulation, 1-liner KTX test harness, **Kotlin Power-Assert** helpers, and **Google Truth** domain subjects. | [**Module README**](robolectric-testing/README.md) | | [**:golden-testing**](golden-testing) | **Deterministic Golden Image Testing**
Offline screenshot and visual regression testing using the real Maps SDK with synthetic, deterministic raster tiles (`MAP_TYPE_NONE`), LRU caching, and pixel diff assertions. | [**Module README**](golden-testing/README.md) | -| [**:visual-testing**](visual-testing) | **AI Visual Testing & Golden Fallback**
Multimodal AI visual validation powered by Gemini, UiAutomator integration, dynamic Flash model resolution, and hybrid golden pixel diff with AI semantic fallback. | [**Module README**](visual-testing/README.md) | +| [**:visual-testing**](visual-testing) | **AI Visual Testing & Golden Fallback**
Multimodal AI visual validation powered by Gemini, UiAutomator integration, a pinned Gemini model with opt-in latest Flash lookup, and hybrid golden pixel diff with AI semantic fallback. | [**Module README**](visual-testing/README.md) | | [**:app**](app) | **Sample Application**
Interactive demonstration app showcasing real-world usage of Google Maps SDK and testing integration. | [**App Directory**](app) | --- @@ -41,7 +41,7 @@ JVM execution without devices? fidelity asser - Validate polygon geometry v v - Assert with Power-Assert or Truth [:golden-testing] [:visual-testing] - 100% offline & zero quota - Natural language assertions - - Deterministic synthetic tiles - Dynamic Flash model caching + - Deterministic synthetic tiles - Pinned Gemini model - Strict pixel diff assertions - Hybrid golden fallback - UiAutomator AI actions ``` @@ -387,7 +387,6 @@ class VisualMapSampleTest : BaseVisualTest() { testName = "store_locator_initial_state", screenshotBitmap = screenshotBitmap, prompt = prompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, ) } } diff --git a/app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt b/app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt index e49b0fb..e4b0786 100644 --- a/app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt +++ b/app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt @@ -65,7 +65,6 @@ class VisualMapSampleTest : BaseVisualTest() { testName = "store_locator_initial_state", screenshotBitmap = screenshotBitmap, prompt = prompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b2ac37..b43866e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ truth = { module = "com.google.truth:truth", version.ref = "truth" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } diff --git a/visual-testing/README.md b/visual-testing/README.md index e561f31..0944d4a 100644 --- a/visual-testing/README.md +++ b/visual-testing/README.md @@ -38,12 +38,12 @@ Screenshot and visual testing of map applications has traditionally presented un ## Features -- **Dynamic Flash Model Resolution**: Automatically queries the Gemini API for the latest, stable Flash model (e.g. `gemini-3.8-flash`, `gemini-2.5-flash`) and caches the resolved model for the entire testing session, avoiding hardcoded or stale model names. +- **Pinned Model by Default**: Uses `GeminiVisualTestHelper.DEFAULT_FALLBACK_MODEL` so verdicts do not change when a new model ships. Set the model to `latest` to query the Gemini API for the newest stable Flash model instead, cached for the whole testing session. - **Hybrid Golden Fallback (`verifyScreenshotWithGoldenFallback`)**: - Runs a fast local pixel comparison first using `:golden-testing`'s `GoldenImageDiff`. - If pixel diff matches within tolerance, the test passes with **zero network latency and zero API quota**. - If pixel diff fails, it seamlessly falls back to Gemini Multimodal AI to verify semantic correctness and visual integrity. - - Optionally auto-updates baseline golden images when Gemini confirms the change is valid. + - Records a golden baseline on the first run when Gemini approves the screenshot. An existing golden that diverged is never overwritten, so a regression approved once by Gemini cannot become the new baseline. - **Multimodal Visual Inspection**: Send captured UI or map screenshots directly to Gemini with natural-language verification prompts (`analyzeImage` / `analyzeImageBlocking`). - **AI-Driven UI Automation (`performActionFromPrompt`)**: Generates and executes UiAutomator actions (click, scroll, type, wait) derived directly from the current view hierarchy and screenshot. - **Smart API Key Discovery**: Hierarchical resolution checks: @@ -149,7 +149,6 @@ class VisualMapSampleTest : BaseVisualTest() { testName = "store_locator_initial_state", screenshotBitmap = screenshotBitmap, prompt = prompt, - passCondition = { it.contains("PASSED", ignoreCase = true) }, ) } } @@ -175,7 +174,7 @@ class VisualMapSampleTest : BaseVisualTest() { testName: String, screenshotBitmap: Bitmap, prompt: String, - passCondition: (String) -> Boolean = { it.contains("PASSED", ignoreCase = true) }, + passCondition: (String) -> Boolean = { isPassedVerdict(it) }, goldensDirectory: File = File(context.getExternalFilesDir(null) ?: context.filesDir, "goldens"), tolerancePercent: Double = 0.5, updateGoldenOnPass: Boolean = true, @@ -224,7 +223,13 @@ class VisualMapSampleTest : BaseVisualTest() { passed, ) - if (updateGoldenOnPass) { + if (goldenBitmap != null) { + Log.w( + "BaseVisualTest", + "Gemini approved '$testName', but the screenshot differs from its golden baseline. The golden was " + + "not updated. If the change is intended, replace the golden with the new screenshot.", + ) + } else if (updateGoldenOnPass) { try { goldensDirectory.mkdirs() FileOutputStream(goldenFile).use { out -> @@ -248,16 +253,17 @@ class VisualMapSampleTest : BaseVisualTest() { ## Model Selection & Overrides -By default, `:visual-testing` queries `https://generativelanguage.googleapis.com/v1beta/models` on first run to find the latest stable `flash` model and caches it across all test cases. +By default, `:visual-testing` uses the pinned `GeminiVisualTestHelper.DEFAULT_FALLBACK_MODEL`, so test results only change when you upgrade the library or choose another model. The API key is sent in the `x-goog-api-key` header, never in the request URL. -You can explicitly force a specific model if desired: +You can select a model explicitly, or pass `latest` (`GeminiVisualTestHelper.LATEST_MODEL`) to query `https://generativelanguage.googleapis.com/v1beta/models` once per session for the newest stable Flash model: ```kotlin // In test code: -visualTestHelper.preferredModel = "gemini-3.8-flash" +val visualTestHelper = GeminiVisualTestHelper(preferredModel = "gemini-3.8-flash") // Or via system property / Gradle flag: // -Dgemini.model=gemini-2.5-pro +// -Dgemini.model=latest ``` --- diff --git a/visual-testing/api/visual-testing.api b/visual-testing/api/visual-testing.api index ac876ee..fc730a9 100644 --- a/visual-testing/api/visual-testing.api +++ b/visual-testing/api/visual-testing.api @@ -69,7 +69,7 @@ public abstract class com/google/maps/android/testing/visual/BaseVisualTest { public static synthetic fun captureScreenshot$default (Lcom/google/maps/android/testing/visual/BaseVisualTest;Ljava/lang/String;ILjava/lang/Object;)Landroid/graphics/Bitmap; protected final fun getContext ()Landroid/content/Context; protected final fun getGeminiApiKey ()Ljava/lang/String; - protected final fun getHelper ()Lcom/google/maps/android/testing/visual/GeminiVisualTestHelper; + protected fun getHelper ()Lcom/google/maps/android/testing/visual/GeminiVisualTestHelper; protected final fun getInstrumentation ()Landroid/app/Instrumentation; protected final fun getUiDevice ()Landroidx/test/uiautomator/UiDevice; protected fun loadGoldenBaseline (Ljava/lang/String;Ljava/io/File;)Landroid/graphics/Bitmap; @@ -204,6 +204,7 @@ public final class com/google/maps/android/testing/visual/GeminiResponse$Compani public final class com/google/maps/android/testing/visual/GeminiVisualTestHelper { public static final field Companion Lcom/google/maps/android/testing/visual/GeminiVisualTestHelper$Companion; public static final field DEFAULT_FALLBACK_MODEL Ljava/lang/String; + public static final field LATEST_MODEL Ljava/lang/String; public fun ()V public fun (Ljava/lang/String;Lio/ktor/client/HttpClient;)V public synthetic fun (Ljava/lang/String;Lio/ktor/client/HttpClient;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/visual-testing/build.gradle.kts b/visual-testing/build.gradle.kts index df8a237..d44ffde 100644 --- a/visual-testing/build.gradle.kts +++ b/visual-testing/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.truth) + testImplementation(libs.ktor.client.mock) testImplementation(libs.robolectric) testImplementation(libs.robolectric.shadows.framework) testImplementation(libs.androidx.test.core) diff --git a/visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt b/visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt index 68aeb17..902e230 100644 --- a/visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt +++ b/visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt @@ -43,14 +43,19 @@ import java.util.concurrent.TimeUnit * - High-level screenshot capture and decoding. * - [waitForMapRendering] to ensure map surfaces and vectors finish rendering before capture. * - [verifyScreenshotWithGoldenFallback]: Hybrid visual testing combining high-speed local pixel - * matching with Gemini AI fallback and automatic golden baseline updating. + * matching with Gemini AI fallback and first-run golden baseline recording. */ abstract class BaseVisualTest { protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() protected val uiDevice: UiDevice = UiDevice.getInstance(instrumentation) protected val context: Context = instrumentation.targetContext - protected val helper: GeminiVisualTestHelper = GeminiVisualTestHelper() + + /** + * Gemini client used by [verifyScreenshotWithGoldenFallback]. Override it to pin a model or to + * supply a custom HTTP client. + */ + protected open val helper: GeminiVisualTestHelper = GeminiVisualTestHelper() /** * Resolves the Gemini API key from various configuration sources. @@ -133,23 +138,31 @@ abstract class BaseVisualTest { * 2. If pixels match within [tolerancePercent], the test passes immediately without an API call. * 3. If the golden image is missing or the pixel comparison fails, falls back to Gemini Vision * with [prompt] for semantic validation. - * 4. If Gemini approves ([passCondition] returns true), updates the golden file on disk so future - * runs execute instantly offline. + * 4. If no golden existed and Gemini approves ([passCondition] returns true), records the + * screenshot as the new golden so future runs execute instantly offline. + * + * A golden that exists but diverged is never overwritten, even when Gemini approves the new + * screenshot. Otherwise a regression approved by Gemini once would become the baseline and later + * runs would fast-pass against it. To accept an intentional visual change, replace the golden + * file yourself. * * @param testName Unique name of the test (used for the golden file name, e.g. "sydney_markers"). * @param screenshotBitmap The captured bitmap to verify. * @param prompt The natural language specification prompt for Gemini. - * @param passCondition Predicate verifying the Gemini text response (defaults to checking for "PASSED"). + * @param passCondition Predicate verifying the Gemini text response. The default passes only when + * the last `PASSED` or `FAILED` verdict in the response is `PASSED` and is not negated (for + * example "NOT PASSED" fails). * @param goldensDirectory The directory where golden files are stored. * @param tolerancePercent The allowed pixel mismatch percentage before falling back to Gemini (default: 0.5%). - * @param updateGoldenOnPass If true, updates or creates the baseline golden image upon successful Gemini validation. + * @param updateGoldenOnPass If true, records the screenshot as the golden baseline when no + * baseline existed and Gemini approves it. Existing baselines are never overwritten. */ // [START maps_visual_golden_fallback] suspend fun verifyScreenshotWithGoldenFallback( testName: String, screenshotBitmap: Bitmap, prompt: String, - passCondition: (String) -> Boolean = { it.contains("PASSED", ignoreCase = true) }, + passCondition: (String) -> Boolean = { isPassedVerdict(it) }, goldensDirectory: File = File(context.getExternalFilesDir(null) ?: context.filesDir, "goldens"), tolerancePercent: Double = 0.5, updateGoldenOnPass: Boolean = true, @@ -198,7 +211,13 @@ abstract class BaseVisualTest { passed, ) - if (updateGoldenOnPass) { + if (goldenBitmap != null) { + Log.w( + "BaseVisualTest", + "Gemini approved '$testName', but the screenshot differs from its golden baseline. The golden was " + + "not updated. If the change is intended, replace the golden with the new screenshot.", + ) + } else if (updateGoldenOnPass) { try { goldensDirectory.mkdirs() FileOutputStream(goldenFile).use { out -> @@ -221,7 +240,8 @@ abstract class BaseVisualTest { * Resolves a golden baseline [Bitmap] for [testName] by checking: * 1. Device storage override at [goldenFile] (`goldensDirectory/$testName.png`) * 2. Bundled instrumentation test APK assets (`src/androidTest/assets/goldens/$testName.png`) - * 3. Bundled target application assets (`src/main/assets/goldens/$testName.png`) + * 3. Bundled target application assets (`src/main/assets/goldens/$testName.png`). Prefer + * `src/androidTest/assets`: files under `src/main/assets` ship inside the release APK. * 4. JVM classpath resources (`src/test/resources/goldens/$testName.png`) */ protected open fun loadGoldenBaseline(testName: String, goldenFile: File): Bitmap? { @@ -255,7 +275,7 @@ abstract class BaseVisualTest { testName: String, screenshotBitmap: Bitmap, prompt: String, - passCondition: (String) -> Boolean = { it.contains("PASSED", ignoreCase = true) }, + passCondition: (String) -> Boolean = { isPassedVerdict(it) }, goldensDirectory: File = File(context.getExternalFilesDir(null) ?: context.filesDir, "goldens"), tolerancePercent: Double = 0.5, updateGoldenOnPass: Boolean = true, diff --git a/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt b/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt index b48807b..5c2339f 100644 --- a/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt +++ b/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt @@ -27,6 +27,7 @@ import androidx.test.uiautomator.Until import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse @@ -48,8 +49,10 @@ import java.io.ByteArrayOutputStream * Helper class to interact with Google's Gemini API for visual verification and UI actions. * * Features: - * - Dynamically queries the Gemini API for the latest stable Flash model and caches it - * for the entire testing session. + * - Uses the pinned [DEFAULT_FALLBACK_MODEL] unless a model is configured, so results do not change + * when a new model ships. Setting the model to [LATEST_MODEL] opts into querying the Gemini API + * for the latest stable Flash model, cached for the entire testing session. + * - Sends the API key in the `x-goog-api-key` header, never in the request URL. * - Encodes screenshot bitmaps into base64 JPEG payloads for multimodal vision prompts. * - Translates natural language commands into UiAutomator interactions via UI hierarchy inspection. * - Uses kotlinx.serialization for type-safe request/response parsing. @@ -63,9 +66,20 @@ class GeminiVisualTestHelper( companion object { private const val TAG = "GeminiVisualTestHelper" private const val BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + private const val API_KEY_HEADER = "x-goog-api-key" + + /** + * Model used when neither the constructor nor the `gemini.model` system property selects one. + */ const val DEFAULT_FALLBACK_MODEL = "gemini-3.8-flash" - // Session-wide cached model name so discovery only happens once per test run + /** + * Model value that opts into querying `models.list` for the latest stable Flash model instead + * of using [DEFAULT_FALLBACK_MODEL]. Pass it as `preferredModel` or as `-Dgemini.model=latest`. + */ + const val LATEST_MODEL = "latest" + + // Session-wide cached model name so discovery with LATEST_MODEL only happens once per test run @Volatile private var sessionResolvedModel: String? = null @@ -117,18 +131,19 @@ class GeminiVisualTestHelper( * Resolution order: * 1. Constructor [preferredModel], if explicitly provided. * 2. System property `gemini.model`, if specified. - * 3. Session-cached model from a previous server resolution. - * 4. Dynamic query to `models.list` selecting the newest stable Flash model. - * 5. Fallback default: [DEFAULT_FALLBACK_MODEL]. + * 3. Otherwise the pinned [DEFAULT_FALLBACK_MODEL], without any network call. + * + * When the configured model is [LATEST_MODEL], the newest stable Flash model is looked up with + * `models.list` once per session and cached, falling back to [DEFAULT_FALLBACK_MODEL] if the + * lookup fails. */ suspend fun getOrResolveModel(apiKey: String): String { - if (!preferredModel.isNullOrBlank()) { - return preferredModel - } + val configuredModel = preferredModel?.takeIf { it.isNotBlank() } + ?: System.getProperty("gemini.model")?.takeIf { it.isNotBlank() } + ?: return DEFAULT_FALLBACK_MODEL - val systemPropModel = System.getProperty("gemini.model") - if (!systemPropModel.isNullOrBlank()) { - return systemPropModel + if (!configuredModel.equals(LATEST_MODEL, ignoreCase = true)) { + return configuredModel } sessionResolvedModel?.let { return it } @@ -145,7 +160,9 @@ class GeminiVisualTestHelper( */ suspend fun queryLatestStableFlashModel(apiKey: String): String? { return try { - val response: HttpResponse = client.get("$BASE_URL/models?key=$apiKey") + val response: HttpResponse = client.get("$BASE_URL/models") { + header(API_KEY_HEADER, apiKey) + } if (response.status != HttpStatusCode.OK) { Log.w(TAG, "Failed to query models list (${response.status}): ${response.bodyAsText()}") return null @@ -204,7 +221,7 @@ class GeminiVisualTestHelper( ), ) - val response = executeWithRetry("$BASE_URL/models/$model:generateContent?key=$apiKey", request) + val response = executeWithRetry("$BASE_URL/models/$model:generateContent", apiKey, request) val geminiResponse: GeminiResponse = json.decodeFromString(response.bodyAsText()) // Extract usage metrics @@ -285,7 +302,7 @@ class GeminiVisualTestHelper( ), ) - val response = executeWithRetry("$BASE_URL/models/$model:generateContent?key=$apiKey", request) + val response = executeWithRetry("$BASE_URL/models/$model:generateContent", apiKey, request) val geminiResponse: GeminiResponse = json.decodeFromString(response.bodyAsText()) val rawText = geminiResponse.candidates.firstOrNull()?.content?.parts?.firstOrNull()?.text @@ -344,6 +361,7 @@ class GeminiVisualTestHelper( private suspend fun executeWithRetry( url: String, + apiKey: String, request: GeminiRequest, maxRetries: Int = 3, ): HttpResponse { @@ -353,6 +371,7 @@ class GeminiVisualTestHelper( while (attempts < maxRetries) { attempts++ val response: HttpResponse = client.post(url) { + header(API_KEY_HEADER, apiKey) contentType(ContentType.Application.Json) setBody(json.encodeToString(request)) } diff --git a/visual-testing/src/main/java/com/google/maps/android/testing/visual/VisualVerdict.kt b/visual-testing/src/main/java/com/google/maps/android/testing/visual/VisualVerdict.kt new file mode 100644 index 0000000..940588c --- /dev/null +++ b/visual-testing/src/main/java/com/google/maps/android/testing/visual/VisualVerdict.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.testing.visual + +private val VERDICT_REGEX = Regex("""\b(not\s+)?(passed|failed)\b""", RegexOption.IGNORE_CASE) + +/** + * Default pass condition for Gemini visual verification. Passes only when the last `PASSED` or + * `FAILED` verdict in [response] is `PASSED` and is not negated, so "NOT PASSED", "FAILED" and + * responses without a verdict all fail. + */ +internal fun isPassedVerdict(response: String): Boolean { + val verdict = VERDICT_REGEX.findAll(response).lastOrNull() ?: return false + return verdict.groups[1] == null && verdict.groupValues[2].equals("PASSED", ignoreCase = true) +} diff --git a/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt b/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt index 7bb1c5b..268e73b 100644 --- a/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt +++ b/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt @@ -16,12 +16,20 @@ package com.google.maps.android.testing.visual +import android.graphics.Bitmap import com.google.common.truth.Truth.assertThat +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpStatusCode import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class GeminiModelResolutionTest { @Before @@ -47,26 +55,76 @@ class GeminiModelResolutionTest { } @Test - fun fallbackDefaultUsedWhenNetworkFails() = runBlocking { - val helper = GeminiVisualTestHelper() - // Querying with dummy key against real endpoint fails, triggering default fallback - val model = helper.getOrResolveModel("invalid-dummy-key") + fun defaultModelIsPinnedWithoutQueryingServer() = runBlocking { + val requests = mutableListOf() + val helper = GeminiVisualTestHelper(client = mockGeminiClient(requests)) + + val model = helper.getOrResolveModel("dummy-key") + assertThat(model).isEqualTo(GeminiVisualTestHelper.DEFAULT_FALLBACK_MODEL) + assertThat(requests).isEmpty() } @Test - fun sessionModelIsCachedAcrossCalls() = runBlocking { - val helper = GeminiVisualTestHelper() - val firstModel = helper.getOrResolveModel("invalid-dummy-key") - assertThat(firstModel).isEqualTo(GeminiVisualTestHelper.DEFAULT_FALLBACK_MODEL) + fun latestModelQueriesServerForNewestStableFlash() = runBlocking { + System.setProperty("gemini.model", GeminiVisualTestHelper.LATEST_MODEL) + val helper = GeminiVisualTestHelper(client = mockGeminiClient { MODEL_LIST }) + + val model = helper.getOrResolveModel("dummy-key") + + assertThat(model).isEqualTo("gemini-9.0-flash") + } + + @Test + fun latestModelFallsBackToDefaultWhenQueryFails() = runBlocking { + val helper = GeminiVisualTestHelper( + preferredModel = GeminiVisualTestHelper.LATEST_MODEL, + client = mockGeminiClient(status = HttpStatusCode.InternalServerError) { "{}" }, + ) + + val model = helper.getOrResolveModel("dummy-key") + + assertThat(model).isEqualTo(GeminiVisualTestHelper.DEFAULT_FALLBACK_MODEL) + } + + @Test + fun latestModelIsCachedForTheSession() = runBlocking { + val requests = mutableListOf() + val helper = GeminiVisualTestHelper( + preferredModel = GeminiVisualTestHelper.LATEST_MODEL, + client = mockGeminiClient(requests) { MODEL_LIST }, + ) + + helper.getOrResolveModel("dummy-key") + val second = helper.getOrResolveModel("dummy-key") + + assertThat(second).isEqualTo("gemini-9.0-flash") + assertThat(requests).hasSize(1) - // Even if system property is set afterwards, the session cache takes precedence - // unless explicitly reset - System.setProperty("gemini.model", "new-model") - // Reset cache explicitly to see change GeminiVisualTestHelper.resetSessionModel() - val secondModel = helper.getOrResolveModel("invalid-dummy-key") - assertThat(secondModel).isEqualTo("new-model") + helper.getOrResolveModel("dummy-key") + assertThat(requests).hasSize(2) + } + + @Test + fun apiKeyIsSentInHeaderNotUrl() = runBlocking { + val requests = mutableListOf() + val helper = GeminiVisualTestHelper( + preferredModel = GeminiVisualTestHelper.LATEST_MODEL, + client = mockGeminiClient(requests) { request -> + if (request.url.encodedPath.endsWith("/models")) MODEL_LIST else generateContentResponse("PASSED") + }, + ) + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888) + + helper.analyzeImage(bitmap, "Verify the map", "secret-key") + + assertThat(requests).hasSize(2) + for (request in requests) { + assertThat(request.headers["x-goog-api-key"]).isEqualTo("secret-key") + assertThat(request.url.parameters.contains("key")).isFalse() + assertThat(request.url.toString()).doesNotContain("secret-key") + } } @Test @@ -97,4 +155,16 @@ class GeminiModelResolutionTest { fun selectBestFlashModelReturnsNullForEmptyList() { assertThat(GeminiVisualTestHelper.selectBestFlashModel(emptyList())).isNull() } + + private companion object { + val MODEL_LIST = """ + { + "models": [ + { "name": "models/gemini-2.5-flash", "supportedGenerationMethods": ["generateContent"] }, + { "name": "models/gemini-9.0-flash", "supportedGenerationMethods": ["generateContent"] }, + { "name": "models/gemini-10.0-flash-preview", "supportedGenerationMethods": ["generateContent"] } + ] + } + """.trimIndent() + } } diff --git a/visual-testing/src/test/java/com/google/maps/android/testing/visual/GoldenFallbackLogicTest.kt b/visual-testing/src/test/java/com/google/maps/android/testing/visual/GoldenFallbackLogicTest.kt index 958ed41..3ffeac0 100644 --- a/visual-testing/src/test/java/com/google/maps/android/testing/visual/GoldenFallbackLogicTest.kt +++ b/visual-testing/src/test/java/com/google/maps/android/testing/visual/GoldenFallbackLogicTest.kt @@ -20,6 +20,9 @@ import android.graphics.Bitmap import android.graphics.Color import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -36,6 +39,16 @@ class GoldenFallbackLogicTest { @get:Rule val tempFolder = TemporaryFolder() + @Before + fun setApiKey() { + System.setProperty("gemini.api.key", "test-key") + } + + @After + fun clearApiKey() { + System.clearProperty("gemini.api.key") + } + @Test fun fastPassWhenGoldenMatches() = runBlocking { val goldensDir = tempFolder.newFolder("goldens") @@ -96,4 +109,69 @@ class GoldenFallbackLogicTest { goldensDirectory = emptyDeviceGoldensDir, ) } + + @Test + fun divergedGoldenIsNotOverwrittenWhenGeminiPasses() = runBlocking { + val goldensDir = tempFolder.newFolder("goldens") + val goldenFile = File(goldensDir, "diverged.png") + writePng(solidBitmap(Color.BLUE), goldenFile) + val goldenBytes = goldenFile.readBytes() + + geminiHarness(answer = "PASSED").verifyScreenshotWithGoldenFallback( + testName = "diverged", + screenshotBitmap = solidBitmap(Color.RED), + prompt = "Verify the map", + goldensDirectory = goldensDir, + ) + + assertThat(goldenFile.readBytes()).isEqualTo(goldenBytes) + } + + @Test + fun missingGoldenIsRecordedWhenGeminiPasses() = runBlocking { + val goldensDir = tempFolder.newFolder("goldens") + + geminiHarness(answer = "PASSED").verifyScreenshotWithGoldenFallback( + testName = "first_run", + screenshotBitmap = solidBitmap(Color.RED), + prompt = "Verify the map", + goldensDirectory = goldensDir, + ) + + assertThat(File(goldensDir, "first_run.png").exists()).isTrue() + } + + @Test + fun negatedVerdictFailsVerification() { + val goldensDir = tempFolder.newFolder("goldens") + + val error = assertThrows(AssertionError::class.java) { + runBlocking { + geminiHarness(answer = "NOT PASSED: the marker is missing").verifyScreenshotWithGoldenFallback( + testName = "negated", + screenshotBitmap = solidBitmap(Color.RED), + prompt = "Verify the map", + goldensDirectory = goldensDir, + ) + } + } + + assertThat(error).hasMessageThat().contains("Gemini visual verification failed") + assertThat(File(goldensDir, "negated.png").exists()).isFalse() + } + + private fun geminiHarness(answer: String) = object : BaseVisualTest() { + override val helper = GeminiVisualTestHelper( + preferredModel = "test-model", + client = mockGeminiClient { generateContentResponse(answer) }, + ) + } + + private fun solidBitmap(color: Int): Bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply { + eraseColor(color) + } + + private fun writePng(bitmap: Bitmap, file: File) { + FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } + } } diff --git a/visual-testing/src/test/java/com/google/maps/android/testing/visual/MockGeminiClient.kt b/visual-testing/src/test/java/com/google/maps/android/testing/visual/MockGeminiClient.kt new file mode 100644 index 0000000..856309d --- /dev/null +++ b/visual-testing/src/test/java/com/google/maps/android/testing/visual/MockGeminiClient.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.testing.visual + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf + +/** Builds an [HttpClient] that records every request into [requests] and answers with [body]. */ +internal fun mockGeminiClient( + requests: MutableList = mutableListOf(), + status: HttpStatusCode = HttpStatusCode.OK, + body: (HttpRequestData) -> String = { generateContentResponse("PASSED") }, +): HttpClient = HttpClient( + MockEngine { request -> + requests += request + respond(body(request), status, headersOf(HttpHeaders.ContentType, "application/json")) + }, +) + +/** A `generateContent` response whose single candidate answers with [text]. */ +internal fun generateContentResponse(text: String): String = """{ "candidates": [ { "content": { "parts": [ { "text": "$text" } ] }, "finishReason": "STOP" } ] }""" diff --git a/visual-testing/src/test/java/com/google/maps/android/testing/visual/VisualVerdictTest.kt b/visual-testing/src/test/java/com/google/maps/android/testing/visual/VisualVerdictTest.kt new file mode 100644 index 0000000..288b6d5 --- /dev/null +++ b/visual-testing/src/test/java/com/google/maps/android/testing/visual/VisualVerdictTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.testing.visual + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class VisualVerdictTest { + + @Test + fun passedVerdictPasses() { + assertThat(isPassedVerdict("Test Result: PASSED. All 6 markers are rendered.")).isTrue() + } + + @Test + fun verdictIsCaseInsensitive() { + assertThat(isPassedVerdict("passed")).isTrue() + } + + @Test + fun negatedPassedFails() { + assertThat(isPassedVerdict("NOT PASSED: the Sydney marker is missing.")).isFalse() + } + + @Test + fun failedVerdictFails() { + assertThat(isPassedVerdict("FAILED. Expected 6 markers but found 5.")).isFalse() + } + + @Test + fun lastVerdictWins() { + assertThat(isPassedVerdict("Criterion 1 PASSED. Criterion 2 FAILED.")).isFalse() + assertThat(isPassedVerdict("An earlier draft FAILED, final verdict: PASSED")).isTrue() + } + + @Test + fun responseWithoutVerdictFails() { + assertThat(isPassedVerdict("The map shows Sydney.")).isFalse() + } + + @Test + fun verdictMustBeWholeWord() { + assertThat(isPassedVerdict("BYPASSED")).isFalse() + } +} From d3f0ae5f5460453882373d0249fd33c1b0a00c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 6 Nov 2026 14:01:27 +0100 Subject: [PATCH 2/3] fix(visual): give Gemini requests a 60s timeout and align sample coroutines with Ktor --- app/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 1 + .../android/testing/visual/GeminiVisualTestHelper.kt | 12 +++++++++++- .../testing/visual/GeminiModelResolutionTest.kt | 9 +++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e2012d..8191521 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,6 +104,9 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.play.services.maps) + // :visual-testing uses Ktor, which needs coroutines 1.11+. The test APK is pinned to the app's + // runtime versions, so the app must not resolve an older coroutines than Ktor expects. + implementation(libs.kotlinx.coroutines.android) // Arch Components implementation(libs.androidx.lifecycle.runtime.compose) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b43866e..6de3f8c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } diff --git a/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt b/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt index 5c2339f..8d2e572 100644 --- a/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt +++ b/visual-testing/src/main/java/com/google/maps/android/testing/visual/GeminiVisualTestHelper.kt @@ -91,7 +91,17 @@ class GeminiVisualTestHelper( sessionResolvedModel = null } - private fun createDefaultHttpClient(): HttpClient = HttpClient(CIO) + /** + * Request timeout for the default client. CIO's own default is 15 seconds, which a Gemini vision + * request from an emulator can exceed. + */ + internal const val DEFAULT_REQUEST_TIMEOUT_MILLIS = 60_000L + + internal fun createDefaultHttpClient(): HttpClient = HttpClient(CIO) { + engine { + requestTimeout = DEFAULT_REQUEST_TIMEOUT_MILLIS + } + } private val VERSION_REGEX = Regex("""gemini-(\d+(?:\.\d+)*)""", RegexOption.IGNORE_CASE) diff --git a/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt b/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt index 268e73b..c806134 100644 --- a/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt +++ b/visual-testing/src/test/java/com/google/maps/android/testing/visual/GeminiModelResolutionTest.kt @@ -18,6 +18,7 @@ package com.google.maps.android.testing.visual import android.graphics.Bitmap import com.google.common.truth.Truth.assertThat +import io.ktor.client.engine.cio.CIOEngineConfig import io.ktor.client.request.HttpRequestData import io.ktor.http.HttpStatusCode import kotlinx.coroutines.runBlocking @@ -106,6 +107,14 @@ class GeminiModelResolutionTest { assertThat(requests).hasSize(2) } + @Test + fun defaultClientAllowsSlowVisionRequests() { + val config = GeminiVisualTestHelper.createDefaultHttpClient().engine.config as CIOEngineConfig + + assertThat(config.requestTimeout).isEqualTo(GeminiVisualTestHelper.DEFAULT_REQUEST_TIMEOUT_MILLIS) + assertThat(config.requestTimeout).isAtLeast(60_000L) + } + @Test fun apiKeyIsSentInHeaderNotUrl() = runBlocking { val requests = mutableListOf() From 77a424b9648310d06d2532880c9b969799b8beea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 6 Nov 2026 14:02:41 +0100 Subject: [PATCH 3/3] docs(skills): refer to code regions by name so snippet-bot can parse the skills --- .agents/skills/android-maps-testing/SKILL.md | 16 ++++++++-------- .../references/ai_visual_testing_and_gemini.md | 4 ++-- .../references/golden_image_testing.md | 8 ++++---- .../references/jetpack_compose_and_ecosystem.md | 8 ++++---- .../modular_architecture_and_anti_bloat.md | 4 ++-- .../robolectric_shadows_and_jvm_testing.md | 14 +++++++------- .agents/skills/maps-golden-testing/SKILL.md | 6 +++--- .agents/skills/maps-robolectric-testing/SKILL.md | 14 +++++++------- .agents/skills/maps-visual-testing/SKILL.md | 4 ++-- 9 files changed, 39 insertions(+), 39 deletions(-) diff --git a/.agents/skills/android-maps-testing/SKILL.md b/.agents/skills/android-maps-testing/SKILL.md index 40e1cee..2434ff4 100644 --- a/.agents/skills/android-maps-testing/SKILL.md +++ b/.agents/skills/android-maps-testing/SKILL.md @@ -181,7 +181,7 @@ dependencies { Use `withTestMap { ... }` to spin up a measured, laid-out, initialized `GoogleMap` in one line and assert state with `com.google.maps.android.robolectric.truth.assertThat`: -Reference: `// [START maps_robolectric_demo_test]` in [`MapDemoTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/MapDemoTest.kt#L36-L83) +Reference: region `maps_robolectric_demo_test` in [`MapDemoTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/MapDemoTest.kt#L36-L83) ```kotlin // [START maps_robolectric_demo_test] @@ -240,7 +240,7 @@ class MapDemoTest { If you prefer plain boolean expressions with zero Google Truth dependency, import `assertMap`, `assertMarker`, `distanceTo`, and `meters` directly from `com.google.maps.android.robolectric.powerassert`: -Reference: `// [START maps_robolectric_power_assert]` in [`LazyAssertionsComparisonTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/LazyAssertionsComparisonTest.kt#L151-L187) +Reference: region `maps_robolectric_power_assert` in [`LazyAssertionsComparisonTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/LazyAssertionsComparisonTest.kt#L151-L187) ```kotlin // [START maps_robolectric_power_assert] @@ -288,7 +288,7 @@ Reference: `// [START maps_robolectric_power_assert]` in [`LazyAssertionsCompari Pass the test `GoogleMap` from `createTestMap()` directly into your production controller (`StoreLocatorMapManager`), or locate an embedded `MapView` inside an `Activity` or Jetpack Compose screen via `requireMapView()`: -Reference: `// [START maps_robolectric_manager_test]` in [`StoreLocatorMapManagerTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManagerTest.kt#L37-L129) +Reference: region `maps_robolectric_manager_test` in [`StoreLocatorMapManagerTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManagerTest.kt#L37-L129) ```kotlin // [START maps_robolectric_manager_test] @@ -386,7 +386,7 @@ class StoreLocatorMapManagerTest { // [END maps_robolectric_manager_test] ``` -Reference: `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521) +Reference: region `robolectric_view_hierarchy_lookup` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521) ```kotlin // [START robolectric_view_hierarchy_lookup] @@ -406,7 +406,7 @@ Reference: `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotl ### Paradigm 1D: Comprehensive Java Headless JVM Testing (`:robolectric-testing`) -Reference: `// [START maps_robolectric_java_demo_test]` in [`MapViewDemoTest.java`](../../../app/src/test/java/com/example/androidmapsrobolectric/MapViewDemoTest.java#L86-L152) +Reference: region `maps_robolectric_java_demo_test` in [`MapViewDemoTest.java`](../../../app/src/test/java/com/example/androidmapsrobolectric/MapViewDemoTest.java#L86-L152) ```java // [START maps_robolectric_java_demo_test] @@ -483,7 +483,7 @@ Reference: `// [START maps_robolectric_java_demo_test]` in [`MapViewDemoTest.jav Use `enableGoldenTesting(...)` to replace live Google tiles with deterministic synthetic tiles (`CoordinateGrid`, `SolidColorHash`, `Checkerboard`, or `Custom`) and verify renders with `captureSnapshot()`, `assertMatchesGolden(goldenFile)`, or `GoldenImageDiff.assertMatches(...)`: -Reference: `// [START maps_sample_golden_map_test]` in [`SampleGoldenMapTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/sample/SampleGoldenMapTest.kt#L46-L128) +Reference: region `maps_sample_golden_map_test` in [`SampleGoldenMapTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/sample/SampleGoldenMapTest.kt#L46-L128) ```kotlin // [START maps_sample_golden_map_test] @@ -571,7 +571,7 @@ public class SampleGoldenMapTest { // [END maps_sample_golden_map_test] ``` -Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/GoldenImageDiffTest.kt#L109-L146) +Reference: region `golden_diff_artifacts_and_record` in [`GoldenImageDiffTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/GoldenImageDiffTest.kt#L109-L146) ```kotlin // [START golden_diff_artifacts_and_record] @@ -620,7 +620,7 @@ Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTes Extend `BaseVisualTest` and call `verifyScreenshotWithGoldenFallback(...)`. On clean emulators or CI, `BaseVisualTest` checks bundled test APK assets (`src/androidTest/assets/goldens/.png`) first for an instant zero-quota fast pass, and only consults Gemini Vision when no baseline exists yet or pixel diff exceeds `tolerancePercent`: -Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) +Reference: region `maps_visual_test_sample` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) ```kotlin // [START maps_visual_test_sample] diff --git a/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md b/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md index d98ad9b..ef44581 100644 --- a/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md +++ b/.agents/skills/android-maps-testing/references/ai_visual_testing_and_gemini.md @@ -77,7 +77,7 @@ If no key is configured and no matching golden baseline is found, tests using Ge ``` 3. **Fast Zero-Quota CI & Fresh Emulator Runs**: On clean CI emulators, `BaseVisualTest` automatically loads `src/androidTest/assets/goldens/.png` from the test APK assets. When the screenshot matches within `tolerancePercent`, the test passes in milliseconds with **zero Gemini API calls** (and doesn't even require `GEMINI_API_KEY` to be set). -Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L160-L237) +Reference: region `maps_visual_golden_fallback` in [`BaseVisualTest.kt`](../../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L160-L237) ```kotlin // [START maps_visual_golden_fallback] @@ -164,7 +164,7 @@ Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../ ## 5. End-to-End Instrumented Test -Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) +Reference: region `maps_visual_test_sample` in [`VisualMapSampleTest.kt`](../../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) ```kotlin // [START maps_visual_test_sample] diff --git a/.agents/skills/android-maps-testing/references/golden_image_testing.md b/.agents/skills/android-maps-testing/references/golden_image_testing.md index 0f9c8c4..41f9440 100644 --- a/.agents/skills/android-maps-testing/references/golden_image_testing.md +++ b/.agents/skills/android-maps-testing/references/golden_image_testing.md @@ -44,7 +44,7 @@ Screenshot and golden image testing for interactive maps has historically suffer `GoogleMap.awaitMapLoaded()` and `GoogleMap.captureSnapshot()` automatically idle `ShadowLooper.idleMainLooper()` when running under Robolectric (while suspending for real callbacks on Android devices), and `GoogleMap.assertMatchesGolden(goldenFile)` / `GoogleMap.assertMatchesGoldenBlocking(goldenFile)` capture and verify snapshots in a single line: -Reference: `// [START maps_golden_map_extensions]` in [`GoogleMapExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoogleMapExtensions.kt#L29-L162) +Reference: region `maps_golden_map_extensions` in [`GoogleMapExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoogleMapExtensions.kt#L29-L162) ```kotlin // [START maps_golden_map_extensions] @@ -187,7 +187,7 @@ private fun idleMainLooperIfRobolectric() { ## 4. Jetpack Compose Integration (`rememberGoldenTileProvider`) -Reference: `// [START maps_golden_compose_extensions]` in [`GoldenComposeExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoldenComposeExtensions.kt#L22-L51) +Reference: region `maps_golden_compose_extensions` in [`GoldenComposeExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoldenComposeExtensions.kt#L22-L51) ```kotlin // [START maps_golden_compose_extensions] @@ -226,7 +226,7 @@ public fun rememberGoldenTileProvider( ## 5. Recording Baselines (`-Dmaps.golden.record=true`) & Failure Artifacts (`_actual.png` / `_diff.png`) -Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTest.kt`](../../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/GoldenImageDiffTest.kt#L109-L146) +Reference: region `golden_diff_artifacts_and_record` in [`GoldenImageDiffTest.kt`](../../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/GoldenImageDiffTest.kt#L109-L146) ```kotlin // [START golden_diff_artifacts_and_record] @@ -273,7 +273,7 @@ Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTes ## 6. End-to-End Sample Test -Reference: `// [START maps_sample_golden_map_test]` in [`SampleGoldenMapTest.kt`](../../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/sample/SampleGoldenMapTest.kt#L46-L128) +Reference: region `maps_sample_golden_map_test` in [`SampleGoldenMapTest.kt`](../../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/sample/SampleGoldenMapTest.kt#L46-L128) ```kotlin // [START maps_sample_golden_map_test] diff --git a/.agents/skills/android-maps-testing/references/jetpack_compose_and_ecosystem.md b/.agents/skills/android-maps-testing/references/jetpack_compose_and_ecosystem.md index 6e8894c..8f8843c 100644 --- a/.agents/skills/android-maps-testing/references/jetpack_compose_and_ecosystem.md +++ b/.agents/skills/android-maps-testing/references/jetpack_compose_and_ecosystem.md @@ -8,7 +8,7 @@ This guide covers testing Google Maps within **Jetpack Compose** applications an Both `:robolectric-testing` (`com.google.maps.android.robolectric.ktx`) and `:golden-testing` (`com.google.maps.android.testing.golden`) provide view hierarchy traversal helpers to extract the hosted `MapView` from an `Activity` or Jetpack Compose `AndroidView` container: -Reference: `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521) +Reference: region `robolectric_view_hierarchy_lookup` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521) ```kotlin // [START robolectric_view_hierarchy_lookup] @@ -24,7 +24,7 @@ Reference: `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotl // [END robolectric_view_hierarchy_lookup] ``` -Reference: `// [START maps_compose_test_extensions]` in [`ComposeMapTestExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/ComposeMapTestExtensions.kt#L36-L110) +Reference: region `maps_compose_test_extensions` in [`ComposeMapTestExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/ComposeMapTestExtensions.kt#L36-L110) ```kotlin // [START maps_compose_test_extensions] @@ -110,7 +110,7 @@ public fun GoogleMap.assertCameraTarget(expected: LatLng, tolerance: Distance = Inject deterministic synthetic tiles directly into Maps Compose with `rememberGoldenTileProvider()`: -Reference: `// [START maps_golden_compose_extensions]` in [`GoldenComposeExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoldenComposeExtensions.kt#L22-L51) +Reference: region `maps_golden_compose_extensions` in [`GoldenComposeExtensions.kt`](../../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoldenComposeExtensions.kt#L22-L51) ```kotlin // [START maps_golden_compose_extensions] @@ -151,7 +151,7 @@ public fun rememberGoldenTileProvider( `android-maps-testing` faithfully models `ShadowProjection` and `ShadowGoogleMap` so that distance-based marker clustering algorithms (like `DistanceBasedAlgorithm` and `NonHierarchicalDistanceBasedAlgorithm`) operate identically on the local JVM: -Reference: `// [START maps_robolectric_clustering_test]` in [`MapsUtilsClusteringIntegrationTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/MapsUtilsClusteringIntegrationTest.kt#L45-L167) +Reference: region `maps_robolectric_clustering_test` in [`MapsUtilsClusteringIntegrationTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/MapsUtilsClusteringIntegrationTest.kt#L45-L167) ```kotlin // [START maps_robolectric_clustering_test] diff --git a/.agents/skills/android-maps-testing/references/modular_architecture_and_anti_bloat.md b/.agents/skills/android-maps-testing/references/modular_architecture_and_anti_bloat.md index 2f3925a..2cc37d6 100644 --- a/.agents/skills/android-maps-testing/references/modular_architecture_and_anti_bloat.md +++ b/.agents/skills/android-maps-testing/references/modular_architecture_and_anti_bloat.md @@ -95,7 +95,7 @@ flowchart TD ## 4. Production Map Controller Implementation -Reference: `// [START maps_robolectric_manager]` in [`StoreLocatorMapManager.kt`](../../../../app/src/main/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManager.kt#L31-L157) +Reference: region `maps_robolectric_manager` in [`StoreLocatorMapManager.kt`](../../../../app/src/main/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManager.kt#L31-L157) ```kotlin // [START maps_robolectric_manager] @@ -231,7 +231,7 @@ class StoreLocatorMapManager( ## 5. Testing the Controller on JVM with `createTestMap` & Domain Assertions -Reference: `// [START maps_robolectric_manager_test]` in [`StoreLocatorMapManagerTest.kt`](../../../../app/src/test/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManagerTest.kt#L37-L129) +Reference: region `maps_robolectric_manager_test` in [`StoreLocatorMapManagerTest.kt`](../../../../app/src/test/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManagerTest.kt#L37-L129) ```kotlin // [START maps_robolectric_manager_test] diff --git a/.agents/skills/android-maps-testing/references/robolectric_shadows_and_jvm_testing.md b/.agents/skills/android-maps-testing/references/robolectric_shadows_and_jvm_testing.md index 9bedd9a..c3bf882 100644 --- a/.agents/skills/android-maps-testing/references/robolectric_shadows_and_jvm_testing.md +++ b/.agents/skills/android-maps-testing/references/robolectric_shadows_and_jvm_testing.md @@ -35,7 +35,7 @@ Annotate your test class with `@EnableMapsShadows`, `@RunWith(RobolectricTestRun Instead of manually constructing an `Activity`, measuring/laying out a `MapView`, and idling `ShadowLooper`, use `withTestMap { ... }` or `createTestMap()` from `com.google.maps.android.robolectric.ktx`: -Reference: `// [START maps_robolectric_demo_test]` in [`MapDemoTest.kt`](../../../../app/src/test/java/com/example/androidmapsrobolectric/MapDemoTest.kt#L36-L83) +Reference: region `maps_robolectric_demo_test` in [`MapDemoTest.kt`](../../../../app/src/test/java/com/example/androidmapsrobolectric/MapDemoTest.kt#L36-L83) ```kotlin // [START maps_robolectric_demo_test] @@ -94,7 +94,7 @@ class MapDemoTest { `:robolectric-testing` supports both **Google Truth** domain subjects (`com.google.maps.android.robolectric.truth.assertThat`) and **Kotlin Power-Assert** (`com.google.maps.android.robolectric.powerassert.assertMap`, `assertMarker`, `assertGeo`, with `distanceTo` and `meters` included directly in `.powerassert`): -Reference: `// [START maps_robolectric_truth_domain_subjects]` and `// [START maps_robolectric_power_assert]` in [`LazyAssertionsComparisonTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/LazyAssertionsComparisonTest.kt#L114-L187) +Reference: region `maps_robolectric_truth_domain_subjects` and region `maps_robolectric_power_assert` in [`LazyAssertionsComparisonTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/LazyAssertionsComparisonTest.kt#L114-L187) ```kotlin // [START maps_robolectric_truth_domain_subjects] @@ -179,7 +179,7 @@ Reference: `// [START maps_robolectric_truth_domain_subjects]` and `// [START ma `ShadowGoogleMap` computes accurate Web Mercator zoom levels for `CameraUpdateFactory.newLatLngBounds` (accounting for map padding and explicit width/height) and pixel-accurate shifts for `CameraUpdateFactory.scrollBy`: -Reference: `// [START robolectric_camera_bounds_and_scroll]` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L248-L270) +Reference: region `robolectric_camera_bounds_and_scroll` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L248-L270) ```kotlin // [START robolectric_camera_bounds_and_scroll] @@ -216,7 +216,7 @@ When `clickMarker` is invoked, `ShadowGoogleMap` follows the exact Maps SDK cont - Opening an `InfoWindow` on one marker automatically hides any previously open `InfoWindow` on the same map. - If the listener returns `true`, the event is consumed (suppressing default InfoWindow and camera move). -Reference: `// [START robolectric_marker_click_behavior]` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L287-L317) +Reference: region `robolectric_marker_click_behavior` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L287-L317) ```kotlin // [START robolectric_marker_click_behavior] @@ -258,7 +258,7 @@ Reference: `// [START robolectric_marker_click_behavior]` in [`ShadowMapViewKotl Use `map.simulateUserCameraMove(...)` to dispatch `OnCameraMoveStartedListener.REASON_GESTURE` (distinguishing user drags from programmatic `moveCamera`/`animateCamera` calls that fire `REASON_DEVELOPER_ANIMATION`), and assert `MapColorScheme`, `MapStyleOptions`, `setLatLngBoundsForCameraTarget`, and `contentDescription`: -Reference: `// [START robolectric_user_gesture_and_styling]` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L328-L372) +Reference: region `robolectric_user_gesture_and_styling` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L328-L372) ```kotlin // [START robolectric_user_gesture_and_styling] @@ -312,7 +312,7 @@ Reference: `// [START robolectric_user_gesture_and_styling]` in [`ShadowMapViewK ## 7. Extracting `MapView` from Activity or Jetpack Compose View Hierarchies -Reference: `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521) +Reference: region `robolectric_view_hierarchy_lookup` in [`ShadowMapViewKotlinTest.kt`](../../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521) ```kotlin // [START robolectric_view_hierarchy_lookup] @@ -334,7 +334,7 @@ Reference: `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotl `ShadowMapView` integrates Mercator projection mathematics to render overlays directly onto an Android `Bitmap` / `Canvas` in memory: -Reference: `// [START maps_robolectric_java_demo_test]` in [`MapViewDemoTest.java`](../../../../app/src/test/java/com/example/androidmapsrobolectric/MapViewDemoTest.java#L86-L152) +Reference: region `maps_robolectric_java_demo_test` in [`MapViewDemoTest.java`](../../../../app/src/test/java/com/example/androidmapsrobolectric/MapViewDemoTest.java#L86-L152) ```java // [START maps_robolectric_java_demo_test] diff --git a/.agents/skills/maps-golden-testing/SKILL.md b/.agents/skills/maps-golden-testing/SKILL.md index 4bf8564..dd70115 100644 --- a/.agents/skills/maps-golden-testing/SKILL.md +++ b/.agents/skills/maps-golden-testing/SKILL.md @@ -43,7 +43,7 @@ dependencies { `googleMap.awaitMapLoaded()` and `googleMap.captureSnapshot()` automatically idle `ShadowLooper.idleMainLooper()` when running under Robolectric, so you can call them directly inside `runTest` with zero `async` or `testScheduler` boilerplate: -Reference: `// [START maps_sample_golden_map_test]` in [`SampleGoldenMapTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/sample/SampleGoldenMapTest.kt#L46-L128) +Reference: region `maps_sample_golden_map_test` in [`SampleGoldenMapTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/sample/SampleGoldenMapTest.kt#L46-L128) ```kotlin // [START maps_sample_golden_map_test] @@ -137,7 +137,7 @@ public class SampleGoldenMapTest { Use `googleMap.assertMatchesGolden(goldenFile)` (or `GoldenImageDiff.assertMatches(goldenFile, actual)`). Pass `-Dmaps.golden.record=true` (or `recordBaseline = true`) to record/update baseline PNGs; when a regression occurs, `_actual.png` and `_diff.png` (highlighting changed pixels in `Color.RED`) are written automatically: -Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/GoldenImageDiffTest.kt#L109-L146) +Reference: region `golden_diff_artifacts_and_record` in [`GoldenImageDiffTest.kt`](../../../golden-testing/src/test/java/com/google/maps/android/testing/golden/GoldenImageDiffTest.kt#L109-L146) ```kotlin // [START golden_diff_artifacts_and_record] @@ -184,7 +184,7 @@ Reference: `// [START golden_diff_artifacts_and_record]` in [`GoldenImageDiffTes ### C. Jetpack Compose Integration (`rememberGoldenTileProvider`) -Reference: `// [START maps_golden_compose_extensions]` in [`GoldenComposeExtensions.kt`](../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoldenComposeExtensions.kt#L22-L51) +Reference: region `maps_golden_compose_extensions` in [`GoldenComposeExtensions.kt`](../../../golden-testing/src/main/java/com/google/maps/android/testing/golden/GoldenComposeExtensions.kt#L22-L51) ```kotlin // [START maps_golden_compose_extensions] diff --git a/.agents/skills/maps-robolectric-testing/SKILL.md b/.agents/skills/maps-robolectric-testing/SKILL.md index d26e790..ec3620b 100644 --- a/.agents/skills/maps-robolectric-testing/SKILL.md +++ b/.agents/skills/maps-robolectric-testing/SKILL.md @@ -51,7 +51,7 @@ Annotate your test class with: ### A. 1-Liner KTX Test Harness (`withTestMap`) & Unified Google Truth (`assertThat(map)`) -Reference: `// [START maps_robolectric_demo_test]` in [`MapDemoTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/MapDemoTest.kt#L36-L83) +Reference: region `maps_robolectric_demo_test` in [`MapDemoTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/MapDemoTest.kt#L36-L83) ```kotlin // [START maps_robolectric_demo_test] @@ -110,7 +110,7 @@ class MapDemoTest { Import `assertMap`, `assertMarker`, `assertGeo`, `distanceTo`, and `meters` directly from `com.google.maps.android.robolectric.powerassert`: -Reference: `// [START maps_robolectric_power_assert]` in [`LazyAssertionsComparisonTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/LazyAssertionsComparisonTest.kt#L151-L187) +Reference: region `maps_robolectric_power_assert` in [`LazyAssertionsComparisonTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/LazyAssertionsComparisonTest.kt#L151-L187) ```kotlin // [START maps_robolectric_power_assert] @@ -156,7 +156,7 @@ Reference: `// [START maps_robolectric_power_assert]` in [`LazyAssertionsCompari ### C. Testing Production Map Controllers (`createTestMap`) -Reference: `// [START maps_robolectric_manager_test]` in [`StoreLocatorMapManagerTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManagerTest.kt#L37-L129) +Reference: region `maps_robolectric_manager_test` in [`StoreLocatorMapManagerTest.kt`](../../../app/src/test/java/com/example/androidmapsrobolectric/manager/StoreLocatorMapManagerTest.kt#L37-L129) ```kotlin // [START maps_robolectric_manager_test] @@ -258,7 +258,7 @@ class StoreLocatorMapManagerTest { ### D. Camera Bounds (`newLatLngBounds`), `scrollBy`, Marker Clicks, and User Gestures (`REASON_GESTURE`) -- **Bounds & Scroll**: See `// [START robolectric_camera_bounds_and_scroll]` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L248-L270). -- **Marker Click & InfoWindow Semantics**: See `// [START robolectric_marker_click_behavior]` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L287-L317). -- **User Gesture (`REASON_GESTURE`) & Map Styling/Clamping**: See `// [START robolectric_user_gesture_and_styling]` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L328-L372). -- **Activity & Compose View Hierarchy Lookup**: See `// [START robolectric_view_hierarchy_lookup]` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521). +- **Bounds & Scroll**: See region `robolectric_camera_bounds_and_scroll` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L248-L270). +- **Marker Click & InfoWindow Semantics**: See region `robolectric_marker_click_behavior` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L287-L317). +- **User Gesture (`REASON_GESTURE`) & Map Styling/Clamping**: See region `robolectric_user_gesture_and_styling` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L328-L372). +- **Activity & Compose View Hierarchy Lookup**: See region `robolectric_view_hierarchy_lookup` in [`ShadowMapViewKotlinTest.kt`](../../../robolectric-testing/src/test/java/com/google/maps/android/robolectric/shadows/ShadowMapViewKotlinTest.kt#L511-L521). diff --git a/.agents/skills/maps-visual-testing/SKILL.md b/.agents/skills/maps-visual-testing/SKILL.md index 29f75c4..02f9781 100644 --- a/.agents/skills/maps-visual-testing/SKILL.md +++ b/.agents/skills/maps-visual-testing/SKILL.md @@ -49,7 +49,7 @@ dependencies { ## 3. End-to-End Sample Test -Reference: `// [START maps_visual_test_sample]` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) +Reference: region `maps_visual_test_sample` in [`VisualMapSampleTest.kt`](../../../app/src/androidTest/java/com/example/androidmapsrobolectric/VisualMapSampleTest.kt#L28-L71) ```kotlin // [START maps_visual_test_sample] @@ -102,7 +102,7 @@ class VisualMapSampleTest : BaseVisualTest() { ## 4. Hybrid Golden Fallback Implementation Reference -Reference: `// [START maps_visual_golden_fallback]` in [`BaseVisualTest.kt`](../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L160-L237) +Reference: region `maps_visual_golden_fallback` in [`BaseVisualTest.kt`](../../../visual-testing/src/main/java/com/google/maps/android/testing/visual/BaseVisualTest.kt#L160-L237) ```kotlin // [START maps_visual_golden_fallback]