Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Skip to content
Commits on Source (3)
...@@ -18,15 +18,24 @@ ...@@ -18,15 +18,24 @@
package earth.maps.cardinal.data package earth.maps.cardinal.data
import earth.maps.cardinal.data.room.ItemType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 @Singleton
class CutPasteRepository @Inject constructor() { class CutPasteRepository @Inject constructor() {
/** /**
* The set of list item UUIDs and ItemTypes in the clipboard. * The set of list item UUIDs and ItemTypes in the clipboard.
*/ */
val clipboard: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet()) val clipboard: MutableStateFlow<Set<ClipboardItem>> = MutableStateFlow(emptySet())
} }
\ No newline at end of file
...@@ -288,6 +288,74 @@ class SavedListRepository @Inject constructor( ...@@ -288,6 +288,74 @@ class SavedListRepository @Inject constructor(
return@mapLatest flows 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<String>
): 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 { companion object {
private const val TAG = "SavedListRepository" private const val TAG = "SavedListRepository"
} }
......
...@@ -50,6 +50,8 @@ import androidx.compose.material3.IconButton ...@@ -50,6 +50,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ToggleFloatingActionButton import androidx.compose.material3.ToggleFloatingActionButton
import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
...@@ -77,6 +79,7 @@ import androidx.navigation.NavController ...@@ -77,6 +79,7 @@ import androidx.navigation.NavController
import earth.maps.cardinal.R.dimen import earth.maps.cardinal.R.dimen
import earth.maps.cardinal.R.drawable import earth.maps.cardinal.R.drawable
import earth.maps.cardinal.R.string 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.ListContent
import earth.maps.cardinal.data.room.ListContentItem import earth.maps.cardinal.data.room.ListContentItem
import earth.maps.cardinal.data.room.PlaceContent import earth.maps.cardinal.data.room.PlaceContent
...@@ -102,11 +105,13 @@ fun ManagePlacesScreen( ...@@ -102,11 +105,13 @@ fun ManagePlacesScreen(
val clipboard by viewModel.clipboard.collectAsState(emptySet()) val clipboard by viewModel.clipboard.collectAsState(emptySet())
val selectedItems by viewModel.selectedItems.collectAsState() val selectedItems by viewModel.selectedItems.collectAsState()
val isAllSelected by viewModel.isAllSelected.collectAsState(initial = false) val isAllSelected by viewModel.isAllSelected.collectAsState(initial = false)
val errorMessage by viewModel.errorMessage.collectAsState()
var showDeleteConfirmation by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) }
var showCreateListDialog by remember { mutableStateOf(false) } var showCreateListDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) } var showEditDialog by remember { mutableStateOf(false) }
var editingItem by remember { mutableStateOf<ListContent?>(null) } var editingItem by remember { mutableStateOf<ListContent?>(null) }
var fabMenuExpanded by remember { mutableStateOf(false) } var fabMenuExpanded by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
// Initialize the view model with the listId if provided // Initialize the view model with the listId if provided
LaunchedEffect(listId) { LaunchedEffect(listId) {
...@@ -114,13 +119,28 @@ fun ManagePlacesScreen( ...@@ -114,13 +119,28 @@ fun ManagePlacesScreen(
} }
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.safeDrawing, topBar = { contentWindowInsets = WindowInsets.safeDrawing,
snackbarHost = { SnackbarHost(snackbarHostState, modifier = Modifier.padding(bottom = TOOLBAR_HEIGHT_DP)) },
topBar = {
ManagePlacesTopBar( ManagePlacesTopBar(
navController = navController, navController = navController,
title = currentListName ?: stringResource(string.saved_places_title_case), title = currentListName ?: stringResource(string.saved_places_title_case),
breadcrumbNames = parents.plus(currentListName ?: ""), breadcrumbNames = parents.plus(currentListName ?: ""),
) )
}) { paddingValues -> }) { 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) { if (currentListContent?.isEmpty() == true) {
Column( Column(
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
...@@ -129,7 +149,6 @@ fun ManagePlacesScreen( ...@@ -129,7 +149,6 @@ fun ManagePlacesScreen(
} }
} }
val coroutineScope = rememberCoroutineScope()
val currentListContent = currentListContent val currentListContent = currentListContent
Box(modifier = Modifier.padding(paddingValues)) { Box(modifier = Modifier.padding(paddingValues)) {
AnimatedVisibility( AnimatedVisibility(
...@@ -453,7 +472,7 @@ private fun EmptyListContent() { ...@@ -453,7 +472,7 @@ private fun EmptyListContent() {
private fun ListContentGrid( private fun ListContentGrid(
viewModel: ManagePlacesViewModel, viewModel: ManagePlacesViewModel,
content: List<Flow<ListContent?>>, content: List<Flow<ListContent?>>,
clipboard: Set<String>, clipboard: Set<ClipboardItem>,
selectedItems: Set<String>, selectedItems: Set<String>,
onItemClick: (ListContent) -> Unit, onItemClick: (ListContent) -> Unit,
onEditClick: (ListContent) -> Unit, onEditClick: (ListContent) -> Unit,
...@@ -483,7 +502,7 @@ private fun ListContentGrid( ...@@ -483,7 +502,7 @@ private fun ListContentGrid(
items(lists) { item -> items(lists) { item ->
ListItem( ListItem(
item = item, item = item,
isInClipboard = clipboard.contains(item.id), isInClipboard = clipboard.any { it.itemId == item.id },
isSelected = selectedItems.contains(item.id), isSelected = selectedItems.contains(item.id),
onSelectionChange = { viewModel.toggleSelection(item.id) }, onSelectionChange = { viewModel.toggleSelection(item.id) },
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
...@@ -505,7 +524,7 @@ private fun ListContentGrid( ...@@ -505,7 +524,7 @@ private fun ListContentGrid(
items(pinnedPlaces) { item -> items(pinnedPlaces) { item ->
PlaceItem( PlaceItem(
item = item, item = item,
isInClipboard = clipboard.contains(item.id), isInClipboard = clipboard.any { it.itemId == item.id },
isSelected = selectedItems.contains(item.id), isSelected = selectedItems.contains(item.id),
onSelectionChange = { viewModel.toggleSelection(item.id) }, onSelectionChange = { viewModel.toggleSelection(item.id) },
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
...@@ -530,7 +549,7 @@ private fun ListContentGrid( ...@@ -530,7 +549,7 @@ private fun ListContentGrid(
items(unpinnedPlaces) { item -> items(unpinnedPlaces) { item ->
PlaceItem( PlaceItem(
item = item, item = item,
isInClipboard = clipboard.contains(item.id), isInClipboard = clipboard.any { it.itemId == item.id },
isSelected = selectedItems.contains(item.id), isSelected = selectedItems.contains(item.id),
onSelectionChange = { viewModel.toggleSelection(item.id) }, onSelectionChange = { viewModel.toggleSelection(item.id) },
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
......
...@@ -18,9 +18,13 @@ ...@@ -18,9 +18,13 @@
package earth.maps.cardinal.ui.saved package earth.maps.cardinal.ui.saved
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.CutPasteRepository
import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.room.ItemType import earth.maps.cardinal.data.room.ItemType
...@@ -42,6 +46,7 @@ import javax.inject.Inject ...@@ -42,6 +46,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ManagePlacesViewModel @Inject constructor( class ManagePlacesViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
private val savedPlaceRepository: SavedPlaceRepository, private val savedPlaceRepository: SavedPlaceRepository,
private val savedListRepository: SavedListRepository, private val savedListRepository: SavedListRepository,
private val listItemDao: ListItemDao, private val listItemDao: ListItemDao,
...@@ -92,7 +97,11 @@ class ManagePlacesViewModel @Inject constructor( ...@@ -92,7 +97,11 @@ class ManagePlacesViewModel @Inject constructor(
list?.let { savedListRepository.getListContent(it.id) } ?: flowOf(null) list?.let { savedListRepository.getListContent(it.id) } ?: flowOf(null)
} }
val clipboard: Flow<Set<String>> = cutPasteRepository.clipboard val clipboard: Flow<Set<ClipboardItem>> = cutPasteRepository.clipboard
// Error messages for UI display
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage
suspend fun setInitialList(listId: String?) { suspend fun setInitialList(listId: String?) {
if (listId != null) { if (listId != null) {
...@@ -174,23 +183,55 @@ class ManagePlacesViewModel @Inject constructor( ...@@ -174,23 +183,55 @@ class ManagePlacesViewModel @Inject constructor(
} }
fun cutSelected() { fun cutSelected() {
val newClipboard = _selectedItems.value.toSet() viewModelScope.launch {
cutPasteRepository.clipboard.value = newClipboard val currentListId = _currentListId.value ?: return@launch
clearSelection() 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() { fun pasteSelected() {
val currentListId = currentListId.value ?: return val currentListId = currentListId.value ?: return
viewModelScope.launch { 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 val itemsInListCount = listItemDao.getItemsInList(currentListId).size
cutPasteRepository.clipboard.value.forEachIndexed { index, id -> clipboardItems.forEachIndexed { index, clipboardItem ->
listItemDao.moveItem(id, currentListId, itemsInListCount + index) listItemDao.moveItem(clipboardItem.itemId, currentListId, itemsInListCount + index)
} }
cutPasteRepository.clipboard.value = emptySet() cutPasteRepository.clipboard.value = emptySet()
} }
} }
fun clearErrorMessage() {
_errorMessage.value = null
}
fun updatePlace( fun updatePlace(
id: String, id: String,
customName: String?, customName: String?,
......
...@@ -264,4 +264,5 @@ ...@@ -264,4 +264,5 @@
<string name="category_transportation">Transportation</string> <string name="category_transportation">Transportation</string>
<string name="category_entertainment">Entertainment</string> <string name="category_entertainment">Entertainment</string>
<string name="category_nightlife">Nightlife</string> <string name="category_nightlife">Nightlife</string>
<string name="cannot_paste_a_list_into_itself_or_one_of_its_sublists">Cannot paste a list into itself or one of its sublists</string>
</resources> </resources>
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<Context>(relaxed = true)
private val mockDatabase = mockk<AppDatabase>(relaxed = false)
private val mockListDao = mockk<SavedListDao>(relaxed = false)
private val mockListItemDao = mockk<ListItemDao>(relaxed = false)
private val mockPlaceDao = mockk<SavedPlaceDao>(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<String>()
// 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