From fc84cf6f6994f3ea24bf42ab497b6784322d8965 Mon Sep 17 00:00:00 2001 From: Rohan Maity Date: Fri, 9 Oct 2026 00:14:09 +0530 Subject: [PATCH] feat(clustering): add keyed item diffing Refs #504 --- .../compose/GoogleMapViewClusteringTests.kt | 127 ++++++++++++++++++ .../MarkerClusteringActivity.kt | 126 ++++++++++++----- .../compose/clustering/ClusterItemsDiff.kt | 68 ++++++++++ .../android/compose/clustering/Clustering.kt | 60 ++++++++- .../clustering/ClusterItemsDiffTest.kt | 89 ++++++++++++ 5 files changed, 428 insertions(+), 42 deletions(-) create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt create mode 100644 maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt index c374b453a..ec8fd2573 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.junit4.createComposeRule @@ -164,6 +165,132 @@ class GoogleMapViewClusteringTests { } } + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testKeyedClusteringAppliesAdditionsRemovalsAndUpdates() { + val clusterManagerHolder = arrayOfNulls>(1) + val firstItem = MyItem(startingPosition, "first", "Snippet", 0f) + val items = mutableStateOf(listOf(firstItem)) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Clustering( + items = items.value, + key = MyItem::title, + onClusterManager = { clusterManagerHolder[0] = it }, + ) + } + } + + val timeoutMillis = TimeUnit.SECONDS.toMillis(MAP_LOAD_TIMEOUT_SECONDS) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + clusterManagerHolder[0]?.algorithm?.items?.size == 1 + } + } + + val updatedPosition = LatLng(2.34, 5.67) + val addedItem = MyItem(LatLng(3.45, 6.78), "added", "Snippet", 0f) + items.value = listOf(firstItem.copy(position = updatedPosition), addedItem) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + val currentItems = clusterManagerHolder[0]?.algorithm?.items.orEmpty() + currentItems.size == 2 && + currentItems.any { it.title == "first" && it.position == updatedPosition } && + currentItems.any { it.title == "added" } + } + } + + items.value = listOf(addedItem) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + val currentItems = clusterManagerHolder[0]?.algorithm?.items.orEmpty() + currentItems.size == 1 && currentItems.single().title == "added" + } + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testKeyedClusteringLeavesUnchangedItemsAlone() { + val clusterManagerHolder = arrayOfNulls>(1) + val initialItem = MyItem(startingPosition, "same", "Snippet", 0f) + val items = mutableStateOf(listOf(initialItem)) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Clustering( + items = items.value, + key = MyItem::title, + onClusterManager = { clusterManagerHolder[0] = it }, + ) + } + } + + val timeoutMillis = TimeUnit.SECONDS.toMillis(MAP_LOAD_TIMEOUT_SECONDS) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + clusterManagerHolder[0]?.algorithm?.items?.size == 1 + } + } + val marker = composeTestRule.runOnUiThread { + clusterManagerHolder[0]!!.markerCollection.getMarkers().single() + } + + items.value = listOf(initialItem.copy()) + composeTestRule.waitForIdle() + + composeTestRule.runOnUiThread { + assertThat(clusterManagerHolder[0]?.algorithm?.items).containsExactly(initialItem) + assertThat(clusterManagerHolder[0]?.markerCollection?.getMarkers()).contains(marker) + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testUnkeyedClusteringStillUpdatesItemsByEquality() { + val clusterManagerHolder = arrayOfNulls>(1) + val initialItem = MyItem(startingPosition, "first", "Snippet", 0f) + val items = mutableStateOf(listOf(initialItem)) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Clustering( + items = items.value, + onClusterManager = { clusterManagerHolder[0] = it }, + ) + } + } + + val timeoutMillis = TimeUnit.SECONDS.toMillis(MAP_LOAD_TIMEOUT_SECONDS) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + clusterManagerHolder[0]?.algorithm?.items?.size == 1 + } + } + + val updatedItem = initialItem.copy(position = LatLng(2.34, 5.67)) + val addedItem = MyItem(LatLng(3.45, 6.78), "added", "Snippet", 0f) + items.value = listOf(updatedItem, addedItem) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + val currentItems = clusterManagerHolder[0]?.algorithm?.items.orEmpty() + currentItems.size == 2 && + currentItems.any { it.title == "first" && it.position == updatedItem.position } && + currentItems.any { it.title == "added" } + } + } + } + @OptIn(MapsComposeExperimentalApi::class) @Test fun testClusterItemContentUsingRememberComposeBitmapDescriptorDoesNotCrash() { diff --git a/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt index 98f722cf6..f4fcd969d 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt @@ -24,6 +24,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -43,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +61,7 @@ import androidx.compose.ui.unit.sp import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.GoogleMap @@ -74,6 +77,7 @@ import com.google.maps.android.compose.Circle import com.google.maps.android.compose.singapore import com.google.maps.android.compose.singapore2 import kotlin.random.Random +import kotlinx.coroutines.delay private val TAG = MarkerClusteringActivity::class.simpleName @@ -107,57 +111,107 @@ fun GoogleMapClustering() { } } +@OptIn(MapsComposeExperimentalApi::class) @Composable fun GoogleMapClustering(items: List) { var clusteringType by remember { mutableStateOf(ClusteringType.Default) } - GoogleMap( - modifier = Modifier.fillMaxSize(), - cameraPositionState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(singapore2, 6f) - } - ) { - when (clusteringType) { - ClusteringType.Default -> { - DefaultClustering( - items = items, - ) - } + val keyedItems = remember { mutableStateListOf() } + var updateCount by remember { mutableIntStateOf(0) } + var lastUpdatedItemIndex by remember { mutableIntStateOf(-1) } + var clusterManager by remember { mutableStateOf?>(null) } - ClusteringType.CustomUi -> { - CustomUiClustering( - items = items, - ) + LaunchedEffect(clusteringType) { + if (clusteringType == ClusteringType.KeyedDiff) { + keyedItems.clear() + keyedItems.addAll( + List(5_000) { index -> + val row = index / 100 + val column = index % 100 + MyItem( + position = LatLng( + singapore2.latitude + (row - 25) * 0.001f, + singapore2.longitude + (column - 50) * 0.001f, + ), + title = "item-$index", + snippet = "Frequently updated keyed cluster item", + zIndex = 0f, + ) + } + ) + var nextItemIndex = 0 + while (true) { + delay(500) + repeat(100) { + val index = nextItemIndex++ % keyedItems.size + val item = keyedItems[index] + val direction = if (updateCount % 2 == 0) 1 else -1 + keyedItems[index] = item.copy( + position = LatLng( + item.position.latitude + direction * 0.0002, + item.position.longitude + direction * 0.0002, + ) + ) + } + lastUpdatedItemIndex = (nextItemIndex - 1) % keyedItems.size + updateCount++ } + } else { + keyedItems.clear() + } + } - ClusteringType.CustomRenderer -> { - CustomRendererClustering( - items = items, - ) + Box(Modifier.fillMaxSize()) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(singapore2, 6f) } - - ClusteringType.Decorations -> { - DecorationsClustering( - items = items, + ) { + when (clusteringType) { + ClusteringType.Default -> DefaultClustering(items) + ClusteringType.CustomUi -> CustomUiClustering(items) + ClusteringType.CustomRenderer -> CustomRendererClustering(items) + ClusteringType.Decorations -> DecorationsClustering(items) + ClusteringType.KeyedDiff -> Clustering( + items = keyedItems, + key = { item -> item.title }, + onClusterManager = { clusterManager = it }, ) } + + MarkerInfoWindow( + state = rememberUpdatedMarkerState(position = singapore2), + onClick = { + Log.d(TAG, "Non-cluster marker clicked! $it") + true + } + ) } - MarkerInfoWindow( - state = rememberUpdatedMarkerState(position = singapore2), - onClick = { - Log.d(TAG, "Non-cluster marker clicked! $it") - true + if (clusteringType == ClusteringType.KeyedDiff) { + val managerItems = clusterManager?.algorithm?.items.orEmpty() + val lastUpdatedItem = managerItems.firstOrNull { it.title == "item-$lastUpdatedItemIndex" } + Surface( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 72.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + ) { + Column(Modifier.padding(8.dp)) { + Text("Keyed diff demo: 5,000 items; 100 updates / 500 ms") + Text( + "Update $updateCount · ${managerItems.size} items · " + + "item $lastUpdatedItemIndex at ${lastUpdatedItem?.position}" + ) + } } + } + + ClusteringTypeControls( + modifier = Modifier.align(Alignment.TopCenter), + onClusteringTypeClick = { clusteringType = it }, ) } - - ClusteringTypeControls( - onClusteringTypeClick = { - clusteringType = it - }, - ) } @OptIn(MapsComposeExperimentalApi::class) @@ -354,6 +408,7 @@ private fun ClusteringTypeControls( ClusteringType.CustomUi -> "Custom UI" ClusteringType.CustomRenderer -> "Custom Renderer" ClusteringType.Decorations -> "Decorations" + ClusteringType.KeyedDiff -> "Keyed diff (5k)" }, onClick = { onClusteringTypeClick(it) } ) @@ -380,6 +435,7 @@ private enum class ClusteringType { CustomUi, CustomRenderer, Decorations, + KeyedDiff, } data class MyItem( diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt new file mode 100644 index 000000000..144a3ed2f --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt @@ -0,0 +1,68 @@ +/* + * 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.compose.clustering + +internal data class ClusterItemsDiff( + val added: List, + val removed: List, + val updated: List>, +) { + val hasChanges: Boolean + get() = added.isNotEmpty() || removed.isNotEmpty() || updated.isNotEmpty() +} + +internal fun clusterItemsByKey( + items: Collection, + key: ((T) -> Any)?, +): Map { + val keyedItems = linkedMapOf() + items.forEach { item -> + val itemKey = key?.invoke(item) ?: item + if (key != null) { + require(itemKey !in keyedItems) { + "Clustering item keys must be unique: $itemKey" + } + } + keyedItems[itemKey] = item + } + return keyedItems +} + +internal fun diffClusterItems( + previousItems: Map, + currentItems: Map, +): ClusterItemsDiff { + val added = mutableListOf() + val removed = mutableListOf() + val updated = mutableListOf>() + + previousItems.forEach { (key, previousItem) -> + val currentItem = currentItems[key] + if (currentItem == null) { + removed += previousItem + } else if (previousItem != currentItem) { + updated += previousItem to currentItem + } + } + currentItems.forEach { (key, currentItem) -> + if (key !in previousItems) { + added += currentItem + } + } + + return ClusterItemsDiff(added, removed, updated) +} diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt index f65dbdf1a..bef791c68 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt @@ -112,6 +112,9 @@ public fun ClusteringMarkerProperties( * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point * @param clusterRenderer an optional ClusterRenderer that can be used to specify the algorithm used by the rendering. + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. */ @Composable @GoogleMapComposable @@ -161,6 +164,7 @@ public fun Clustering( clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, clusterRenderer: ClusterRenderer? = null, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { val clusterManager = rememberClusterManager( @@ -186,6 +190,7 @@ public fun Clustering( clusterManager = clusterManager, clusterItemDecoration = clusterItemDecoration, renderer = clusterManager.renderer, + key = key, ) } @@ -207,6 +212,9 @@ public fun Clustering( * @param clusterItemContentZIndex the z-index of the non-clustered item * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. */ @Composable @GoogleMapComposable @@ -225,6 +233,7 @@ public fun Clustering( clusterItemContentZIndex: Float = 0.0f, clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { Clustering( @@ -243,6 +252,7 @@ public fun Clustering( clusterItemContentRotation = clusterItemContentRotation, clusterItemDecoration = clusterItemDecoration, onClusterManager = null, + key = key, ) } @@ -264,6 +274,9 @@ public fun Clustering( * @param clusterItemContentZIndex the z-index of the non-clustered item * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. * @param onClusterManager an optional lambda invoked with the clusterManager as a param when both * the clusterManager and renderer are set up, allowing callers a customization hook. */ @@ -285,6 +298,7 @@ public fun Clustering( clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, + key: ((T) -> Any)? = null, onClusterManager: ((ClusterManager) -> Unit)? = null, ) { val clusterManager = rememberClusterManager() @@ -322,6 +336,7 @@ public fun Clustering( clusterManager = clusterManager, clusterItemDecoration = clusterItemDecoration, renderer = renderer, + key = key, ) } } @@ -331,6 +346,9 @@ public fun Clustering( * * @param items all items to show * @param clusterManager a [ClusterManager] that can be used to specify the algorithm used by the rendering. + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. */ @Composable @GoogleMapComposable @@ -338,13 +356,15 @@ public fun Clustering( public fun Clustering( items: Collection, clusterManager: ClusterManager, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { Clustering( items = items, clusterManager = clusterManager, clusterItemDecoration = clusterItemDecoration, - renderer = null + renderer = null, + key = key, ) } @@ -356,6 +376,7 @@ internal fun Clustering( clusterManager: ClusterManager, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, renderer: ClusterRenderer? = null, + key: ((T) -> Any)? = null, ) { ResetMapListeners(clusterManager) InputHandler( @@ -376,12 +397,37 @@ internal fun Clustering( } } val itemsState = rememberUpdatedState(items) - LaunchedEffect(itemsState) { - snapshotFlow { itemsState.value.toList() } - .collect { items -> - clusterManager.clearItems() - clusterManager.addItems(items) - clusterManager.cluster() + val keyState = rememberUpdatedState(key) + LaunchedEffect(clusterManager, itemsState, keyState) { + var previousKeyedItems: Map? = null + var previousUsedExplicitKey: Boolean? = null + snapshotFlow { itemsState.value.toList() to keyState.value } + .collect { (items, itemKey) -> + val currentKeyedItems = clusterItemsByKey(items, itemKey) + val previousItems = previousKeyedItems + val usesExplicitKey = itemKey != null + + if (previousItems == null || previousUsedExplicitKey != usesExplicitKey) { + // Reset on the first update or when switching identity strategies. + clusterManager.clearItems() + clusterManager.addItems(items) + clusterManager.cluster() + } else { + val diff = diffClusterItems(previousItems, currentKeyedItems) + val removedItems = diff.removed + diff.updated.map { (oldItem, _) -> oldItem } + val addedItems = diff.added + diff.updated.map { (_, newItem) -> newItem } + if (removedItems.isNotEmpty()) { + clusterManager.removeItems(removedItems) + } + if (addedItems.isNotEmpty()) { + clusterManager.addItems(addedItems) + } + if (diff.hasChanges) { + clusterManager.cluster() + } + } + previousKeyedItems = currentKeyedItems + previousUsedExplicitKey = usesExplicitKey } } DisposableEffect(itemsState) { diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt new file mode 100644 index 000000000..5e57994df --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt @@ -0,0 +1,89 @@ +/* + * 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.compose.clustering + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +public class ClusterItemsDiffTest { + @Test + public fun reportsAddedAndRemovedItems() { + val diff = diffClusterItems( + previousItems = mapOf("kept" to Item("kept", 1), "removed" to Item("removed", 2)), + currentItems = mapOf("kept" to Item("kept", 1), "added" to Item("added", 3)), + ) + + assertEquals(listOf(Item("added", 3)), diff.added) + assertEquals(listOf(Item("removed", 2)), diff.removed) + assertEquals(emptyList>(), diff.updated) + assertTrue(diff.hasChanges) + } + + @Test + public fun reportsUpdatedItemsByStableKey() { + val old = Item("same-key", 1) + val current = Item("same-key", 2) + val diff = diffClusterItems( + previousItems = mapOf("stable-key" to old), + currentItems = mapOf("stable-key" to current), + ) + + assertEquals(listOf(old to current), diff.updated) + assertTrue(diff.added.isEmpty()) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.hasChanges) + } + + @Test + public fun reportsUnchangedItemsAndNoChanges() { + val item = Item("same", 1) + val diff = diffClusterItems( + previousItems = mapOf("stable-key" to item), + currentItems = mapOf("stable-key" to item.copy()), + ) + + assertTrue(diff.added.isEmpty()) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.updated.isEmpty()) + assertFalse(diff.hasChanges) + } + + @Test + public fun noKeyUsesItemEqualityAndHashCodeAsIdentity() { + val unchanged = Item("same", 1) + val changed = Item("same", 2) + val current = clusterItemsByKey(listOf(unchanged.copy(), changed), key = null) + + assertEquals(mapOf(unchanged to unchanged), clusterItemsByKey(listOf(unchanged), key = null)) + assertEquals(setOf(unchanged, changed), current.keys) + assertEquals(2, current.size) + } + + @Test + public fun emptyPreviousCollectionAddsAllItems() { + val item = Item("first", 1) + val diff = diffClusterItems(emptyMap(), mapOf("first" to item)) + + assertEquals(listOf(item), diff.added) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.updated.isEmpty()) + } + + private data class Item(val id: String, val value: Int) +}