diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/CutPasteRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/CutPasteRepository.kt index cb82fb9c2f4a1524f2755470de7965d795113804..05e4975471d7bd45d5154a6c6d338769d6bf0604 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/CutPasteRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/CutPasteRepository.kt @@ -18,15 +18,24 @@ package earth.maps.cardinal.data +import earth.maps.cardinal.data.room.ItemType import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject import javax.inject.Singleton +/** + * Represents an item in the clipboard with its ID and type + */ +data class ClipboardItem( + val itemId: String, + val itemType: ItemType +) + @Singleton class CutPasteRepository @Inject constructor() { /** * The set of list item UUIDs and ItemTypes in the clipboard. */ - val clipboard: MutableStateFlow> = MutableStateFlow(emptySet()) + val clipboard: MutableStateFlow> = MutableStateFlow(emptySet()) } \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedListRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedListRepository.kt index 1a559969fcb11fff48db4622474780ddf4583485..296da99ff6c6f8eb4865cba5606457b248c2ac08 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedListRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedListRepository.kt @@ -288,6 +288,74 @@ class SavedListRepository @Inject constructor( return@mapLatest flows } + /** + * Checks if pasting the specified list items into the target list would create a cycle. + * A cycle occurs when a list is being pasted into itself or any of its sublists. + * + * @param targetListId The ID of the list where items would be pasted + * @param listIdsToPaste Set of list IDs that would be pasted + * @return true if pasting would create a cycle, false otherwise + */ + suspend fun wouldCreateCycle( + targetListId: String, + listIdsToPaste: Set + ): Boolean = withContext(Dispatchers.IO) { + try { + // If no lists are being pasted, no cycle can be created + if (listIdsToPaste.isEmpty()) { + return@withContext false + } + + // Check if any of the lists to paste is the target list itself + if (listIdsToPaste.contains(targetListId)) { + return@withContext true + } + + // For each list being pasted, check if the target list is in its hierarchy + for (listId in listIdsToPaste) { + if (isListInHierarchy(targetListId, listId)) { + return@withContext true + } + } + + false + } catch (e: Exception) { + Log.e(TAG, "Error checking for cycle", e) + false // Default to allowing paste if there's an error + } + } + + /** + * Recursively checks if targetListId is in the hierarchy of parentListId. + * This means targetListId is either parentListId itself or one of its descendants. + * + * @param targetListId The list ID we're looking for + * @param parentListId The list ID to start searching from + * @return true if targetListId is in the hierarchy of parentListId + */ + private suspend fun isListInHierarchy( + targetListId: String, + parentListId: String + ): Boolean { + // Base case: if we found the target list + if (targetListId == parentListId) { + return true + } + + // Get all child lists of the parent list + val childLists = listItemDao.getItemsInList(parentListId) + .filter { it.itemType == ItemType.LIST } + + // Recursively check each child list + for (childListItem in childLists) { + if (isListInHierarchy(targetListId, childListItem.itemId)) { + return true + } + } + + return false + } + companion object { private const val TAG = "SavedListRepository" } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt index a0c921723cddb5d45c74f8b1629d7dad02a16ce1..4c5e0ed8f90f3445701f927f58ffe2862c6ef6f9 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt @@ -50,6 +50,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.ToggleFloatingActionButton import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon @@ -77,6 +79,7 @@ import androidx.navigation.NavController import earth.maps.cardinal.R.dimen import earth.maps.cardinal.R.drawable import earth.maps.cardinal.R.string +import earth.maps.cardinal.data.ClipboardItem import earth.maps.cardinal.data.room.ListContent import earth.maps.cardinal.data.room.ListContentItem import earth.maps.cardinal.data.room.PlaceContent @@ -102,11 +105,13 @@ fun ManagePlacesScreen( val clipboard by viewModel.clipboard.collectAsState(emptySet()) val selectedItems by viewModel.selectedItems.collectAsState() val isAllSelected by viewModel.isAllSelected.collectAsState(initial = false) + val errorMessage by viewModel.errorMessage.collectAsState() var showDeleteConfirmation by remember { mutableStateOf(false) } var showCreateListDialog by remember { mutableStateOf(false) } var showEditDialog by remember { mutableStateOf(false) } var editingItem by remember { mutableStateOf(null) } var fabMenuExpanded by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } // Initialize the view model with the listId if provided LaunchedEffect(listId) { @@ -114,13 +119,28 @@ fun ManagePlacesScreen( } Scaffold( - contentWindowInsets = WindowInsets.safeDrawing, topBar = { + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { SnackbarHost(snackbarHostState, modifier = Modifier.padding(bottom = TOOLBAR_HEIGHT_DP)) }, + topBar = { ManagePlacesTopBar( navController = navController, title = currentListName ?: stringResource(string.saved_places_title_case), breadcrumbNames = parents.plus(currentListName ?: ""), ) }) { paddingValues -> + + val coroutineScope = rememberCoroutineScope() + + // Show error message in snackbar when it changes + LaunchedEffect(errorMessage) { + errorMessage?.let { message -> + coroutineScope.launch { + snackbarHostState.showSnackbar(message) + } + viewModel.clearErrorMessage() + } + } + if (currentListContent?.isEmpty() == true) { Column( modifier = Modifier.padding(paddingValues) @@ -129,7 +149,6 @@ fun ManagePlacesScreen( } } - val coroutineScope = rememberCoroutineScope() val currentListContent = currentListContent Box(modifier = Modifier.padding(paddingValues)) { AnimatedVisibility( @@ -453,7 +472,7 @@ private fun EmptyListContent() { private fun ListContentGrid( viewModel: ManagePlacesViewModel, content: List>, - clipboard: Set, + clipboard: Set, selectedItems: Set, onItemClick: (ListContent) -> Unit, onEditClick: (ListContent) -> Unit, @@ -483,7 +502,7 @@ private fun ListContentGrid( items(lists) { item -> ListItem( item = item, - isInClipboard = clipboard.contains(item.id), + isInClipboard = clipboard.any { it.itemId == item.id }, isSelected = selectedItems.contains(item.id), onSelectionChange = { viewModel.toggleSelection(item.id) }, onClick = { onItemClick(item) }, @@ -505,7 +524,7 @@ private fun ListContentGrid( items(pinnedPlaces) { item -> PlaceItem( item = item, - isInClipboard = clipboard.contains(item.id), + isInClipboard = clipboard.any { it.itemId == item.id }, isSelected = selectedItems.contains(item.id), onSelectionChange = { viewModel.toggleSelection(item.id) }, onClick = { onItemClick(item) }, @@ -530,7 +549,7 @@ private fun ListContentGrid( items(unpinnedPlaces) { item -> PlaceItem( item = item, - isInClipboard = clipboard.contains(item.id), + isInClipboard = clipboard.any { it.itemId == item.id }, isSelected = selectedItems.contains(item.id), onSelectionChange = { viewModel.toggleSelection(item.id) }, onClick = { onItemClick(item) }, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt index 05eb991b1f1ef5b80dc19e1e36269dee8fe6ea10..8d19f59c91bc331fc32f7109a6eb6d24808b7292 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt @@ -18,9 +18,13 @@ package earth.maps.cardinal.ui.saved +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import earth.maps.cardinal.R +import earth.maps.cardinal.data.ClipboardItem import earth.maps.cardinal.data.CutPasteRepository import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.room.ItemType @@ -42,6 +46,7 @@ import javax.inject.Inject @HiltViewModel class ManagePlacesViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, private val savedPlaceRepository: SavedPlaceRepository, private val savedListRepository: SavedListRepository, private val listItemDao: ListItemDao, @@ -92,7 +97,11 @@ class ManagePlacesViewModel @Inject constructor( list?.let { savedListRepository.getListContent(it.id) } ?: flowOf(null) } - val clipboard: Flow> = cutPasteRepository.clipboard + val clipboard: Flow> = cutPasteRepository.clipboard + + // Error messages for UI display + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage suspend fun setInitialList(listId: String?) { if (listId != null) { @@ -174,23 +183,55 @@ class ManagePlacesViewModel @Inject constructor( } fun cutSelected() { - val newClipboard = _selectedItems.value.toSet() - cutPasteRepository.clipboard.value = newClipboard - clearSelection() + viewModelScope.launch { + val currentListId = _currentListId.value ?: return@launch + val itemsResult = savedListRepository.getItemsInList(currentListId) + if (itemsResult.isFailure) return@launch + val items = itemsResult.getOrNull() ?: return@launch + + val clipboardItems = _selectedItems.value.mapNotNull { itemId -> + val item = items.find { it.itemId == itemId } ?: return@mapNotNull null + ClipboardItem(itemId, item.itemType) + }.toSet() + + cutPasteRepository.clipboard.value = clipboardItems + clearSelection() + } } fun pasteSelected() { val currentListId = currentListId.value ?: return viewModelScope.launch { + val clipboardItems = cutPasteRepository.clipboard.value + + // Check if any lists in the clipboard would create a cycle + val listIdsToCheck = clipboardItems + .filter { it.itemType == ItemType.LIST } + .map { it.itemId } + .toSet() + + val wouldCreateCycle = savedListRepository.wouldCreateCycle(currentListId, listIdsToCheck) + + if (wouldCreateCycle) { + // Don't paste if it would create a cycle + _errorMessage.value = + context.getString(R.string.cannot_paste_a_list_into_itself_or_one_of_its_sublists) + return@launch + } + val itemsInListCount = listItemDao.getItemsInList(currentListId).size - cutPasteRepository.clipboard.value.forEachIndexed { index, id -> - listItemDao.moveItem(id, currentListId, itemsInListCount + index) + clipboardItems.forEachIndexed { index, clipboardItem -> + listItemDao.moveItem(clipboardItem.itemId, currentListId, itemsInListCount + index) } cutPasteRepository.clipboard.value = emptySet() } } + fun clearErrorMessage() { + _errorMessage.value = null + } + fun updatePlace( id: String, customName: String?, diff --git a/cardinal-android/app/src/main/res/values/strings.xml b/cardinal-android/app/src/main/res/values/strings.xml index 30cd012e0d9b6023671ee39d3a5832295dfdc823..7f45ec0899f4efa90f0c6abcce5c0fdd87ee7def 100644 --- a/cardinal-android/app/src/main/res/values/strings.xml +++ b/cardinal-android/app/src/main/res/values/strings.xml @@ -264,4 +264,5 @@ Transportation Entertainment Nightlife + Cannot paste a list into itself or one of its sublists diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/room/SavedListRepositoryTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/room/SavedListRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..cbbd5e7c0d6971497ed3c0d187b9fccf92f2dea5 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/room/SavedListRepositoryTest.kt @@ -0,0 +1,385 @@ +/* + * Cardinal Maps + * Copyright (C) 2025 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data.room + +import android.content.Context +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class SavedListRepositoryTest { + + private lateinit var repository: SavedListRepository + private val mockContext = mockk(relaxed = true) + private val mockDatabase = mockk(relaxed = false) + private val mockListDao = mockk(relaxed = false) + private val mockListItemDao = mockk(relaxed = false) + private val mockPlaceDao = mockk(relaxed = false) + + // Test data + private val rootList = SavedList( + id = "root-list-id", + name = "Saved Places", + description = null, + isRoot = true, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val parentList = SavedList( + id = "parent-list-id", + name = "Parent List", + description = "Parent Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val childList = SavedList( + id = "child-list-id", + name = "Child List", + description = "Child Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val grandchildList = SavedList( + id = "grandchild-list-id", + name = "Grandchild List", + description = "Grandchild Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val unrelatedList = SavedList( + id = "unrelated-list-id", + name = "Unrelated List", + description = "Unrelated Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + @Before + fun setup() { + every { mockDatabase.savedListDao() } returns mockListDao + every { mockDatabase.listItemDao() } returns mockListItemDao + every { mockDatabase.savedPlaceDao() } returns mockPlaceDao + + repository = SavedListRepository(mockContext, mockDatabase) + } + + @Test + fun `wouldCreateCycle should return false when no lists are being pasted`() = runTest { + // Given + val targetListId = "target-list-id" + val listIdsToPaste = emptySet() + + // When + val result = repository.wouldCreateCycle(targetListId, listIdsToPaste) + + // Then + assertFalse(result) + } + + @Test + fun `wouldCreateCycle should return true when pasting list into itself`() = runTest { + // Given + val targetListId = parentList.id + val listIdsToPaste = setOf(parentList.id) + + // When + val result = repository.wouldCreateCycle(targetListId, listIdsToPaste) + + // Then + assertTrue(result) + } + + @Test + fun `wouldCreateCycle should return true when pasting parent into child`() = runTest { + // Given - set up hierarchy: parent -> child + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns emptyList() + + // When + val result = repository.wouldCreateCycle(childList.id, setOf(parentList.id)) + + // Then + assertTrue(result) + } + + @Test + fun `wouldCreateCycle should return true when pasting grandparent into grandchild`() = runTest { + // Given - set up hierarchy: parent -> child -> grandchild + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + val childToGrandchildListItem = ListItem( + listId = childList.id, + itemId = grandchildList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns listOf(childToGrandchildListItem) + coEvery { mockListItemDao.getItemsInList(grandchildList.id) } returns emptyList() + + // When + val result = repository.wouldCreateCycle(grandchildList.id, setOf(parentList.id)) + + // Then + assertTrue(result) + } + + @Test + fun `wouldCreateCycle should return false when pasting child into parent`() = runTest { + // Given - set up hierarchy: parent -> child + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns emptyList() + + // When + val result = repository.wouldCreateCycle(parentList.id, setOf(childList.id)) + + // Then + assertFalse(result) + } + + @Test + fun `wouldCreateCycle should return false when pasting unrelated list`() = runTest { + // Given - set up hierarchy: parent -> child + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns emptyList() + coEvery { mockListItemDao.getItemsInList(unrelatedList.id) } returns emptyList() + + // When + val result = repository.wouldCreateCycle(parentList.id, setOf(unrelatedList.id)) + + // Then + assertFalse(result) + } + + @Test + fun `wouldCreateCycle should return true when any list in set would create cycle`() = runTest { + // Given - set up hierarchy: parent -> child + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns emptyList() + coEvery { mockListItemDao.getItemsInList(unrelatedList.id) } returns emptyList() + + // When - trying to paste both child (would create cycle) and unrelated (wouldn't create cycle) + val result = repository.wouldCreateCycle(childList.id, setOf(parentList.id, unrelatedList.id)) + + // Then + assertTrue(result) + } + + @Test + fun `wouldCreateCycle should return false when no lists in set would create cycle`() = runTest { + // Given - set up hierarchy: parent -> child + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns emptyList() + coEvery { mockListItemDao.getItemsInList(unrelatedList.id) } returns emptyList() + + // When - trying to paste unrelated lists only + val result = repository.wouldCreateCycle(parentList.id, setOf(unrelatedList.id)) + + // Then + assertFalse(result) + } + + @Test + fun `wouldCreateCycle should handle complex hierarchy correctly`() = runTest { + // Given - set up complex hierarchy: parent -> child1, child2 -> grandchild1, grandchild2 + val parentToChild1ListItem = ListItem( + listId = parentList.id, + itemId = "child1-list-id", + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + val parentToChild2ListItem = ListItem( + listId = parentList.id, + itemId = "child2-list-id", + itemType = ItemType.LIST, + position = 1, + addedAt = System.currentTimeMillis() + ) + + val child1ToGrandchild1ListItem = ListItem( + listId = "child1-list-id", + itemId = "grandchild1-list-id", + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + val child2ToGrandchild2ListItem = ListItem( + listId = "child2-list-id", + itemId = "grandchild2-list-id", + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChild1ListItem, parentToChild2ListItem) + coEvery { mockListItemDao.getItemsInList("child1-list-id") } returns listOf(child1ToGrandchild1ListItem) + coEvery { mockListItemDao.getItemsInList("child2-list-id") } returns listOf(child2ToGrandchild2ListItem) + coEvery { mockListItemDao.getItemsInList("grandchild1-list-id") } returns emptyList() + coEvery { mockListItemDao.getItemsInList("grandchild2-list-id") } returns emptyList() + + // When - trying to paste parent into grandchild (should create cycle) + val result1 = repository.wouldCreateCycle("grandchild1-list-id", setOf(parentList.id)) + + // When - trying to paste child1 into child2 (should not create cycle) + val result2 = repository.wouldCreateCycle("child2-list-id", setOf("child1-list-id")) + + // When - trying to paste grandchild1 into parent (should not create cycle) + val result3 = repository.wouldCreateCycle(parentList.id, setOf("grandchild1-list-id")) + + // Then + assertTrue(result1) // parent -> child1 -> grandchild1, so pasting parent into grandchild1 creates cycle + assertFalse(result2) // child1 and child2 are siblings, no cycle + assertFalse(result3) // grandchild1 is descendant of parent, but pasting it into parent doesn't create cycle + } + + @Test + fun `wouldCreateCycle should handle empty lists correctly`() = runTest { + // Given + val targetListId = "target-list-id" + val listIdsToPaste = setOf("list1-id", "list2-id") + + // Mock all lists as empty + coEvery { mockListItemDao.getItemsInList(any()) } returns emptyList() + + // When + val result = repository.wouldCreateCycle(targetListId, listIdsToPaste) + + // Then + assertFalse(result) + } + + @Test + fun `wouldCreateCycle should handle database errors gracefully`() = runTest { + // Given + val targetListId = parentList.id + val listIdsToPaste = setOf(childList.id) + + // Mock database to throw exception + coEvery { mockListItemDao.getItemsInList(any()) } throws RuntimeException("Database error") + + // When + val result = repository.wouldCreateCycle(targetListId, listIdsToPaste) + + // Then - should return false (allow paste) when there's an error + assertFalse(result) + } + + @Test + fun `wouldCreateCycle should handle mixed item types correctly`() = runTest { + // Given - list contains both lists and places + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + val parentToPlaceListItem = ListItem( + listId = parentList.id, + itemId = "place-id", + itemType = ItemType.PLACE, + position = 1, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockListItemDao.getItemsInList(parentList.id) } returns listOf(parentToChildListItem, parentToPlaceListItem) + coEvery { mockListItemDao.getItemsInList(childList.id) } returns emptyList() + + // When + val result = repository.wouldCreateCycle(childList.id, setOf(parentList.id)) + + // Then - should still detect cycle even with mixed item types + assertTrue(result) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt index 87067ba2a9003e65db583f6eccccdd344819c23a..4b9e724ed6f8ff7121e1faee30a81cba44674f9b 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt @@ -1,6 +1,9 @@ package earth.maps.cardinal.ui.saved +import android.content.Context import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.R.string +import earth.maps.cardinal.data.ClipboardItem import earth.maps.cardinal.data.CutPasteRepository import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.room.ItemType @@ -36,6 +39,7 @@ class ManagePlacesViewModelTest { val mainCoroutineRule = MainCoroutineRule() // Mock dependencies + private val mockContext = mockk() private val mockSavedListRepository = mockk(relaxed = false) private val mockSavedPlaceRepository = mockk(relaxed = false) private val mockListItemDao = mockk(relaxed = false) @@ -108,30 +112,52 @@ class ManagePlacesViewModelTest { @Before fun setup() { + // Mock context methods + every { mockContext.getString(string.cannot_paste_a_list_into_itself_or_one_of_its_sublists) } returns "error" // Mock repository methods coEvery { mockSavedListRepository.getRootList() } returns Result.success(rootList) coEvery { mockSavedListRepository.getListById(any()) } returns Result.success(null) coEvery { mockSavedListRepository.getListById(testList.id) } returns Result.success(testList) - coEvery { mockSavedListRepository.getListById(nestedList.id) } returns Result.success(nestedList) + coEvery { mockSavedListRepository.getListById(nestedList.id) } returns Result.success( + nestedList + ) coEvery { mockSavedListRepository.getItemsInList(any()) } returns Result.success(emptyList()) - coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success(listOf(listItem1)) + coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success( + listOf(listItem1) + ) coEvery { mockSavedListRepository.getItemIdsInListAsFlow(any()) } returns flowOf(emptySet()) - coEvery { mockSavedListRepository.getItemIdsInListAsFlow(testList.id) } returns flowOf(setOf(testPlace.id)) + coEvery { mockSavedListRepository.getItemIdsInListAsFlow(testList.id) } returns flowOf( + setOf( + testPlace.id + ) + ) coEvery { mockSavedListRepository.getListContent(any()) } returns flowOf(emptyList()) - + // Mock additional repository methods needed for tests coEvery { mockSavedListRepository.deleteList(any()) } returns Result.success(Unit) - coEvery { mockSavedListRepository.updateList(any(), any(), any()) } returns Result.success(Unit) - coEvery { mockSavedListRepository.createList(any(), any(), any(), any(), any()) } returns Result.success("new-list-id") - + coEvery { mockSavedListRepository.updateList(any(), any(), any()) } returns Result.success( + Unit + ) + coEvery { + mockSavedListRepository.createList( + any(), + any(), + any(), + any(), + any() + ) + } returns Result.success("new-list-id") + // Mock DAO methods coEvery { mockListItemDao.getItemsInList(any()) } returns emptyList() coEvery { mockListItemDao.getItemsInList(testList.id) } returns listOf(listItem1) coEvery { mockListItemDao.moveItem(any(), any(), any()) } just runs - + // Mock place repository coEvery { mockSavedPlaceRepository.getPlaceById(any()) } returns Result.success(null) - coEvery { mockSavedPlaceRepository.getPlaceById(testPlace.id) } returns Result.success(testPlace) + coEvery { mockSavedPlaceRepository.getPlaceById(testPlace.id) } returns Result.success( + testPlace + ) coEvery { mockSavedPlaceRepository.toPlace(any()) } returns Place( id = testPlace.id, name = testPlace.name, @@ -143,12 +169,23 @@ class ManagePlacesViewModelTest { transitStopId = testPlace.transitStopId ) coEvery { mockSavedPlaceRepository.deletePlace(any()) } returns Result.success(Unit) - coEvery { mockSavedPlaceRepository.updatePlace(any(), any(), any(), any()) } returns Result.success(Unit) - + coEvery { + mockSavedPlaceRepository.updatePlace( + any(), + any(), + any(), + any() + ) + } returns Result.success(Unit) + // Mock cut/paste repository every { mockCutPasteRepository.clipboard } returns MutableStateFlow(emptySet()) - + + // Mock wouldCreateCycle method + coEvery { mockSavedListRepository.wouldCreateCycle(any(), any()) } returns false + viewModel = ManagePlacesViewModel( + context = mockContext, savedPlaceRepository = mockSavedPlaceRepository, savedListRepository = mockSavedListRepository, listItemDao = mockListItemDao, @@ -160,7 +197,7 @@ class ManagePlacesViewModelTest { fun `setInitialList with null should navigate to root list`() = runTest { // When - use the existing viewModel instance viewModel.setInitialList(null) - + // Then - wait for the async operation to complete advanceUntilIdle() // Verify that the current list name is set to the root list name @@ -172,7 +209,7 @@ class ManagePlacesViewModelTest { fun `setInitialList with valid listId should navigate to that list`() = runTest { // When viewModel.setInitialList(testList.id) - + // Then - wait for the async operation to complete advanceUntilIdle() assertEquals(testList, viewModel.currentList.value) @@ -182,7 +219,7 @@ class ManagePlacesViewModelTest { fun `setInitialList with invalid listId should not navigate`() = runTest { // When viewModel.setInitialList("invalid-id") - + // Then assertEquals(null, viewModel.currentList.value) } @@ -191,10 +228,10 @@ class ManagePlacesViewModelTest { fun `currentListName should return current list name`() = runTest { // Given viewModel.setInitialList(testList.id) - + // When val listName = viewModel.currentListName.first() - + // Then assertEquals("Test List", listName) } @@ -203,15 +240,16 @@ class ManagePlacesViewModelTest { fun `currentListName should return root list name when no current list`() = runTest { // Given - ViewModel initialized with no current list val freshViewModel = ManagePlacesViewModel( + context = mockContext, savedPlaceRepository = mockSavedPlaceRepository, savedListRepository = mockSavedListRepository, listItemDao = mockListItemDao, cutPasteRepository = mockCutPasteRepository ) - + // When val listName = freshViewModel.currentListName.first() - + // Then assertEquals("Saved Places", listName) } @@ -220,10 +258,10 @@ class ManagePlacesViewModelTest { fun `toggleSelection should add item to selection when not selected`() = runTest { // Given viewModel.setInitialList(testList.id) - + // When viewModel.toggleSelection(testPlace.id) - + // Then assertTrue(viewModel.selectedItems.value.contains(testPlace.id)) } @@ -233,10 +271,10 @@ class ManagePlacesViewModelTest { // Given viewModel.setInitialList(testList.id) viewModel.toggleSelection(testPlace.id) - + // When viewModel.toggleSelection(testPlace.id) - + // Then assertFalse(viewModel.selectedItems.value.contains(testPlace.id)) } @@ -246,11 +284,11 @@ class ManagePlacesViewModelTest { // Given viewModel.setInitialList(testList.id) advanceUntilIdle() - + // When viewModel.selectAll() advanceUntilIdle() - + // Then assertTrue(viewModel.selectedItems.value.contains(testPlace.id)) } @@ -260,10 +298,10 @@ class ManagePlacesViewModelTest { // Given viewModel.setInitialList(testList.id) viewModel.toggleSelection(testPlace.id) - + // When viewModel.clearSelection() - + // Then assertTrue(viewModel.selectedItems.value.isEmpty()) } @@ -275,10 +313,10 @@ class ManagePlacesViewModelTest { advanceUntilIdle() viewModel.toggleSelection(testPlace.id) // Manually select the item advanceUntilIdle() - + // When val result = viewModel.isAllSelected.first() - + // Then assertTrue(result) } @@ -288,10 +326,10 @@ class ManagePlacesViewModelTest { // Given viewModel.setInitialList(testList.id) advanceUntilIdle() - + // When val result = viewModel.isAllSelected.first() - + // Then assertFalse(result) } @@ -302,11 +340,11 @@ class ManagePlacesViewModelTest { viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) - + // When viewModel.deleteSelected() advanceUntilIdle() - + // Then coVerify { mockSavedPlaceRepository.deletePlace(testPlace.id) } assertTrue(viewModel.selectedItems.value.isEmpty()) @@ -322,17 +360,19 @@ class ManagePlacesViewModelTest { position = 1, addedAt = System.currentTimeMillis() ) - - coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success(listOf(listItem1, listItemWithNestedList)) - + + coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success( + listOf(listItem1, listItemWithNestedList) + ) + viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(nestedList.id) - + // When viewModel.deleteSelected() advanceUntilIdle() - + // Then coVerify { mockSavedListRepository.deleteList(nestedList.id) } assertTrue(viewModel.selectedItems.value.isEmpty()) @@ -342,17 +382,25 @@ class ManagePlacesViewModelTest { fun `createNewListWithSelected should create new list with selected items`() = runTest { // Given val newListId = "new-list-id" - coEvery { mockSavedListRepository.createList(any(), any(), any(), any(), any()) } returns Result.success(newListId) + coEvery { + mockSavedListRepository.createList( + any(), + any(), + any(), + any(), + any() + ) + } returns Result.success(newListId) coEvery { mockListItemDao.getItemsInList(newListId) } returns emptyList() - + viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) - + // When viewModel.createNewListWithSelected("New List") advanceUntilIdle() - + // Then coVerify { mockSavedListRepository.createList("New List", testList.id, null, false, false) } coVerify { mockListItemDao.moveItem(testPlace.id, newListId, 0) } @@ -362,54 +410,56 @@ class ManagePlacesViewModelTest { @Test fun `cutSelected should update clipboard with selected items`() = runTest { // Given - val clipboardFlow = MutableStateFlow>(emptySet()) + val clipboardFlow = MutableStateFlow>(emptySet()) every { mockCutPasteRepository.clipboard } returns clipboardFlow - + viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) - + // When viewModel.cutSelected() - + advanceUntilIdle() + // Then - assertEquals(setOf(testPlace.id), clipboardFlow.value) + assertEquals(setOf(ClipboardItem(testPlace.id, ItemType.PLACE)), clipboardFlow.value) assertTrue(viewModel.selectedItems.value.isEmpty()) // Selection should be cleared } @Test fun `pasteSelected should move items from clipboard to current list`() = runTest { // Given - val clipboardItems = setOf(testPlace.id) + val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE)) val clipboardFlow = MutableStateFlow(clipboardItems) every { mockCutPasteRepository.clipboard } returns clipboardFlow - + viewModel.setInitialList(testList.id) advanceUntilIdle() - + // When viewModel.pasteSelected() advanceUntilIdle() - + // Then coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } - assertEquals(emptySet(), clipboardFlow.value) + assertEquals(emptySet(), clipboardFlow.value) } @Test fun `clipboard should reflect CutPasteRepository clipboard`() = runTest { // Given - val clipboardItems = setOf(testPlace.id) + val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE)) every { mockCutPasteRepository.clipboard } returns MutableStateFlow(clipboardItems) - + // When val freshViewModel = ManagePlacesViewModel( + context = mockContext, savedPlaceRepository = mockSavedPlaceRepository, savedListRepository = mockSavedListRepository, listItemDao = mockListItemDao, cutPasteRepository = mockCutPasteRepository ) - + // Then assertEquals(clipboardItems, freshViewModel.clipboard.first()) } @@ -420,11 +470,11 @@ class ManagePlacesViewModelTest { val customName = "Custom Name" val customDescription = "Custom Description" val isPinned = true - + // When viewModel.updatePlace(testPlace.id, customName, customDescription, isPinned) advanceUntilIdle() - + // Then coVerify { mockSavedPlaceRepository.updatePlace( @@ -441,11 +491,11 @@ class ManagePlacesViewModelTest { // Given val newName = "New List Name" val newDescription = "New Description" - + // When viewModel.updateList(testList.id, newName, newDescription) advanceUntilIdle() - + // Then coVerify { mockSavedListRepository.updateList( @@ -460,10 +510,10 @@ class ManagePlacesViewModelTest { fun `getSavedPlace should return place when found`() = runTest { // Given viewModel.setInitialList(testList.id) - + // When val result = viewModel.getSavedPlace(testPlace.id) - + // Then assertEquals(testPlace.id, result?.id) } @@ -472,10 +522,10 @@ class ManagePlacesViewModelTest { fun `getSavedPlace should return null when not found`() = runTest { // Given viewModel.setInitialList(testList.id) - + // When val result = viewModel.getSavedPlace("non-existent-id") - + // Then assertEquals(null, result) } @@ -486,10 +536,11 @@ class ManagePlacesViewModelTest { viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) - + // When viewModel.cutSelected() - + advanceUntilIdle() // Wait for the coroutine to complete + // Then assertTrue(viewModel.selectedItems.value.isEmpty()) } @@ -498,17 +549,25 @@ class ManagePlacesViewModelTest { fun `createNewListWithSelected should clear selection after creating list`() = runTest { // Given val newListId = "new-list-id" - coEvery { mockSavedListRepository.createList(any(), any(), any(), any(), any()) } returns Result.success(newListId) + coEvery { + mockSavedListRepository.createList( + any(), + any(), + any(), + any(), + any() + ) + } returns Result.success(newListId) coEvery { mockListItemDao.getItemsInList(newListId) } returns emptyList() - + viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) - + // When viewModel.createNewListWithSelected("New List") advanceUntilIdle() - + // Then assertTrue(viewModel.selectedItems.value.isEmpty()) } @@ -539,59 +598,62 @@ class ManagePlacesViewModelTest { createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis() ) - - val clipboardItems = setOf(testPlace.id, testPlace2.id) + + val clipboardItems = setOf( + ClipboardItem(testPlace.id, ItemType.PLACE), + ClipboardItem(testPlace2.id, ItemType.PLACE) + ) val clipboardFlow = MutableStateFlow(clipboardItems) every { mockCutPasteRepository.clipboard } returns clipboardFlow - + viewModel.setInitialList(testList.id) advanceUntilIdle() - + // When viewModel.pasteSelected() advanceUntilIdle() - + // Then coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } coVerify { mockListItemDao.moveItem(testPlace2.id, testList.id, 2) } - assertEquals(emptySet(), clipboardFlow.value) + assertEquals(emptySet(), clipboardFlow.value) } @Test fun `pasteSelected should handle empty target list`() = runTest { // Given - val clipboardItems = setOf(testPlace.id) + val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE)) val clipboardFlow = MutableStateFlow(clipboardItems) every { mockCutPasteRepository.clipboard } returns clipboardFlow - + // Mock empty target list coEvery { mockListItemDao.getItemsInList(testList.id) } returns emptyList() - + viewModel.setInitialList(testList.id) advanceUntilIdle() - + // When viewModel.pasteSelected() advanceUntilIdle() - + // Then coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 0) } - assertEquals(emptySet(), clipboardFlow.value) + assertEquals(emptySet(), clipboardFlow.value) } @Test fun `deleteSelected should handle repository failures gracefully`() = runTest { // Given coEvery { mockSavedPlaceRepository.deletePlace(any()) } returns Result.failure(Exception("Delete failed")) - + viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) - + // When viewModel.deleteSelected() advanceUntilIdle() - + // Then - should still clear selection even if delete fails assertTrue(viewModel.selectedItems.value.isEmpty()) } @@ -599,17 +661,25 @@ class ManagePlacesViewModelTest { @Test fun `createNewListWithSelected should handle creation failures`() = runTest { // Given - coEvery { mockSavedListRepository.createList(any(), any(), any(), any(), any()) } returns Result.failure(Exception("Creation failed")) - + coEvery { + mockSavedListRepository.createList( + any(), + any(), + any(), + any(), + any() + ) + } returns Result.failure(Exception("Creation failed")) + viewModel.setInitialList(testList.id) advanceUntilIdle() viewModel.toggleSelection(testPlace.id) val initialSelection = viewModel.selectedItems.value - + // When viewModel.createNewListWithSelected("New List") advanceUntilIdle() - + // Then - selection should not be cleared if creation fails assertEquals(initialSelection, viewModel.selectedItems.value) } @@ -617,16 +687,332 @@ class ManagePlacesViewModelTest { @Test fun `selectAll should handle empty list gracefully`() = runTest { // Given - empty list - coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success(emptyList()) - + coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success( + emptyList() + ) + viewModel.setInitialList(testList.id) advanceUntilIdle() - + // When viewModel.selectAll() advanceUntilIdle() - + // Then assertTrue(viewModel.selectedItems.value.isEmpty()) } + + @Test + fun `pasteSelected should prevent pasting list into itself`() = runTest { + // Given + val clipboardItems = setOf(ClipboardItem(testList.id, ItemType.LIST)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + // Mock wouldCreateCycle to return true when trying to paste list into itself + coEvery { + mockSavedListRepository.wouldCreateCycle(testList.id, setOf(testList.id)) + } returns true + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should not move any items and clipboard should remain unchanged + coVerify(exactly = 0) { mockListItemDao.moveItem(any(), any(), any()) } + assertEquals(clipboardItems, clipboardFlow.value) + } + + @Test + fun `pasteSelected should prevent pasting parent list into child list`() = runTest { + // Given - set up a parent-child relationship + val parentList = SavedList( + id = "parent-list-id", + name = "Parent List", + description = "Parent Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + val childList = SavedList( + id = "child-list-id", + name = "Child List", + description = "Child Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + // Mock parent list contains child list + val parentToChildListItem = ListItem( + listId = parentList.id, + itemId = childList.id, + itemType = ItemType.LIST, + position = 0, + addedAt = System.currentTimeMillis() + ) + + coEvery { mockSavedListRepository.getListById(parentList.id) } returns Result.success( + parentList + ) + coEvery { mockSavedListRepository.getListById(childList.id) } returns Result.success( + childList + ) + coEvery { mockSavedListRepository.getItemsInList(parentList.id) } returns Result.success( + listOf(parentToChildListItem) + ) + + // Mock wouldCreateCycle to return true when trying to paste parent into child + coEvery { + mockSavedListRepository.wouldCreateCycle(childList.id, setOf(parentList.id)) + } returns true + + val clipboardItems = setOf(ClipboardItem(parentList.id, ItemType.LIST)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + viewModel.setInitialList(childList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should not move any items and clipboard should remain unchanged + coVerify(exactly = 0) { mockListItemDao.moveItem(any(), any(), any()) } + assertEquals(clipboardItems, clipboardFlow.value) + } + + @Test + fun `pasteSelected should allow pasting place into list`() = runTest { + // Given + val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + // Places don't create cycles, so wouldCreateCycle should return false + coEvery { + mockSavedListRepository.wouldCreateCycle(testList.id, emptySet()) + } returns false + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should move the place and clear clipboard + coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } + assertEquals(emptySet(), clipboardFlow.value) + } + + @Test + fun `pasteSelected should allow pasting list into different list without cycle`() = runTest { + // Given + val targetList = SavedList( + id = "target-list-id", + name = "Target List", + description = "Target Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + coEvery { mockSavedListRepository.getListById(targetList.id) } returns Result.success( + targetList + ) + coEvery { mockSavedListRepository.getItemsInList(targetList.id) } returns Result.success( + emptyList() + ) + + // Mock wouldCreateCycle to return false when no cycle would be created + coEvery { + mockSavedListRepository.wouldCreateCycle(targetList.id, setOf(testList.id)) + } returns false + + val clipboardItems = setOf(ClipboardItem(testList.id, ItemType.LIST)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + viewModel.setInitialList(targetList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should move the list and clear clipboard + coVerify { mockListItemDao.moveItem(testList.id, targetList.id, 0) } + assertEquals(emptySet(), clipboardFlow.value) + } + + @Test + fun `pasteSelected should allow pasting mixed items when no cycle`() = runTest { + // Given + val clipboardItems = setOf( + ClipboardItem(testPlace.id, ItemType.PLACE), + ClipboardItem(nestedList.id, ItemType.LIST) + ) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + // Mock wouldCreateCycle to return false when no cycle would be created + coEvery { + mockSavedListRepository.wouldCreateCycle(testList.id, setOf(nestedList.id)) + } returns false + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should move both items and clear clipboard + coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } + coVerify { mockListItemDao.moveItem(nestedList.id, testList.id, 2) } + assertEquals(emptySet(), clipboardFlow.value) + } + + @Test + fun `pasteSelected should prevent pasting when any list would create cycle`() = runTest { + // Given + val anotherList = SavedList( + id = "another-list-id", + name = "Another List", + description = "Another Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + val clipboardItems = setOf( + ClipboardItem(testPlace.id, ItemType.PLACE), + ClipboardItem(testList.id, ItemType.LIST), + ClipboardItem(anotherList.id, ItemType.LIST) + ) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + // Mock wouldCreateCycle to return true because testList would create a cycle + coEvery { + mockSavedListRepository.wouldCreateCycle( + testList.id, + setOf(testList.id, anotherList.id) + ) + } returns true + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should not move any items and clipboard should remain unchanged + coVerify(exactly = 0) { mockListItemDao.moveItem(any(), any(), any()) } + assertEquals(clipboardItems, clipboardFlow.value) + } + + @Test + fun `pasteSelected should set error message when cycle detected`() = runTest { + // Given + val clipboardItems = setOf(ClipboardItem(testList.id, ItemType.LIST)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + // Mock wouldCreateCycle to return true + coEvery { + mockSavedListRepository.wouldCreateCycle(testList.id, setOf(testList.id)) + } returns true + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should set error message + val errorMessage = viewModel.errorMessage.value + assertEquals("error", errorMessage) + } + + @Test + fun `pasteSelected should not set error message when no cycle detected`() = runTest { + // Given + val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + every { mockContext.getString(string.cannot_paste_a_list_into_itself_or_one_of_its_sublists) } returns "error" + + // Mock wouldCreateCycle to return false + coEvery { + mockSavedListRepository.wouldCreateCycle(testList.id, emptySet()) + } returns false + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then - should not set error message + val errorMessage = viewModel.errorMessage.value + assertEquals(null, errorMessage) + } + + @Test + fun `clearErrorMessage should clear the error message`() = runTest { + // Given - set an error message first + val clipboardItems = setOf(ClipboardItem(testList.id, ItemType.LIST)) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + coEvery { + mockSavedListRepository.wouldCreateCycle(testList.id, setOf(testList.id)) + } returns true + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + viewModel.pasteSelected() + advanceUntilIdle() + + // Verify error message is set + assertEquals("error", viewModel.errorMessage.value) + + // When + viewModel.clearErrorMessage() + + // Then - error message should be cleared + assertEquals(null, viewModel.errorMessage.value) + } + + @Test + fun `errorMessage should be null initially`() = runTest { + // Given - fresh ViewModel + val freshViewModel = ManagePlacesViewModel( + context = mockContext, + savedPlaceRepository = mockSavedPlaceRepository, + savedListRepository = mockSavedListRepository, + listItemDao = mockListItemDao, + cutPasteRepository = mockCutPasteRepository + ) + + // When + val errorMessage = freshViewModel.errorMessage.value + + // Then - should be null initially + assertEquals(null, errorMessage) + } } \ No newline at end of file