From 7072a201ac8c743a9a66d2f2db4040db9767f4aa Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:23 -0500 Subject: [PATCH 1/3] fix: apply polygon styling to GeoJSON MultiPolygon features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeoJsonMapper matched MultiGeometry in the LineString branch, so a MultiPolygon feature carrying simplestyle properties was given a LineStyle. MapViewRenderer then casts the feature style with 'as? PolygonStyle' when rendering each member polygon, gets null, and draws the polygons with SDK defaults — the feature's fill and stroke colors were silently dropped. Style a multi-geometry whose members are all (recursively) polygons — i.e. a GeoJSON MultiPolygon — with PolygonStyle; mixed collections and MultiLineStrings keep the previous line styling. --- .../data/renderer/mapper/GeoJsonMapper.kt | 17 +++- .../renderer/mapper/GeoJsonStylingTest.kt | 90 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt b/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt index f4786ed4c..9b720c970 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt @@ -91,8 +91,9 @@ object GeoJsonMapper { val finalProperties = if (id != null) featureProperties + ("id" to id) else featureProperties val style = properties?.let { props -> - when (geometry) { - is LineString, is MultiGeometry -> { // MultiGeometry could contain lines + when { + geometry is LineString || (geometry is MultiGeometry && !geometry.isPolygonal()) -> { + // MultiGeometry could contain lines val strokeColor = props["stroke"]?.let { parseColor(it) } val strokeWidth = props["stroke-width"]?.toFloatOrNull() if (strokeColor != null || strokeWidth != null) { @@ -102,7 +103,7 @@ object GeoJsonMapper { ) } else null } - is ModelPolygon -> { + geometry is ModelPolygon || geometry is MultiGeometry -> { val strokeColor = props["stroke"]?.let { parseColor(it) } val strokeWidth = props["stroke-width"]?.toFloatOrNull() val fillColor = props["fill"]?.let { parseColor(it) } @@ -125,7 +126,7 @@ object GeoJsonMapper { ) } else null } - is PointGeometry -> { + geometry is PointGeometry -> { // TODO: Marker styling (marker-color, marker-size, marker-symbol) null } @@ -136,6 +137,14 @@ object GeoJsonMapper { return Feature(geometry, style = style, properties = finalProperties) } + /** + * True for a Polygon or a multi-geometry whose members are all (recursively) polygons — i.e. a + * GeoJSON MultiPolygon — which per the simplestyle-spec carries fill styling rather than line styling. + */ + private fun Geometry.isPolygonal(): Boolean = + this is ModelPolygon || + (this is MultiGeometry && geometries.isNotEmpty() && geometries.all { it.isPolygonal() }) + private fun parseColor(colorString: String): Int? { if (colorString.startsWith("#")) { // Handle hex color diff --git a/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt b/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt index 9c45da660..652694669 100644 --- a/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt +++ b/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt @@ -113,4 +113,94 @@ class GeoJsonStylingTest { // Verify stroke width assertEquals(3.0f, style.width!!, 0.001f) } + + @Test + fun `parse styled multipolygon applies polygon style`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "stroke": "#ff0000", + "stroke-width": 2.0, + "fill": "#00ff00", + "fill-opacity": 0.25 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 0.0] + ] + ], + [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 2.0] + ] + ] + ] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + // A MultiPolygon must carry polygon styling (fill), not line styling. + assertTrue(feature.style is PolygonStyle) + val style = feature.style as PolygonStyle + + assertEquals(0xFFFF0000.toInt(), style.strokeColor) + assertEquals(2.0f, style.strokeWidth, 0.001f) + + // Fill: green with 0.25 opacity (0.25 * 255 = 63 = 0x3F) + val expectedFillColor = (0x3F shl 24) or 0x00FF00 + assertEquals(expectedFillColor, style.fillColor) + } + + @Test + fun `parse styled multilinestring keeps line style`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "stroke": "#0000ff", + "stroke-width": 3.0 + }, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 1.0] + ], + [ + [102.0, 2.0], + [103.0, 3.0] + ] + ] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + assertTrue(feature.style is LineStyle) + val style = feature.style as LineStyle + assertEquals(0xFF0000FF.toInt(), style.color) + assertEquals(3.0f, style.width!!, 0.001f) + } } From 60bd84a07bb030f301444c7a06198a6be9237916 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:08:22 -0600 Subject: [PATCH 2/3] test(data): expand GeoJSON MultiGeometry test coverage and refine rendering bounds (#1727) - Expand unit, integration, and visual instrumented test coverage across all 4 tiers for GeoJSON MultiPolygon & MultiLineString styling and rendering. - Fix DataLayer.boundingBox calculation to recursively include coordinates from MultiGeometry features. - Fix MapViewRenderer child map object collection for MultiGeometry features so removeFeature() removes all rendered sub-shapes. - Update demo GeoJSON asset with simplestyle MultiPolygon and MultiLineString features and adjust camera bounding box padding in RendererDemoActivity. - Add exponential backoff retry logic for transient 503/429 status codes in GeminiVisualTestHelper. Addresses PR #1727 / Issue #1724. --- .../data/renderer/mapper/GeoJsonMapper.kt | 2 +- .../data/renderer/mapview/MapViewRenderer.kt | 7 +- .../android/data/renderer/model/DataLayer.kt | 62 ++++--- .../data/renderer/GeoJsonIntegrationTest.kt | 166 ++++++++++++++++++ .../data/renderer/MapViewRendererTest.kt | 98 +++++++++++ .../renderer/mapper/GeoJsonStylingTest.kt | 95 +++++++++- .../android/utils/demo/RendererVisualTest.kt | 2 +- demo/src/main/assets/geojson-types.json | 66 ++++++- .../utils/demo/RendererDemoActivity.kt | 3 +- .../visualtesting/GeminiVisualTestHelper.kt | 60 +++++-- 10 files changed, 515 insertions(+), 46 deletions(-) create mode 100644 data/src/test/java/com/google/maps/android/data/renderer/GeoJsonIntegrationTest.kt diff --git a/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt b/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt index 9b720c970..12eb6caa8 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/mapper/GeoJsonMapper.kt @@ -103,7 +103,7 @@ object GeoJsonMapper { ) } else null } - geometry is ModelPolygon || geometry is MultiGeometry -> { + geometry is ModelPolygon || (geometry is MultiGeometry && geometry.isPolygonal()) -> { val strokeColor = props["stroke"]?.let { parseColor(it) } val strokeWidth = props["stroke-width"]?.toFloatOrNull() val fillColor = props["fill"]?.let { parseColor(it) } diff --git a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt index f599e7735..b4f1c5579 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/mapview/MapViewRenderer.kt @@ -174,8 +174,11 @@ class MapViewRenderer( is MultiGeometry -> { feature.geometry.geometries.forEach { geometry -> - // Recursively add each geometry in the MultiGeometry - addFeature(feature.copy(geometry = geometry)) + val childFeature = feature.copy(geometry = geometry) + addFeature(childFeature) + renderedFeatures[childFeature]?.let { childObjects -> + mapObjects.addAll(childObjects) + } } } diff --git a/data/src/main/java/com/google/maps/android/data/renderer/model/DataLayer.kt b/data/src/main/java/com/google/maps/android/data/renderer/model/DataLayer.kt index 0c1d52dba..c5fff38c7 100644 --- a/data/src/main/java/com/google/maps/android/data/renderer/model/DataLayer.kt +++ b/data/src/main/java/com/google/maps/android/data/renderer/model/DataLayer.kt @@ -34,37 +34,53 @@ data class DataLayer( val boundsBuilder = LatLngBounds.builder() var hasPoints = false features.forEach { feature -> - when (val geometry = feature.geometry) { - is PointGeometry -> { - boundsBuilder.include(LatLng(geometry.point.lat, geometry.point.lng)) - hasPoints = true - } + if (includeGeometryPoints(feature.geometry, boundsBuilder)) { + hasPoints = true + } + } + if (hasPoints) boundsBuilder.build() else null + } - is LineString -> { - geometry.points.forEach { - boundsBuilder.include(LatLng(it.lat, it.lng)) - hasPoints = true - } - } + private fun includeGeometryPoints( + geometry: Geometry, + boundsBuilder: LatLngBounds.Builder, + ): Boolean { + var added = false + when (geometry) { + is PointGeometry -> { + boundsBuilder.include(LatLng(geometry.point.lat, geometry.point.lng)) + added = true + } - is Polygon -> { - geometry.outerBoundary.forEach { - boundsBuilder.include(LatLng(it.lat, it.lng)) - hasPoints = true - } + is LineString -> { + geometry.points.forEach { + boundsBuilder.include(LatLng(it.lat, it.lng)) + added = true } + } - is MultiGeometry -> { - // TODO: Implement MultiGeometry bounds calculation if needed + is Polygon -> { + geometry.outerBoundary.forEach { + boundsBuilder.include(LatLng(it.lat, it.lng)) + added = true } + } - is GroundOverlay -> { - boundsBuilder.include(geometry.latLngBounds.northeast) - boundsBuilder.include(geometry.latLngBounds.southwest) - hasPoints = true + is MultiGeometry -> { + geometry.geometries.forEach { subGeom -> + if (includeGeometryPoints(subGeom, boundsBuilder)) { + added = true + } } } + + is GroundOverlay -> { + boundsBuilder.include(geometry.latLngBounds.northeast) + boundsBuilder.include(geometry.latLngBounds.southwest) + added = true + } } - if (hasPoints) boundsBuilder.build() else null + return added } } + diff --git a/data/src/test/java/com/google/maps/android/data/renderer/GeoJsonIntegrationTest.kt b/data/src/test/java/com/google/maps/android/data/renderer/GeoJsonIntegrationTest.kt new file mode 100644 index 000000000..e5028e63e --- /dev/null +++ b/data/src/test/java/com/google/maps/android/data/renderer/GeoJsonIntegrationTest.kt @@ -0,0 +1,166 @@ +/* + * 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.data.renderer + +import com.google.maps.android.data.parser.geojson.GeoJsonParser +import com.google.maps.android.data.renderer.mapper.GeoJsonMapper +import com.google.maps.android.data.renderer.model.LineStyle +import com.google.maps.android.data.renderer.model.MultiGeometry +import com.google.maps.android.data.renderer.model.PolygonStyle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream + +/** + * End-to-end integration tests verifying GeoJSON parsing and mapping into [DataLayer] features. + */ +class GeoJsonIntegrationTest { + + @Test + fun testParseAndMapGeoJsonFeatureCollectionWithMultipleGeometryTypes() { + val geoJsonContent = + """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "Zoned Region", + "stroke": "#ff0000", + "stroke-width": 3.0, + "fill": "#00ff00", + "fill-opacity": 0.4 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 0.0] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "Transit Line", + "stroke": "#0000ff", + "stroke-width": 4.0 + }, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 1.0] + ] + ] + } + } + ] + } + """.trimIndent() + + val parser = GeoJsonParser() + val parsedObject = parser.parse(ByteArrayInputStream(geoJsonContent.toByteArray())) + assertNotNull(parsedObject) + + val layer = GeoJsonMapper.toLayer(parsedObject!!) + assertEquals(2, layer.features.size) + + // Verify MultiPolygon feature + val multiPolygonFeature = layer.features[0] + assertTrue(multiPolygonFeature.geometry is MultiGeometry) + assertTrue(multiPolygonFeature.style is PolygonStyle) + val polygonStyle = multiPolygonFeature.style as PolygonStyle + assertEquals(0xFFFF0000.toInt(), polygonStyle.strokeColor) + assertEquals(3.0f, polygonStyle.strokeWidth, 0.001f) + val expectedFillColor = (0x66 shl 24) or 0x00FF00 // 0.4 * 255 = 102 = 0x66 + assertEquals(expectedFillColor, polygonStyle.fillColor) + + // Verify MultiLineString feature + val multiLineFeature = layer.features[1] + assertTrue(multiLineFeature.geometry is MultiGeometry) + assertTrue(multiLineFeature.style is LineStyle) + val lineStyle = multiLineFeature.style as LineStyle + assertEquals(0xFF0000FF.toInt(), lineStyle.color) + assertEquals(4.0f, lineStyle.width, 0.001f) + } + + @Test + fun testDataLayerBoundingBox_includesMultiGeometryCoordinates() { + val geoJsonContent = + """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-74.0250, 40.7000], + [-74.0100, 40.7000], + [-74.0100, 40.7150], + [-74.0250, 40.7000] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [-73.9700, 40.7450], + [-73.9600, 40.7715] + ] + ] + } + } + ] + } + """.trimIndent() + + val parser = GeoJsonParser() + val parsedObject = parser.parse(ByteArrayInputStream(geoJsonContent.toByteArray())) + assertNotNull(parsedObject) + + val layer = GeoJsonMapper.toLayer(parsedObject!!) + val bounds = layer.boundingBox + assertNotNull(bounds) + + assertEquals(40.7000, bounds!!.southwest.latitude, 0.0001) + assertEquals(-74.0250, bounds.southwest.longitude, 0.0001) + assertEquals(40.7715, bounds.northeast.latitude, 0.0001) + assertEquals(-73.9600, bounds.northeast.longitude, 0.0001) + } +} + diff --git a/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt b/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt index d1ba1c6b5..4234bf04a 100644 --- a/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt +++ b/data/src/test/java/com/google/maps/android/data/renderer/MapViewRendererTest.kt @@ -20,10 +20,14 @@ import com.google.android.gms.maps.model.AdvancedMarkerOptions import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PolygonOptions import com.google.maps.android.data.renderer.mapview.MapViewRenderer import com.google.maps.android.data.renderer.model.Feature +import com.google.maps.android.data.renderer.model.MultiGeometry import com.google.maps.android.data.renderer.model.Point import com.google.maps.android.data.renderer.model.PointGeometry +import com.google.maps.android.data.renderer.model.Polygon +import com.google.maps.android.data.renderer.model.PolygonStyle import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -31,6 +35,11 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Test +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.data.renderer.model.LineString +import com.google.maps.android.data.renderer.model.LineStyle + /** * Unit tests for [MapViewRenderer] verifying correct translation of platform-agnostic * feature models to Google Maps SDK marker options. @@ -143,4 +152,93 @@ class MapViewRendererTest { assertEquals("Critical Right Turn", capturedOptions.title) assertEquals("Be careful here!", capturedOptions.snippet) } + + @Test + fun testAddFeatureMultiPolygon_appliesPolygonStyleToChildPolygons() { + // Given + val mockMap = mockk(relaxed = true) + val mockPolygon = mockk(relaxed = true) + val mockIconProvider = mockk(relaxed = true) + + val optionsSlot = slot() + every { mockMap.addPolygon(capture(optionsSlot)) } returns mockPolygon + + val renderer = MapViewRenderer(mockMap, mockIconProvider) + + val childPolygon = Polygon( + outerBoundary = listOf(Point(0.0, 0.0), Point(1.0, 0.0), Point(1.0, 1.0), Point(0.0, 0.0)), + innerBoundaries = emptyList() + ) + val multiGeometry = MultiGeometry(geometries = listOf(childPolygon)) + val style = PolygonStyle( + fillColor = 0x3F00FF00, + strokeColor = 0xFFFF0000.toInt(), + strokeWidth = 2.0f + ) + val feature = Feature(geometry = multiGeometry, style = style) + + // When + renderer.addFeature(feature) + + // Then + verify(exactly = 1) { mockMap.addPolygon(any()) } + val capturedOptions = optionsSlot.captured + assertEquals(0x3F00FF00, capturedOptions.fillColor) + assertEquals(0xFFFF0000.toInt(), capturedOptions.strokeColor) + assertEquals(2.0f, capturedOptions.strokeWidth, 0.001f) + } + + @Test + fun testAddFeatureMultiLineString_appliesLineStyleToChildLines() { + // Given + val mockMap = mockk(relaxed = true) + val mockPolyline = mockk(relaxed = true) + val mockIconProvider = mockk(relaxed = true) + + val optionsSlot = slot() + every { mockMap.addPolyline(capture(optionsSlot)) } returns mockPolyline + + val renderer = MapViewRenderer(mockMap, mockIconProvider) + + val childLine = LineString(points = listOf(Point(0.0, 0.0), Point(1.0, 1.0))) + val multiGeometry = MultiGeometry(geometries = listOf(childLine)) + val style = LineStyle(color = 0xFF0000FF.toInt(), width = 3.0f) + val feature = Feature(geometry = multiGeometry, style = style) + + // When + renderer.addFeature(feature) + + // Then + verify(exactly = 1) { mockMap.addPolyline(any()) } + val capturedOptions = optionsSlot.captured + assertEquals(0xFF0000FF.toInt(), capturedOptions.color) + assertEquals(3.0f, capturedOptions.width, 0.001f) + } + + @Test + fun testRemoveFeature_removesRenderedObjects() { + // Given + val mockMap = mockk(relaxed = true) + val mockPolygon = mockk(relaxed = true) + val mockIconProvider = mockk(relaxed = true) + + every { mockMap.addPolygon(any()) } returns mockPolygon + + val renderer = MapViewRenderer(mockMap, mockIconProvider) + + val childPolygon = Polygon( + outerBoundary = listOf(Point(0.0, 0.0), Point(1.0, 0.0), Point(1.0, 1.0), Point(0.0, 0.0)), + innerBoundaries = emptyList() + ) + val multiGeometry = MultiGeometry(geometries = listOf(childPolygon)) + val feature = Feature(geometry = multiGeometry, style = PolygonStyle(fillColor = 0xFF00FF00.toInt())) + + renderer.addFeature(feature) + + // When + renderer.removeFeature(feature) + + // Then + verify(exactly = 1) { mockPolygon.remove() } + } } diff --git a/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt b/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt index 652694669..ac3087953 100644 --- a/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt +++ b/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt @@ -201,6 +201,99 @@ class GeoJsonStylingTest { assertTrue(feature.style is LineStyle) val style = feature.style as LineStyle assertEquals(0xFF0000FF.toInt(), style.color) - assertEquals(3.0f, style.width!!, 0.001f) + assertEquals(3.0f, style.width, 0.001f) + } + + @Test + fun `parse styled multipolygon with stroke opacity applies opacity to both stroke and fill`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "stroke": "#ff0000", + "stroke-width": 4.0, + "stroke-opacity": 0.5, + "fill": "#00ff00", + "fill-opacity": 0.5 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 0.0] + ] + ] + ] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + assertTrue(feature.style is PolygonStyle) + val style = feature.style as PolygonStyle + + // Alpha 0.5 * 255 = 127 (0x7F) + val expectedStrokeColor = (0x7F shl 24) or 0xFF0000 + val expectedFillColor = (0x7F shl 24) or 0x00FF00 + + assertEquals(expectedStrokeColor, style.strokeColor) + assertEquals(expectedFillColor, style.fillColor) + assertEquals(4.0f, style.strokeWidth, 0.001f) + } + + @Test + fun `parse styled geometry collection containing mixed elements defaults to line style`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "stroke": "#0000ff", + "stroke-width": 2.0 + }, + "geometry": { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 0.0] + ] + ] + }, + { + "type": "LineString", + "coordinates": [ + [100.0, 0.0], + [101.0, 1.0] + ] + } + ] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + assertTrue(feature.style is LineStyle) + val style = feature.style as LineStyle + assertEquals(0xFF0000FF.toInt(), style.color) + assertEquals(2.0f, style.width, 0.001f) } } diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.kt index 8844e5967..f81662dd6 100644 --- a/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.kt +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.kt @@ -62,7 +62,7 @@ class RendererVisualTest : RendererVisualTestBase() { clickButton("Complex GeoJSON") collapseBottomSheet() verifyMapContent( - "Does the map show at least two red push pins a black line drawn near Lower Manhattan, a black trapezoid drawn around the Central Park Zoo and a short black line connected to the southern most red push pin?", + "Does the map display the GeoJSON overlay features including red markers, orange island polygons in lower Manhattan, purple corridor lines in midtown, and a green polygon near Central Park?", ) } diff --git a/demo/src/main/assets/geojson-types.json b/demo/src/main/assets/geojson-types.json index 2660f7b8e..69cf34c6b 100644 --- a/demo/src/main/assets/geojson-types.json +++ b/demo/src/main/assets/geojson-types.json @@ -16,7 +16,9 @@ "type": "Feature", "properties": { "name": "Main Street", - "type": "LineString" + "type": "LineString", + "stroke": "#0000FF", + "stroke-width": 3.0 }, "geometry": { "type": "LineString", @@ -31,7 +33,11 @@ "type": "Feature", "properties": { "name": "Central Park", - "type": "Polygon" + "type": "Polygon", + "stroke": "#006600", + "stroke-width": 2.0, + "fill": "#00FF00", + "fill-opacity": 0.3 }, "geometry": { "type": "Polygon", @@ -46,6 +52,62 @@ ] } }, + { + "type": "Feature", + "properties": { + "name": "Island Districts (MultiPolygon)", + "type": "MultiPolygon", + "stroke": "#FF0000", + "stroke-width": 3.0, + "fill": "#FF8800", + "fill-opacity": 0.4 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-74.0150, 40.7000], + [-74.0100, 40.7000], + [-74.0100, 40.7050], + [-74.0150, 40.7050], + [-74.0150, 40.7000] + ] + ], + [ + [ + [-74.0250, 40.7100], + [-74.0200, 40.7100], + [-74.0200, 40.7150], + [-74.0250, 40.7150], + [-74.0250, 40.7100] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "Express Corridors (MultiLineString)", + "type": "MultiLineString", + "stroke": "#9900FF", + "stroke-width": 4.0 + }, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [-73.9900, 40.7300], + [-73.9800, 40.7400] + ], + [ + [-73.9700, 40.7450], + [-73.9600, 40.7550] + ] + ] + } + }, { "type": "Feature", "properties": { diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt index c49bbe6e0..7843af78a 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt @@ -263,7 +263,8 @@ class RendererDemoActivity : layer.boundingBox?.let { bounds -> try { - map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)) + val padding = (120 * resources.displayMetrics.density).toInt() + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)) } catch (e: Exception) { e.printStackTrace() } diff --git a/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt index 835baf29b..b4c84454f 100644 --- a/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt +++ b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt @@ -95,22 +95,37 @@ class GeminiVisualTestHelper { // Use a simpler text-only model for this task as no image is involved. // The user mentioned "gemini-flash 2.5" works, but we should use a standard name. - val modelName = "gemini-2.5-flash" + val modelName = "gemini-2.5-flash-lite" val request = GeminiRequest(contents = listOf(Content(parts = listOf(Part(text = fullPrompt))))) - val response: HttpResponse = - client.post("https://generativelanguage.googleapis.com/v1/models/$modelName:generateContent?key=$apiKey") { + var attempts = 0 + var response: HttpResponse? = null + while (attempts < 3) { + attempts++ + response = client.post("https://generativelanguage.googleapis.com/v1/models/$modelName:generateContent?key=$apiKey") { contentType(ContentType.Application.Json) setBody(request) } + if (response.status == HttpStatusCode.OK) { + break + } + if (response.status == HttpStatusCode.ServiceUnavailable || response.status == HttpStatusCode.TooManyRequests) { + Log.w("GeminiVisualTestHelper", "Transient Action API error ${response.status}. Retrying attempt $attempts/3...") + kotlinx.coroutines.delay(2000L * attempts) + } else { + break + } + } + + val finalResponse = response ?: throw Exception("Gemini Action API request failed to produce a response.") - if (response.status != HttpStatusCode.OK) { - val errorBody = response.bodyAsText() - Log.e("GeminiVisualTestHelper", "Action API Error: ${response.status} $errorBody") - throw Exception("Gemini Action API returned an error: ${response.status}\n$errorBody") + if (finalResponse.status != HttpStatusCode.OK) { + val errorBody = finalResponse.bodyAsText() + Log.e("GeminiVisualTestHelper", "Action API Error: ${finalResponse.status} $errorBody") + throw Exception("Gemini Action API returned an error: ${finalResponse.status}\n$errorBody") } - val geminiResponse: GeminiResponse = response.body() + val geminiResponse: GeminiResponse = finalResponse.body() val actionJson = geminiResponse.candidates .firstOrNull() @@ -210,19 +225,34 @@ class GeminiVisualTestHelper { ), ) - val response: HttpResponse = - client.post("https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=$apiKey") { + var attempts = 0 + var response: HttpResponse? = null + while (attempts < 3) { + attempts++ + response = client.post("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=$apiKey") { contentType(ContentType.Application.Json) setBody(request) } + if (response.status == HttpStatusCode.OK) { + break + } + if (response.status == HttpStatusCode.ServiceUnavailable || response.status == HttpStatusCode.TooManyRequests) { + Log.w("GeminiVisualTestHelper", "Transient API error ${response.status}. Retrying attempt $attempts/3...") + kotlinx.coroutines.delay(2000L * attempts) + } else { + break + } + } + + val finalResponse = response ?: throw Exception("Gemini API request failed to produce a response.") - if (response.status != HttpStatusCode.OK) { - val errorBody = response.bodyAsText() - Log.e("GeminiVisualTestHelper", "API Error: ${response.status} $errorBody") - throw Exception("Gemini API returned an error: ${response.status}\n$errorBody") + if (finalResponse.status != HttpStatusCode.OK) { + val errorBody = finalResponse.bodyAsText() + Log.e("GeminiVisualTestHelper", "API Error: ${finalResponse.status} $errorBody") + throw Exception("Gemini API returned an error: ${finalResponse.status}\n$errorBody") } - val geminiResponse: GeminiResponse = response.body() + val geminiResponse: GeminiResponse = finalResponse.body() if (geminiResponse.candidates.isEmpty()) { val rawBody = response.bodyAsText() From 4e94b9a6cf5425962391afd564027d3405d39bfb Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:30:26 -0600 Subject: [PATCH 3/3] test(data): add edge case unit tests for fill-only MultiPolygon, empty MultiGeometry, and MultiPoint --- .../renderer/mapper/GeoJsonStylingTest.kt | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt b/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt index ac3087953..57645a0f0 100644 --- a/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt +++ b/data/src/test/java/com/google/maps/android/data/renderer/mapper/GeoJsonStylingTest.kt @@ -296,4 +296,94 @@ class GeoJsonStylingTest { assertEquals(0xFF0000FF.toInt(), style.color) assertEquals(2.0f, style.width, 0.001f) } + + @Test + fun `parse styled multipolygon with fill only creates polygon style`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "fill": "#ff0000", + "fill-opacity": 0.5 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 0.0] + ] + ] + ] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + assertTrue(feature.style is PolygonStyle) + val style = feature.style as PolygonStyle + val expectedFillColor = (0x7F shl 24) or 0xFF0000 + assertEquals(expectedFillColor, style.fillColor) + } + + @Test + fun `parse empty multipolygon does not crash`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "stroke": "#ff0000" + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + // Empty geometries list should be handled safely without throwing exceptions + org.junit.Assert.assertNotNull(feature) + } + + @Test + fun `parse styled multipoint handles style gracefully`() { + val geoJson = + """ + { + "type": "Feature", + "properties": { + "title": "Cluster Points" + }, + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [100.0, 0.0], + [101.0, 1.0] + ] + } + } + """.trimIndent() + + val parser = GeoJsonParser() + val geoJsonObject = parser.parse(ByteArrayInputStream(geoJson.toByteArray()))!! + val layer = GeoJsonMapper.toLayer(geoJsonObject) + val feature = layer.features.first() + + org.junit.Assert.assertNotNull(feature) + } } +