From a31dfec7183cf0f415b1636cb62602fd45f374c6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 12 Nov 2026 22:18:49 -0800 Subject: [PATCH] Add onAddStart/onAddProgress/onAddEnd to Markers, Circles, Polylines, Polygons A list of thousands shows nothing until it is drawn; these say the work is going on. They count the states a list brings by the same moment as each state's onAdded, report progress at most every 100 ms, and start over when a new list adds states. The same as the js-sdk-react collections. --- api/android-sdk-compose.api | 8 +- .../com/mapconductor/compose/AddProgress.kt | 173 ++++++++++++++++++ .../compose/circle/CircleCompose.kt | 31 +++- .../compose/marker/MarkerCompose.kt | 34 +++- .../compose/polygon/PolygonComponent.kt | 43 ++++- .../compose/polyline/PolylineComponent.kt | 31 +++- .../compose/AddProgressTrackerTest.kt | 117 ++++++++++++ 7 files changed, 410 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/mapconductor/compose/AddProgress.kt create mode 100644 src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 10b1b6d..7844c40 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -17,7 +17,7 @@ public final class com.mapconductor.compose.OverlayProviderKt { } public final class com.mapconductor.compose.circle.CircleComposeKt { public static final void Circle(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleState, androidx.compose.runtime.Composer, int); - public static final void Circles(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Circles(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void CircleTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleTilingOptions, androidx.compose.runtime.Composer, int); public static final void Circle-6pqZqJ8(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoPointInterface, double, java.lang.String, boolean, boolean, long, float, long, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); } @@ -80,7 +80,7 @@ public final class com.mapconductor.compose.marker.MarkerAnimationLayerKt { } public final class com.mapconductor.compose.marker.MarkerComposeKt { public static final void Marker(com.mapconductor.compose.MapViewScope, com.mapconductor.core.marker.MarkerState, androidx.compose.runtime.Composer, int); - public static final void Markers(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Markers(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Marker(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.lang.Integer, boolean, boolean, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.compose.polygon.LocalPolygonCollectorKt { @@ -88,7 +88,7 @@ public final class com.mapconductor.compose.polygon.LocalPolygonCollectorKt { } public final class com.mapconductor.compose.polygon.PolygonComponentKt { public static final void Polygon(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polygon.PolygonState, androidx.compose.runtime.Composer, int); - public static final void Polygons(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Polygons(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Polygon-YoVAMyA(com.mapconductor.compose.MapViewScope, java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); public static final void Polygon-IvR-a-0(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, long, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); } @@ -97,7 +97,7 @@ public final class com.mapconductor.compose.polyline.LocalPolylineCollectorKt { } public final class com.mapconductor.compose.polyline.PolylineComponentKt { public static final void Polyline(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineState, androidx.compose.runtime.Composer, int); - public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void PolylineTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineTilingOptions, androidx.compose.runtime.Composer, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, java.util.List, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); diff --git a/src/main/java/com/mapconductor/compose/AddProgress.kt b/src/main/java/com/mapconductor/compose/AddProgress.kt new file mode 100644 index 0000000..78fb58f --- /dev/null +++ b/src/main/java/com/mapconductor/compose/AddProgress.kt @@ -0,0 +1,173 @@ +package com.mapconductor.compose + +import android.os.SystemClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.mapconductor.core.ComponentState +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.OverlayAddedListeners +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * How far a collection (`Markers`, `Circles`, `Polylines`, `Polygons`) is with + * putting its states on the map: for a list of thousands the screen shows + * nothing until they are drawn, and these say that work is going on. + * + * "On the map" is what each state's `onAdded` means: drawn natively, or, for a + * state drawn in tiles, its tiles handed to the map (their images may still be + * loading). + * + * - `onAddStart(total)`: a list brought states that are not on the map yet; + * `total` is how many. + * - `onAddProgress(added, total)`: how many of them are on the map so far, at + * most every 100 ms, and once more with `added == total` before the end. + * - `onAddEnd(total)`: all of them are on the map. A state taken out of the + * list before it got there no longer counts against the end. + * + * A list that brings new states while an earlier one is still being added + * starts over: a new `onAddStart` with only the new states, and no `onAddEnd` + * for the earlier list. A list that only updates or removes states calls + * nothing. All three run on the main thread. + */ +@InternalMapConductorApi +internal class AddProgressTracker( + private val scope: CoroutineScope, + private val now: () -> Long = SystemClock::uptimeMillis, +) { + var onAddStart: ((total: Int) -> Unit)? = null + var onAddProgress: ((added: Int, total: Int) -> Unit)? = null + var onAddEnd: ((total: Int) -> Unit)? = null + + /** State objects handed over and not on the map yet, by id. */ + private var pending = HashMap() + private var total = 0 + private var added = 0 + private var lastProgressAt = Long.MIN_VALUE / 2 + private var timer: Job? = null + + /** + * The states of [states] whose id is not in [prevIds], or none when no one + * listens. Call it off the main thread; it walks the whole list. + */ + fun newStates( + states: List, + prevIds: Set, + wanted: Boolean, + ): List = if (wanted) states.filter { it.id !in prevIds } else emptyList() + + /** + * [states] (ids [nextIds]) is about to be handed to the collector and + * [newStates] are the ones it adds. Call on the main thread, before the + * hand-over, so that no arrival comes ahead of it. + */ + fun track( + states: List, + nextIds: Set, + newStates: List, + ) { + if (pending.isNotEmpty()) { + val before = pending.size + pending.keys.retainAll(nextIds) + total -= before - pending.size + // A state that is still coming may be handed over as a new object + // with the same id; the map reports that object. + if (pending.isNotEmpty() && newStates.isEmpty()) { + for (state in states) if (state.id in pending) pending[state.id] = state + } + if (newStates.isEmpty()) finishIfDone() + } + if (newStates.isEmpty()) return + timer?.cancel() + timer = null + pending = HashMap(newStates.size * 2).apply { for (s in newStates) put(s.id, s) } + total = newStates.size + added = 0 + lastProgressAt = Long.MIN_VALUE / 2 + onAddStart?.invoke(total) + } + + /** States reached the map (any collection's, any map's: only ours count). */ + fun arrived(states: List) { + if (pending.isEmpty()) return + var hit = false + for (state in states) { + if (pending[state.id] === state) { + pending.remove(state.id) + added++ + hit = true + } + } + if (!hit) return + if (pending.isEmpty()) finishIfDone() else reportProgress() + } + + fun dispose() { + timer?.cancel() + timer = null + pending = HashMap() + } + + private fun finishIfDone() { + if (pending.isNotEmpty()) return + timer?.cancel() + timer = null + onAddProgress?.invoke(total, total) + onAddEnd?.invoke(total) + } + + private fun reportProgress() { + if (timer != null) return + val wait = lastProgressAt + PROGRESS_INTERVAL_MS - now() + if (wait <= 0) { + emitProgress() + } else { + timer = + scope.launch { + delay(wait) + timer = null + emitProgress() + } + } + } + + private fun emitProgress() { + if (pending.isEmpty()) return + lastProgressAt = now() + onAddProgress?.invoke(added, total) + } + + @InternalMapConductorApi + private companion object { + const val PROGRESS_INTERVAL_MS = 100L + } +} + +/** The tracker for one collection, listening only while one of the callbacks is set. */ +@InternalMapConductorApi +@Composable +internal fun rememberAddProgress( + onAddStart: ((total: Int) -> Unit)?, + onAddProgress: ((added: Int, total: Int) -> Unit)?, + onAddEnd: ((total: Int) -> Unit)?, +): AddProgressTracker { + val scope = rememberCoroutineScope() + val tracker = remember { AddProgressTracker(scope) } + SideEffect { + tracker.onAddStart = onAddStart + tracker.onAddProgress = onAddProgress + tracker.onAddEnd = onAddEnd + } + val wanted = onAddStart != null || onAddProgress != null || onAddEnd != null + DisposableEffect(tracker, wanted) { + val remove = if (wanted) OverlayAddedListeners.add(tracker::arrived) else null + onDispose { remove?.invoke() } + } + DisposableEffect(tracker) { onDispose { tracker.dispose() } } + return tracker +} diff --git a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt index eb6c5c5..881efc0 100644 --- a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt +++ b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.circle +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -42,17 +43,39 @@ fun MapViewScope.Circle(state: CircleState) { * [com.mapconductor.compose.polygon.Polygons]: one effect diffs the list * against the ids this call added last time and publishes the result in a * single collector update. Several Circles()/Circle() can share one map. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Circles(states: List) { +fun MapViewScope.Circles( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalCircleCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + overlayStateIds(states) to progress.newStates(states, prevIds, tracking) + } + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) withContext(Dispatchers.Default) { - val prevIds = prevIdsState.value - val nextIds = overlayStateIds(states) - prevIdsState.value = nextIds // Merge only the ids this Circles() owns, so several // Circles()/Circle() can share one map's collector. collector.flow.update { current -> diff --git a/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt b/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt index 29c627f..21a0fa7 100644 --- a/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt +++ b/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.marker +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -37,22 +38,45 @@ fun MapViewScope.Marker(state: MarkerState) { * * This avoids large composition overhead (thousands of LaunchedEffects/DisposableEffects) * by performing batched add/remove in a single effect. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Markers(states: List) { +fun MapViewScope.Markers( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalMarkerCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { val startedAt = SystemClock.elapsedRealtime() markerTrace("collector effect start count=${states.size}") + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + states.asSequence().map { it.id }.toSet() to progress.newStates(states, prevIds, tracking) + } + markerTrace("collector ids created count=${nextIds.size}") + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) // For very large marker sets, avoid per-marker SharedFlow emits which can backpressure and // block rendering; instead publish the whole map in one StateFlow update. withContext(Dispatchers.Default) { - val nextIds = states.asSequence().map { it.id }.toSet() - val removedIds = prevIdsState.value - nextIds - markerTrace("collector ids created count=${nextIds.size}") - prevIdsState.value = nextIds + val removedIds = prevIds - nextIds // Merge only the ids this Markers() owns into the shared collector so // several Markers()/Marker() can share one map without clobbering each // other. Assigning the whole map (a full replace) dropped everyone diff --git a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt index da37eb4..2b0684d 100644 --- a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt +++ b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.polygon +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -43,24 +44,46 @@ fun MapViewScope.Polygon(state: PolygonState) { * The same shape as [com.mapconductor.compose.marker.Markers]: one effect diffs * the list against the ids this call added last time and publishes the result * in a single collector update. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Polygons(states: List) { +fun MapViewScope.Polygons( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalPolygonCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { - withContext(Dispatchers.Default) { - val prevIds = prevIdsState.value - val nextIds = states.asSequence().map { it.id }.toSet() - val removedIds = prevIds - nextIds - // Union the holes once per newly added state, as Polygon(state) does. - for (state in states) { - if (state.id !in prevIds && state.holes.size > 1) { - state.unionHolesInPlace() + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + // Union the holes once per newly added state, as Polygon(state) does. + for (state in states) { + if (state.id !in prevIds && state.holes.size > 1) { + state.unionHolesInPlace() + } } + states.asSequence().map { it.id }.toSet() to progress.newStates(states, prevIds, tracking) } - prevIdsState.value = nextIds + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) + withContext(Dispatchers.Default) { + val removedIds = prevIds - nextIds // Merge only the ids this Polygons() owns, so several // Polygons()/Polygon() can share one map's collector. collector.flow.update { current -> diff --git a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt index d09c26d..28c83ed 100644 --- a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt +++ b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.polyline +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -42,18 +43,40 @@ fun MapViewScope.Polyline(state: PolylineState) { * [com.mapconductor.compose.marker.Markers]: one effect diffs the list against * the ids this call added last time and publishes the result in a single * collector update. Several Polylines()/Polyline() can share one map. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Polylines(states: List) { +fun MapViewScope.Polylines( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalPolylineCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + states.asSequence().map { it.id }.toSet() to progress.newStates(states, prevIds, tracking) + } + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) withContext(Dispatchers.Default) { - val prevIds = prevIdsState.value - val nextIds = states.asSequence().map { it.id }.toSet() val removedIds = prevIds - nextIds - prevIdsState.value = nextIds // Merge only the ids this Polylines() owns, so several // Polylines()/Polyline() can share one map's collector. collector.flow.update { current -> diff --git a/src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt b/src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt new file mode 100644 index 0000000..4a43d8f --- /dev/null +++ b/src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt @@ -0,0 +1,117 @@ +package com.mapconductor.compose + +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * `Markers` / `Circles` / `Polylines` / `Polygons` の onAddStart / onAddProgress / onAddEnd + * (AddProgressTracker)。js-sdk-react の `addProgress.test.mjs` と同じ内容。 + * + * - 地図に未だ無い state を持ち込んだリストで onAddStart(total)。 + * - 各 state の onAdded と同じ時点で 1 つ数える。途中経過は 100 ms に 1 回まで。 + * - 全部載ったら onAddProgress(total, total) と onAddEnd(total)。 + * - 載る前に外された state は数から抜ける。新しい追加は数え直し、前のリストの onAddEnd は来ない。 + * - 更新・削除だけのリストは何も呼ばない。他のコレクション・他の地図の state は数えない。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddProgressTrackerTest { + private val scope = TestScope() + private val calls = mutableListOf() + private var prevIds: Set = emptySet() + private val tracker = + AddProgressTracker(scope, now = { scope.testScheduler.currentTime + 1000 }).apply { + onAddStart = { calls += "start $it" } + onAddProgress = { added, total -> calls += "progress $added/$total" } + onAddEnd = { calls += "end $it" } + } + + private fun circle(id: String) = CircleState(id = id, center = GeoPoint.fromLatLong(42.35, -71.06), radiusMeters = 5.0) + + /** What Circles() does with a new list. */ + private fun set(states: List) { + val nextIds = states.map { it.id }.toSet() + tracker.track(states, nextIds, tracker.newStates(states, prevIds, wanted = true)) + prevIds = nextIds + } + + @Test + fun `all on the map - start, progress at the total, end`() { + val states = listOf(circle("a"), circle("b"), circle("c")) + set(states) + tracker.arrived(listOf(states[0])) + tracker.arrived(listOf(states[1], states[2])) + assertEquals(listOf("start 3", "progress 1/3", "progress 3/3", "end 3"), calls) + } + + @Test + fun `progress at most every 100 ms`() { + val states = List(5) { circle("s$it") } + set(states) + tracker.arrived(listOf(states[0])) // now + tracker.arrived(listOf(states[1])) // held + tracker.arrived(listOf(states[2])) // held + assertEquals(listOf("start 5", "progress 1/5"), calls) + scope.advanceTimeBy(100) + scope.runCurrent() + assertEquals(listOf("start 5", "progress 1/5", "progress 3/5"), calls) + tracker.arrived(listOf(states[3], states[4])) + assertEquals("end 5", calls.last()) + } + + @Test + fun `a state removed before it arrived no longer counts`() { + val (a, b, c) = listOf(circle("a"), circle("b"), circle("c")) + set(listOf(a, b, c)) + tracker.arrived(listOf(a)) + set(listOf(a, b)) + tracker.arrived(listOf(b)) + assertEquals(listOf("progress 2/2", "end 2"), calls.takeLast(2)) + } + + @Test + fun `removing the rest ends at once`() { + val (a, b) = listOf(circle("a"), circle("b")) + set(listOf(a, b)) + tracker.arrived(listOf(a)) + set(listOf(a)) + assertEquals(listOf("progress 1/1", "end 1"), calls.takeLast(2)) + } + + @Test + fun `a still-coming state handed over again as a new object is counted by the new one`() { + val a = circle("a") + set(listOf(a)) + val a2 = circle("a") + set(listOf(a2)) + tracker.arrived(listOf(a2)) + assertEquals(listOf("start 1", "progress 1/1", "end 1"), calls) + } + + @Test + fun `new states while others are coming start over without an end for the earlier list`() { + val (a, b, c) = listOf(circle("a"), circle("b"), circle("c")) + set(listOf(a, b)) + set(listOf(a, b, c)) + assertEquals(listOf("start 2", "start 1"), calls) + tracker.arrived(listOf(a, b)) + tracker.arrived(listOf(c)) + assertEquals(listOf("start 2", "start 1", "progress 1/1", "end 1"), calls) + } + + @Test + fun `updates, removals and other collections' states call nothing`() { + val (a, b) = listOf(circle("a"), circle("b")) + set(listOf(a, b)) + tracker.arrived(listOf(a, b)) + calls.clear() + set(listOf(circle("a"))) // a updated, b removed + tracker.arrived(listOf(circle("other"), circle("a"))) + assertEquals(emptyList(), calls) + } +}