From a22f5eca8cb01a86429f142e4efb49f3f405c008 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 30 Jun 2026 17:13:35 +0200 Subject: [PATCH 1/4] add isClosed, canRedo and hasSelfIntersection to PolygonDrawingSession State --- .../tasks/polygon/PolygonDrawingSession.kt | 9 +++++-- .../polygon/PolygonDrawingSessionImpl.kt | 9 ++++++- .../polygon/PolygonDrawingSessionTest.kt | 27 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt index ea7be15052..b97197d9ff 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt @@ -19,8 +19,13 @@ import org.groundplatform.domain.model.geometry.Coordinates /** Encapsulates the state and basic operations for a polygon drawing session. */ interface PolygonDrawingSession { - - data class State(val isTooClose: Boolean = false, val isMarkedComplete: Boolean = false) + data class State( + val isTooClose: Boolean = false, + val isMarkedComplete: Boolean = false, + val isClosed: Boolean = false, + val canRedo: Boolean = false, + val hasSelfIntersection: Boolean = false, + ) /** The current state of the session. */ val state: State diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt index 375b7bdafb..21c7960331 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt @@ -27,7 +27,14 @@ class PolygonDrawingSessionImpl : PolygonDrawingSession { private var _isMarkedComplete: Boolean = false override val state: PolygonDrawingSession.State - get() = PolygonDrawingSession.State(_isTooClose, _isMarkedComplete) + get() = + PolygonDrawingSession.State( + isTooClose = _isTooClose, + isMarkedComplete = _isMarkedComplete, + isClosed = LineString(_vertices).isClosed(), + canRedo = redoVertexStack.isNotEmpty(), + hasSelfIntersection = isSelfIntersecting(_vertices), + ) private var _vertices: List = listOf() diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionTest.kt index 354fdd50d1..ec8cc8986e 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionTest.kt @@ -233,6 +233,33 @@ class PolygonDrawingSessionTest { assertThat(session.state.isMarkedComplete).isFalse() } + @Test + fun `state exposes isClosed based on the current geometry`() { + session.setVertices(listOf(C1, C3, C2)) + assertThat(session.state.isClosed).isFalse() + + session.setVertices(listOf(C1, C3, C2, C4, C1)) + assertThat(session.state.isClosed).isTrue() + } + + @Test + fun `state exposes canRedo based on the redo stack`() { + session.setVertices(listOf(C1, C2)) + assertThat(session.state.canRedo).isFalse() + + session.removeLastVertex() + assertThat(session.state.canRedo).isTrue() + } + + @Test + fun `state exposes hasSelfIntersection based on the current geometry`() { + session.setVertices(listOf(C1, C2, C3, C4)) + assertThat(session.state.hasSelfIntersection).isTrue() + + session.removeLastVertex() + assertThat(session.state.hasSelfIntersection).isFalse() + } + companion object { private const val DISTANCE_BELOW_THRESHOLD = 20.0 private const val DISTANCE_ABOVE_THRESHOLD = 30.0 From 3e6190d370a2b78b9d848e96229c9afcf7d1fd26 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 30 Jun 2026 17:15:20 +0200 Subject: [PATCH 2/4] remove draftUpdates and rely solely on drawArea to keep track of current draw area state --- .../tasks/polygon/DrawAreaTaskMapFragment.kt | 2 - .../tasks/polygon/DrawAreaTaskViewModel.kt | 70 ++++++------------- .../tasks/polygon/PolygonDrawingSession.kt | 3 - .../polygon/PolygonDrawingSessionImpl.kt | 2 - .../android/ui/map/MapFragment.kt | 3 - .../android/ui/map/gms/GoogleMapsFragment.kt | 4 -- .../ui/map/gms/features/FeatureManager.kt | 26 +++---- .../ui/map/gms/features/MapsItemManager.kt | 19 +++-- 8 files changed, 49 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt index c803cd382c..93c537b4aa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskMapFragment.kt @@ -52,8 +52,6 @@ class DrawAreaTaskMapFragment @Inject constructor() : } } - launch { taskViewModel.draftUpdates.collect { map.updateFeature(it) } } - launch { taskViewModel.cameraMoveEvents.collect { coordinates -> moveToPosition(coordinates) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt index 63564c1a59..b319789362 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt @@ -21,15 +21,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -90,24 +87,6 @@ internal constructor( */ private var draftTag: Feature.Tag? = null - /** - * Emits incremental updates to the currently drawn draft feature (e.g., polygon or line string). - * - * The flow sends partial geometry updates—such as when a new vertex is added or moved— allowing - * the map UI to update the in-progress shape in real-time. - * - * Uses [MutableSharedFlow] with a small buffer to avoid missing updates during rapid emissions. - */ - private val _draftUpdates = MutableSharedFlow(extraBufferCapacity = 1) - - /** - * Public read-only access to the stream of draft feature updates. - * - * UI components (e.g., map fragments) collect from this flow to render live geometry updates as - * the user draws or modifies a shape. - */ - val draftUpdates = _draftUpdates.asSharedFlow() - /** Channel for one-shot camera movement events. Fragment collects to move map. */ private val _cameraMoveEvents = Channel(Channel.CONFLATED) val cameraMoveEvents = _cameraMoveEvents.receiveAsFlow() @@ -131,27 +110,21 @@ internal constructor( lateinit var measurementUnits: MeasurementUnits override val taskActionButtonStates: StateFlow> by lazy { - combine(taskTaskData, merge(draftArea, draftUpdates), sessionState) { - taskData, - currentFeature, - sessionState -> - val isClosed = (currentFeature?.geometry as? LineString)?.isClosed() ?: false + combine(taskTaskData, sessionState) { taskData, state -> listOfNotNull( getPreviousButton(), getSkipButton(taskData), getUndoButton(taskData, true), - getRedoButton(), - getAddPointButton(isClosed, sessionState.isTooClose), - getCompleteButton(isClosed, sessionState.isMarkedComplete), - getNextButton(taskData).takeIf { sessionState.isMarkedComplete }, + getRedoButton(state.canRedo), + getAddPointButton(state.isClosed, state.isTooClose), + getCompleteButton(state.isClosed, state.isMarkedComplete, state.hasSelfIntersection), + getNextButton(taskData).takeIf { state.isMarkedComplete }, ) } .distinctUntilChanged() .stateIn(viewModelScope, WhileSubscribed(5_000), emptyList()) } - - override fun initialize( job: Job, task: Task, @@ -247,7 +220,7 @@ internal constructor( } fun redoLastVertex() { - if (!session.canRedo()) { + if (!_sessionState.value.canRedo) { Timber.e("redoVertexStack is already empty") return } @@ -281,6 +254,7 @@ internal constructor( val intersected = session.checkVertexIntersection() if (intersected) { onSelfIntersectionDetected() + syncSessionState() refreshMap() } return intersected @@ -296,6 +270,7 @@ internal constructor( private fun updateVertices(newVertices: List) { session.setVertices(newVertices) + syncSessionState() refreshMap() } @@ -316,12 +291,9 @@ internal constructor( * This function is responsible for keeping the map view in sync with the user's drawing * interactions: * - When vertices are empty → clears the current draft from the map. - * - On the first vertex → creates a new [Feature] and emits it to [_draftArea]. + * - On the first vertex → creates a new [Feature.Tag]. * - On subsequent updates → reuses the same [Feature.Tag] and emits updated geometry through - * [_draftUpdates] for in-place map updates. - * - * The goal is to ensure smooth, flicker-free rendering by avoiding unnecessary feature - * re-creation. Only the geometry and style of the active draft are updated in place on the map. + * [_draftArea]. * * This coroutine runs on [viewModelScope] to ensure lifecycle safety. */ @@ -330,14 +302,12 @@ internal constructor( _draftArea.emit(null) draftTag = null } else { - if (draftTag == null) { - val feature = buildPolygonFeature() + val isFirstFeature = draftTag == null + val feature = buildPolygonFeature(id = draftTag?.id) + if (isFirstFeature) { draftTag = feature.tag - _draftArea.emit(feature) - } else { - val feature = buildPolygonFeature(id = draftTag!!.id) - _draftUpdates.tryEmit(feature) } + _draftArea.emit(feature) } } @@ -372,8 +342,8 @@ internal constructor( vibrationHelper.vibrate() } - private fun getRedoButton(): ButtonActionState = - ButtonActionState(action = ButtonAction.REDO, isEnabled = session.canRedo(), isVisible = true) + private fun getRedoButton(canRedo: Boolean): ButtonActionState = + ButtonActionState(action = ButtonAction.REDO, isEnabled = canRedo, isVisible = true) private fun getAddPointButton(isPolygonClosed: Boolean, isTooClose: Boolean): ButtonActionState = ButtonActionState( @@ -382,10 +352,14 @@ internal constructor( isVisible = !isPolygonClosed, ) - private fun getCompleteButton(isClosed: Boolean, isMarkedComplete: Boolean): ButtonActionState = + private fun getCompleteButton( + isClosed: Boolean, + isMarkedComplete: Boolean, + hasSelfIntersection: Boolean, + ): ButtonActionState = ButtonActionState( action = ButtonAction.COMPLETE, - isEnabled = isClosed && !isMarkedComplete && !session.hasSelfIntersection, + isEnabled = isClosed && !isMarkedComplete && !hasSelfIntersection, isVisible = isClosed && !isMarkedComplete, ) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt index b97197d9ff..2cae8c9267 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSession.kt @@ -96,9 +96,6 @@ interface PolygonDrawingSession { */ fun redoLastVertex(): Coordinates? - /** Checks if there are any vertices in the redo stack. */ - fun canRedo(): Boolean - companion object { /** Min. distance in dp between two points for them be considered as overlapping. */ const val DISTANCE_THRESHOLD_DP = 24 diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt index 21c7960331..fe6112999e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/PolygonDrawingSessionImpl.kt @@ -129,6 +129,4 @@ class PolygonDrawingSessionImpl : PolygonDrawingSession { _vertices = (_vertices + redoVertex).toImmutableList() return redoVertex } - - override fun canRedo(): Boolean = redoVertexStack.isNotEmpty() } diff --git a/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt index 8774ce7300..49775b2599 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt @@ -95,9 +95,6 @@ interface MapFragment { /** Update the set of map [Feature]s present on the map. */ fun setFeatures(newFeatures: Set) - /** Updates an existing [Feature] present on the map. */ - fun updateFeature(feature: Feature) - /** Returns the actual distance in pixels between provided [Coordinates]s. */ fun getDistanceInPixels(coordinates1: Coordinates, coordinates2: Coordinates): Double diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt index 2f5aaabc49..fe6aedcd22 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt @@ -242,10 +242,6 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { featureManager.setFeatures(newFeatures) } - override fun updateFeature(feature: Feature) { - featureManager.update(feature) - } - private fun onCameraIdle() { val cameraPosition = map.cameraPosition val projection = map.projection diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt index ed19a1466f..4397e979b9 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/FeatureManager.kt @@ -97,6 +97,14 @@ constructor( * map as needed to sync the map state with the provided collection. */ fun setFeatures(updatedFeatures: Collection) { + updatedFeatures.forEach { feature -> + val existingFeature = featuresByTag[feature.tag] + // A non-clustered feature whose tag already exists but whose contents changed can be moved in + // place, instead of being removed and re-added (which flickers). + val shouldUpdate = + existingFeature != null && existingFeature != feature && !feature.clusterable + if (shouldUpdate) update(existing = existingFeature, updated = feature) + } // remove stale val removedOrChanged = features - updatedFeatures.toSet() removedOrChanged.forEach(this::remove) @@ -137,19 +145,13 @@ constructor( } /** Updates the existing feature on the map with it's new properties (geometry, styling, etc). */ - fun update(feature: Feature) = - with(feature) { - val prevFeature = featuresByTag[tag] - if (prevFeature == null) { - Timber.e("Feature not found for update: $tag") - return - } + private fun update(existing: Feature, updated: Feature) { + if (!mapsItemManager.update(updated)) return - features.remove(prevFeature) - features.add(this) - mapsItemManager.update(this) - featuresByTag[tag] = this - } + features.remove(existing) + features.add(updated) + featuresByTag[updated.tag] = updated + } fun onCameraIdle() { clusterManager.onCameraIdle() diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt index f851fc022c..c0687139e8 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/MapsItemManager.kt @@ -27,6 +27,7 @@ import org.groundplatform.domain.model.geometry.LineString import org.groundplatform.domain.model.geometry.MultiPolygon import org.groundplatform.domain.model.geometry.Point import org.groundplatform.domain.model.geometry.Polygon +import timber.log.Timber /** Manages [Feature]s displayed on the map as Maps SDK items (marker, polyline, etc). */ class MapsItemManager( @@ -71,14 +72,20 @@ class MapsItemManager( } } - /** Updates an already-rendered feature with new geometry and style. */ - fun update(feature: Feature) = + /** + * Updates an already-rendered feature with new geometry and style. Logs an error and returns + * false if the item can't be updated. + */ + fun update(feature: Feature): Boolean = with(feature) { - itemsByTag[feature.tag]?.forEach { - if (it is Polyline) { - lineStringRenderer.update(map, it, geometry as LineString, tooltipText) + val items = itemsByTag[tag] ?: return false + items.all { item -> + if (item is Polyline) { + lineStringRenderer.update(map, item, geometry as LineString, tooltipText) + true } else { - error("Unsupported map feature: ${it::class.java}") + Timber.e("Unsupported map feature: ${item::class.java}") + false } } } From 4de2cc422e23c1183b87ed70ea862c473d92a6a5 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 30 Jun 2026 17:15:27 +0200 Subject: [PATCH 3/4] add unit tests --- .../polygon/DrawAreaTaskViewModelTest.kt | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt index 6843f3385e..c6ea2af3f2 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt @@ -29,11 +29,12 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.data.local.LocalValueStore @@ -71,7 +72,7 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { override fun setUp() { super.setUp() - mergedFeatureFlow = merge(viewModel.draftArea.filterNotNull(), viewModel.draftUpdates) + mergedFeatureFlow = viewModel.draftArea.filterNotNull() mergedFeatureLiveData = mergedFeatureFlow.asLiveData() featureTestObserver = TestObserver.test(mergedFeatureLiveData) @@ -264,72 +265,97 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { } } - @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `First feature is emitted on draftArea and not on draftUpdates`() = runWithTestDispatcher { + fun `First feature is emitted on draftArea`() = runWithTestDispatcher { setupViewModel() updateLastVertexAndAdd(COORDINATE_1) updateLastVertexAndAdd(COORDINATE_2) + advanceUntilIdle() - viewModel.draftArea.test { - val first = awaitItem() - assertThat(first).isNotNull() - assertThat(first!!.geometry).isInstanceOf(LineString::class.java) - cancelAndIgnoreRemainingEvents() - } + val feature = viewModel.draftArea.value + assertThat(feature).isNotNull() + assertThat(feature!!.geometry).isInstanceOf(LineString::class.java) + } - viewModel.draftUpdates.test { - expectNoEvents() - cancelAndIgnoreRemainingEvents() - } + @Test + fun `Tooltip is null for a single vertex`() = runWithTestDispatcher { + setupViewModel() + updateLastVertexAndAdd(COORDINATE_1) + advanceUntilIdle() + + assertThat(viewModel.draftArea.value!!.tooltipText).isNull() } - @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `Subsequent vertex move emits in-place update on draftUpdates with stable tag`() = + fun `Subsequent vertex move updates draftArea geometry while keeping a stable tag`() = runWithTestDispatcher { setupViewModel() updateLastVertexAndAdd(COORDINATE_1) updateLastVertexAndAdd(COORDINATE_2) advanceUntilIdle() - val initialTag = viewModel.draftArea.first()!!.tag + val initialTag = viewModel.draftArea.value!!.tag - viewModel.draftUpdates.test { - updateLastVertex(Coordinates(15.0, 15.0), isNearFirstVertex = false) - advanceUntilIdle() + updateLastVertex(Coordinates(15.0, 15.0), isNearFirstVertex = false) + advanceUntilIdle() - val upd = awaitItem() - assertThat(upd.tag).isEqualTo(initialTag) - val ls = upd.geometry as LineString - assertThat(ls.coordinates.last()).isEqualTo(Coordinates(15.0, 15.0)) - assertThat(upd.tooltipText).isNotNull() - cancelAndIgnoreRemainingEvents() - } + val updated = viewModel.draftArea.value!! + assertThat(updated.tag).isEqualTo(initialTag) + val ls = updated.geometry as LineString + assertThat(ls.coordinates.last()).isEqualTo(Coordinates(15.0, 15.0)) + assertThat(updated.tooltipText).isNotNull() } - @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `Tooltip updates along with in-place geometry updates`() = runWithTestDispatcher { + fun `draftArea always holds the full current geometry`() = runWithTestDispatcher { setupViewModel() updateLastVertexAndAdd(COORDINATE_1) advanceUntilIdle() - - val firstFeature = viewModel.draftArea.first() - val firstLine = firstFeature!!.geometry as LineString - assertThat(firstLine.coordinates.size).isEqualTo(1) - assertThat(firstFeature.tooltipText).isNull() + val firstLine = viewModel.draftArea.value!!.geometry as LineString + val firstSize = firstLine.coordinates.size updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_3) advanceUntilIdle() - val secondFeature = viewModel.draftArea.first() - val secondLine = secondFeature!!.geometry as LineString - assertThat(secondLine.coordinates.size).isEqualTo(1) - assertThat(secondFeature.tooltipText).isNull() - viewModel.removeLastVertex() + val secondLine = viewModel.draftArea.value!!.geometry as LineString + assertThat(secondLine.coordinates.size).isGreaterThan(firstSize) + assertThat(secondLine.coordinates).contains(COORDINATE_3) } + @Test + fun `Button states survive subscriber teardown and re-subscription after completion`() = + runWithTestDispatcher { + setupViewModel() + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_3) + updateLastVertex(COORDINATE_4, isNearFirstVertex = true) + viewModel.onButtonClick(ButtonAction.COMPLETE) + advanceUntilIdle() + assertThat(viewModel.isMarkedComplete()).isTrue() + + // First subscriber + viewModel.taskActionButtonStates.test { + assertThat(awaitItem()).isNotEmpty() + cancelAndIgnoreRemainingEvents() + } + + // No subscribers for longer than the WhileSubscribed(5_000) timeout + advanceTimeBy(6.seconds) + advanceUntilIdle() + + // Recreated fragment re-subscribes. + val states = viewModel.taskActionButtonStates.first() + + with(requireNotNull(states.find { it.action == ButtonAction.ADD_POINT })) { + assertFalse(isVisible) + } + with(requireNotNull(states.find { it.action == ButtonAction.NEXT })) { + assertTrue(isVisible) + } + } + @Test fun `isTooClose is false if only one vertex`() { setupViewModel() From 7e898134b6511cc790869aa8bc1add85ff3bc888 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 1 Jul 2026 11:35:51 +0200 Subject: [PATCH 4/4] add unit tests for MapsItemManager --- .../map/gms/features/MapsItemManagerTest.kt | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt new file mode 100644 index 0000000000..a28e6355e5 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/features/MapsItemManagerTest.kt @@ -0,0 +1,101 @@ +/* + * 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 + * + * https://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 org.groundplatform.android.ui.map.gms.features + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.Polyline +import com.google.common.truth.Truth.assertThat +import org.groundplatform.android.ui.map.Feature +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.LineString +import org.groundplatform.domain.model.geometry.Point +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MapsItemManagerTest { + private val map: GoogleMap = mock() + private val pointRenderer: PointRenderer = mock() + private val polygonRenderer: PolygonRenderer = mock() + private val lineStringRenderer: LineStringRenderer = mock() + + private lateinit var mapsItemManager: MapsItemManager + + @Before + fun setUp() { + mapsItemManager = MapsItemManager(map, pointRenderer, polygonRenderer, lineStringRenderer) + } + + @Test + fun `update() returns false when the feature tag has no rendered items`() { + assertThat(mapsItemManager.update(TEST_LINE_STRING_FEATURE)).isFalse() + verify(lineStringRenderer, never()).update(any(), any(), any(), anyOrNull()) + } + + @Test + fun `update() updates a rendered line string and returns true`() { + val polyline = mock() + whenever(lineStringRenderer.add(any(), any(), any(), any(), any(), any(), anyOrNull())) + .thenReturn(polyline) + mapsItemManager.put(TEST_LINE_STRING_FEATURE, visible = true) + + val moved = + TEST_LINE_STRING_FEATURE.copy( + geometry = LineString(listOf(Coordinates(0.0, 0.0), Coordinates(1.0, 1.0))) + ) + val result = mapsItemManager.update(moved) + + assertThat(result).isTrue() + verify(lineStringRenderer) + .update(eq(map), eq(polyline), eq(moved.geometry as LineString), anyOrNull()) + } + + @Test + fun `update() returns false for unsupported item types`() { + whenever(pointRenderer.add(any(), any(), any(), any(), any(), any(), anyOrNull())) + .thenReturn(mock()) + mapsItemManager.put(TEST_POINT_FEATURE, visible = true) + + assertThat(mapsItemManager.update(TEST_POINT_FEATURE)).isFalse() + } + + private companion object { + val TEST_LINE_STRING_FEATURE = + Feature( + tag = Feature.Tag("line", Feature.Type.USER_POLYGON), + geometry = LineString(listOf(Coordinates(0.0, 0.0), Coordinates(1.0, 1.0))), + style = Feature.Style(0), + clusterable = false, + ) + val TEST_POINT_FEATURE = + Feature( + tag = Feature.Tag("point", Feature.Type.USER_POINT), + geometry = Point(Coordinates(0.0, 0.0)), + style = Feature.Style(0), + clusterable = false, + ) + } +}