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
package earth.maps.cardinal.ui.saved package earth.maps.cardinal.ui.saved
import android.content.Context
import earth.maps.cardinal.MainCoroutineRule 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.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
...@@ -36,6 +39,7 @@ class ManagePlacesViewModelTest { ...@@ -36,6 +39,7 @@ class ManagePlacesViewModelTest {
val mainCoroutineRule = MainCoroutineRule() val mainCoroutineRule = MainCoroutineRule()
// Mock dependencies // Mock dependencies
private val mockContext = mockk<Context>()
private val mockSavedListRepository = mockk<SavedListRepository>(relaxed = false) private val mockSavedListRepository = mockk<SavedListRepository>(relaxed = false)
private val mockSavedPlaceRepository = mockk<SavedPlaceRepository>(relaxed = false) private val mockSavedPlaceRepository = mockk<SavedPlaceRepository>(relaxed = false)
private val mockListItemDao = mockk<ListItemDao>(relaxed = false) private val mockListItemDao = mockk<ListItemDao>(relaxed = false)
...@@ -108,30 +112,52 @@ class ManagePlacesViewModelTest { ...@@ -108,30 +112,52 @@ class ManagePlacesViewModelTest {
@Before @Before
fun setup() { 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 // Mock repository methods
coEvery { mockSavedListRepository.getRootList() } returns Result.success(rootList) coEvery { mockSavedListRepository.getRootList() } returns Result.success(rootList)
coEvery { mockSavedListRepository.getListById(any()) } returns Result.success(null) coEvery { mockSavedListRepository.getListById(any()) } returns Result.success(null)
coEvery { mockSavedListRepository.getListById(testList.id) } returns Result.success(testList) 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(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(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()) coEvery { mockSavedListRepository.getListContent(any()) } returns flowOf(emptyList())
// Mock additional repository methods needed for tests // Mock additional repository methods needed for tests
coEvery { mockSavedListRepository.deleteList(any()) } returns Result.success(Unit) coEvery { mockSavedListRepository.deleteList(any()) } returns Result.success(Unit)
coEvery { mockSavedListRepository.updateList(any(), any(), any()) } returns Result.success(Unit) coEvery { mockSavedListRepository.updateList(any(), any(), any()) } returns Result.success(
coEvery { mockSavedListRepository.createList(any(), any(), any(), any(), any()) } returns Result.success("new-list-id") Unit
)
coEvery {
mockSavedListRepository.createList(
any(),
any(),
any(),
any(),
any()
)
} returns Result.success("new-list-id")
// Mock DAO methods // Mock DAO methods
coEvery { mockListItemDao.getItemsInList(any()) } returns emptyList() coEvery { mockListItemDao.getItemsInList(any()) } returns emptyList()
coEvery { mockListItemDao.getItemsInList(testList.id) } returns listOf(listItem1) coEvery { mockListItemDao.getItemsInList(testList.id) } returns listOf(listItem1)
coEvery { mockListItemDao.moveItem(any(), any(), any()) } just runs coEvery { mockListItemDao.moveItem(any(), any(), any()) } just runs
// Mock place repository // Mock place repository
coEvery { mockSavedPlaceRepository.getPlaceById(any()) } returns Result.success(null) 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( coEvery { mockSavedPlaceRepository.toPlace(any()) } returns Place(
id = testPlace.id, id = testPlace.id,
name = testPlace.name, name = testPlace.name,
...@@ -143,12 +169,23 @@ class ManagePlacesViewModelTest { ...@@ -143,12 +169,23 @@ class ManagePlacesViewModelTest {
transitStopId = testPlace.transitStopId transitStopId = testPlace.transitStopId
) )
coEvery { mockSavedPlaceRepository.deletePlace(any()) } returns Result.success(Unit) 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 // Mock cut/paste repository
every { mockCutPasteRepository.clipboard } returns MutableStateFlow(emptySet()) every { mockCutPasteRepository.clipboard } returns MutableStateFlow(emptySet())
// Mock wouldCreateCycle method
coEvery { mockSavedListRepository.wouldCreateCycle(any(), any()) } returns false
viewModel = ManagePlacesViewModel( viewModel = ManagePlacesViewModel(
context = mockContext,
savedPlaceRepository = mockSavedPlaceRepository, savedPlaceRepository = mockSavedPlaceRepository,
savedListRepository = mockSavedListRepository, savedListRepository = mockSavedListRepository,
listItemDao = mockListItemDao, listItemDao = mockListItemDao,
...@@ -160,7 +197,7 @@ class ManagePlacesViewModelTest { ...@@ -160,7 +197,7 @@ class ManagePlacesViewModelTest {
fun `setInitialList with null should navigate to root list`() = runTest { fun `setInitialList with null should navigate to root list`() = runTest {
// When - use the existing viewModel instance // When - use the existing viewModel instance
viewModel.setInitialList(null) viewModel.setInitialList(null)
// Then - wait for the async operation to complete // Then - wait for the async operation to complete
advanceUntilIdle() advanceUntilIdle()
// Verify that the current list name is set to the root list name // Verify that the current list name is set to the root list name
...@@ -172,7 +209,7 @@ class ManagePlacesViewModelTest { ...@@ -172,7 +209,7 @@ class ManagePlacesViewModelTest {
fun `setInitialList with valid listId should navigate to that list`() = runTest { fun `setInitialList with valid listId should navigate to that list`() = runTest {
// When // When
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
// Then - wait for the async operation to complete // Then - wait for the async operation to complete
advanceUntilIdle() advanceUntilIdle()
assertEquals(testList, viewModel.currentList.value) assertEquals(testList, viewModel.currentList.value)
...@@ -182,7 +219,7 @@ class ManagePlacesViewModelTest { ...@@ -182,7 +219,7 @@ class ManagePlacesViewModelTest {
fun `setInitialList with invalid listId should not navigate`() = runTest { fun `setInitialList with invalid listId should not navigate`() = runTest {
// When // When
viewModel.setInitialList("invalid-id") viewModel.setInitialList("invalid-id")
// Then // Then
assertEquals(null, viewModel.currentList.value) assertEquals(null, viewModel.currentList.value)
} }
...@@ -191,10 +228,10 @@ class ManagePlacesViewModelTest { ...@@ -191,10 +228,10 @@ class ManagePlacesViewModelTest {
fun `currentListName should return current list name`() = runTest { fun `currentListName should return current list name`() = runTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
// When // When
val listName = viewModel.currentListName.first() val listName = viewModel.currentListName.first()
// Then // Then
assertEquals("Test List", listName) assertEquals("Test List", listName)
} }
...@@ -203,15 +240,16 @@ class ManagePlacesViewModelTest { ...@@ -203,15 +240,16 @@ class ManagePlacesViewModelTest {
fun `currentListName should return root list name when no current list`() = runTest { fun `currentListName should return root list name when no current list`() = runTest {
// Given - ViewModel initialized with no current list // Given - ViewModel initialized with no current list
val freshViewModel = ManagePlacesViewModel( val freshViewModel = ManagePlacesViewModel(
context = mockContext,
savedPlaceRepository = mockSavedPlaceRepository, savedPlaceRepository = mockSavedPlaceRepository,
savedListRepository = mockSavedListRepository, savedListRepository = mockSavedListRepository,
listItemDao = mockListItemDao, listItemDao = mockListItemDao,
cutPasteRepository = mockCutPasteRepository cutPasteRepository = mockCutPasteRepository
) )
// When // When
val listName = freshViewModel.currentListName.first() val listName = freshViewModel.currentListName.first()
// Then // Then
assertEquals("Saved Places", listName) assertEquals("Saved Places", listName)
} }
...@@ -220,10 +258,10 @@ class ManagePlacesViewModelTest { ...@@ -220,10 +258,10 @@ class ManagePlacesViewModelTest {
fun `toggleSelection should add item to selection when not selected`() = runTest { fun `toggleSelection should add item to selection when not selected`() = runTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
// When // When
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// Then // Then
assertTrue(viewModel.selectedItems.value.contains(testPlace.id)) assertTrue(viewModel.selectedItems.value.contains(testPlace.id))
} }
...@@ -233,10 +271,10 @@ class ManagePlacesViewModelTest { ...@@ -233,10 +271,10 @@ class ManagePlacesViewModelTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// Then // Then
assertFalse(viewModel.selectedItems.value.contains(testPlace.id)) assertFalse(viewModel.selectedItems.value.contains(testPlace.id))
} }
...@@ -246,11 +284,11 @@ class ManagePlacesViewModelTest { ...@@ -246,11 +284,11 @@ class ManagePlacesViewModelTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
// When // When
viewModel.selectAll() viewModel.selectAll()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
assertTrue(viewModel.selectedItems.value.contains(testPlace.id)) assertTrue(viewModel.selectedItems.value.contains(testPlace.id))
} }
...@@ -260,10 +298,10 @@ class ManagePlacesViewModelTest { ...@@ -260,10 +298,10 @@ class ManagePlacesViewModelTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.clearSelection() viewModel.clearSelection()
// Then // Then
assertTrue(viewModel.selectedItems.value.isEmpty()) assertTrue(viewModel.selectedItems.value.isEmpty())
} }
...@@ -275,10 +313,10 @@ class ManagePlacesViewModelTest { ...@@ -275,10 +313,10 @@ class ManagePlacesViewModelTest {
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) // Manually select the item viewModel.toggleSelection(testPlace.id) // Manually select the item
advanceUntilIdle() advanceUntilIdle()
// When // When
val result = viewModel.isAllSelected.first() val result = viewModel.isAllSelected.first()
// Then // Then
assertTrue(result) assertTrue(result)
} }
...@@ -288,10 +326,10 @@ class ManagePlacesViewModelTest { ...@@ -288,10 +326,10 @@ class ManagePlacesViewModelTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
// When // When
val result = viewModel.isAllSelected.first() val result = viewModel.isAllSelected.first()
// Then // Then
assertFalse(result) assertFalse(result)
} }
...@@ -302,11 +340,11 @@ class ManagePlacesViewModelTest { ...@@ -302,11 +340,11 @@ class ManagePlacesViewModelTest {
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.deleteSelected() viewModel.deleteSelected()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { mockSavedPlaceRepository.deletePlace(testPlace.id) } coVerify { mockSavedPlaceRepository.deletePlace(testPlace.id) }
assertTrue(viewModel.selectedItems.value.isEmpty()) assertTrue(viewModel.selectedItems.value.isEmpty())
...@@ -322,17 +360,19 @@ class ManagePlacesViewModelTest { ...@@ -322,17 +360,19 @@ class ManagePlacesViewModelTest {
position = 1, position = 1,
addedAt = System.currentTimeMillis() 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) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(nestedList.id) viewModel.toggleSelection(nestedList.id)
// When // When
viewModel.deleteSelected() viewModel.deleteSelected()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { mockSavedListRepository.deleteList(nestedList.id) } coVerify { mockSavedListRepository.deleteList(nestedList.id) }
assertTrue(viewModel.selectedItems.value.isEmpty()) assertTrue(viewModel.selectedItems.value.isEmpty())
...@@ -342,17 +382,25 @@ class ManagePlacesViewModelTest { ...@@ -342,17 +382,25 @@ class ManagePlacesViewModelTest {
fun `createNewListWithSelected should create new list with selected items`() = runTest { fun `createNewListWithSelected should create new list with selected items`() = runTest {
// Given // Given
val newListId = "new-list-id" 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() coEvery { mockListItemDao.getItemsInList(newListId) } returns emptyList()
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.createNewListWithSelected("New List") viewModel.createNewListWithSelected("New List")
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { mockSavedListRepository.createList("New List", testList.id, null, false, false) } coVerify { mockSavedListRepository.createList("New List", testList.id, null, false, false) }
coVerify { mockListItemDao.moveItem(testPlace.id, newListId, 0) } coVerify { mockListItemDao.moveItem(testPlace.id, newListId, 0) }
...@@ -362,54 +410,56 @@ class ManagePlacesViewModelTest { ...@@ -362,54 +410,56 @@ class ManagePlacesViewModelTest {
@Test @Test
fun `cutSelected should update clipboard with selected items`() = runTest { fun `cutSelected should update clipboard with selected items`() = runTest {
// Given // Given
val clipboardFlow = MutableStateFlow<Set<String>>(emptySet()) val clipboardFlow = MutableStateFlow<Set<ClipboardItem>>(emptySet())
every { mockCutPasteRepository.clipboard } returns clipboardFlow every { mockCutPasteRepository.clipboard } returns clipboardFlow
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.cutSelected() viewModel.cutSelected()
advanceUntilIdle()
// Then // 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 assertTrue(viewModel.selectedItems.value.isEmpty()) // Selection should be cleared
} }
@Test @Test
fun `pasteSelected should move items from clipboard to current list`() = runTest { fun `pasteSelected should move items from clipboard to current list`() = runTest {
// Given // Given
val clipboardItems = setOf(testPlace.id) val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE))
val clipboardFlow = MutableStateFlow(clipboardItems) val clipboardFlow = MutableStateFlow(clipboardItems)
every { mockCutPasteRepository.clipboard } returns clipboardFlow every { mockCutPasteRepository.clipboard } returns clipboardFlow
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
// When // When
viewModel.pasteSelected() viewModel.pasteSelected()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) }
assertEquals(emptySet<String>(), clipboardFlow.value) assertEquals(emptySet<ClipboardItem>(), clipboardFlow.value)
} }
@Test @Test
fun `clipboard should reflect CutPasteRepository clipboard`() = runTest { fun `clipboard should reflect CutPasteRepository clipboard`() = runTest {
// Given // Given
val clipboardItems = setOf(testPlace.id) val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE))
every { mockCutPasteRepository.clipboard } returns MutableStateFlow(clipboardItems) every { mockCutPasteRepository.clipboard } returns MutableStateFlow(clipboardItems)
// When // When
val freshViewModel = ManagePlacesViewModel( val freshViewModel = ManagePlacesViewModel(
context = mockContext,
savedPlaceRepository = mockSavedPlaceRepository, savedPlaceRepository = mockSavedPlaceRepository,
savedListRepository = mockSavedListRepository, savedListRepository = mockSavedListRepository,
listItemDao = mockListItemDao, listItemDao = mockListItemDao,
cutPasteRepository = mockCutPasteRepository cutPasteRepository = mockCutPasteRepository
) )
// Then // Then
assertEquals(clipboardItems, freshViewModel.clipboard.first()) assertEquals(clipboardItems, freshViewModel.clipboard.first())
} }
...@@ -420,11 +470,11 @@ class ManagePlacesViewModelTest { ...@@ -420,11 +470,11 @@ class ManagePlacesViewModelTest {
val customName = "Custom Name" val customName = "Custom Name"
val customDescription = "Custom Description" val customDescription = "Custom Description"
val isPinned = true val isPinned = true
// When // When
viewModel.updatePlace(testPlace.id, customName, customDescription, isPinned) viewModel.updatePlace(testPlace.id, customName, customDescription, isPinned)
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { coVerify {
mockSavedPlaceRepository.updatePlace( mockSavedPlaceRepository.updatePlace(
...@@ -441,11 +491,11 @@ class ManagePlacesViewModelTest { ...@@ -441,11 +491,11 @@ class ManagePlacesViewModelTest {
// Given // Given
val newName = "New List Name" val newName = "New List Name"
val newDescription = "New Description" val newDescription = "New Description"
// When // When
viewModel.updateList(testList.id, newName, newDescription) viewModel.updateList(testList.id, newName, newDescription)
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { coVerify {
mockSavedListRepository.updateList( mockSavedListRepository.updateList(
...@@ -460,10 +510,10 @@ class ManagePlacesViewModelTest { ...@@ -460,10 +510,10 @@ class ManagePlacesViewModelTest {
fun `getSavedPlace should return place when found`() = runTest { fun `getSavedPlace should return place when found`() = runTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
// When // When
val result = viewModel.getSavedPlace(testPlace.id) val result = viewModel.getSavedPlace(testPlace.id)
// Then // Then
assertEquals(testPlace.id, result?.id) assertEquals(testPlace.id, result?.id)
} }
...@@ -472,10 +522,10 @@ class ManagePlacesViewModelTest { ...@@ -472,10 +522,10 @@ class ManagePlacesViewModelTest {
fun `getSavedPlace should return null when not found`() = runTest { fun `getSavedPlace should return null when not found`() = runTest {
// Given // Given
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
// When // When
val result = viewModel.getSavedPlace("non-existent-id") val result = viewModel.getSavedPlace("non-existent-id")
// Then // Then
assertEquals(null, result) assertEquals(null, result)
} }
...@@ -486,10 +536,11 @@ class ManagePlacesViewModelTest { ...@@ -486,10 +536,11 @@ class ManagePlacesViewModelTest {
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.cutSelected() viewModel.cutSelected()
advanceUntilIdle() // Wait for the coroutine to complete
// Then // Then
assertTrue(viewModel.selectedItems.value.isEmpty()) assertTrue(viewModel.selectedItems.value.isEmpty())
} }
...@@ -498,17 +549,25 @@ class ManagePlacesViewModelTest { ...@@ -498,17 +549,25 @@ class ManagePlacesViewModelTest {
fun `createNewListWithSelected should clear selection after creating list`() = runTest { fun `createNewListWithSelected should clear selection after creating list`() = runTest {
// Given // Given
val newListId = "new-list-id" 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() coEvery { mockListItemDao.getItemsInList(newListId) } returns emptyList()
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.createNewListWithSelected("New List") viewModel.createNewListWithSelected("New List")
advanceUntilIdle() advanceUntilIdle()
// Then // Then
assertTrue(viewModel.selectedItems.value.isEmpty()) assertTrue(viewModel.selectedItems.value.isEmpty())
} }
...@@ -539,59 +598,62 @@ class ManagePlacesViewModelTest { ...@@ -539,59 +598,62 @@ class ManagePlacesViewModelTest {
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
updatedAt = 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) val clipboardFlow = MutableStateFlow(clipboardItems)
every { mockCutPasteRepository.clipboard } returns clipboardFlow every { mockCutPasteRepository.clipboard } returns clipboardFlow
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
// When // When
viewModel.pasteSelected() viewModel.pasteSelected()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) }
coVerify { mockListItemDao.moveItem(testPlace2.id, testList.id, 2) } coVerify { mockListItemDao.moveItem(testPlace2.id, testList.id, 2) }
assertEquals(emptySet<String>(), clipboardFlow.value) assertEquals(emptySet<ClipboardItem>(), clipboardFlow.value)
} }
@Test @Test
fun `pasteSelected should handle empty target list`() = runTest { fun `pasteSelected should handle empty target list`() = runTest {
// Given // Given
val clipboardItems = setOf(testPlace.id) val clipboardItems = setOf(ClipboardItem(testPlace.id, ItemType.PLACE))
val clipboardFlow = MutableStateFlow(clipboardItems) val clipboardFlow = MutableStateFlow(clipboardItems)
every { mockCutPasteRepository.clipboard } returns clipboardFlow every { mockCutPasteRepository.clipboard } returns clipboardFlow
// Mock empty target list // Mock empty target list
coEvery { mockListItemDao.getItemsInList(testList.id) } returns emptyList() coEvery { mockListItemDao.getItemsInList(testList.id) } returns emptyList()
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
// When // When
viewModel.pasteSelected() viewModel.pasteSelected()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 0) } coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 0) }
assertEquals(emptySet<String>(), clipboardFlow.value) assertEquals(emptySet<ClipboardItem>(), clipboardFlow.value)
} }
@Test @Test
fun `deleteSelected should handle repository failures gracefully`() = runTest { fun `deleteSelected should handle repository failures gracefully`() = runTest {
// Given // Given
coEvery { mockSavedPlaceRepository.deletePlace(any()) } returns Result.failure(Exception("Delete failed")) coEvery { mockSavedPlaceRepository.deletePlace(any()) } returns Result.failure(Exception("Delete failed"))
viewModel.setInitialList(testList.id) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
// When // When
viewModel.deleteSelected() viewModel.deleteSelected()
advanceUntilIdle() advanceUntilIdle()
// Then - should still clear selection even if delete fails // Then - should still clear selection even if delete fails
assertTrue(viewModel.selectedItems.value.isEmpty()) assertTrue(viewModel.selectedItems.value.isEmpty())
} }
...@@ -599,17 +661,25 @@ class ManagePlacesViewModelTest { ...@@ -599,17 +661,25 @@ class ManagePlacesViewModelTest {
@Test @Test
fun `createNewListWithSelected should handle creation failures`() = runTest { fun `createNewListWithSelected should handle creation failures`() = runTest {
// Given // 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) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
viewModel.toggleSelection(testPlace.id) viewModel.toggleSelection(testPlace.id)
val initialSelection = viewModel.selectedItems.value val initialSelection = viewModel.selectedItems.value
// When // When
viewModel.createNewListWithSelected("New List") viewModel.createNewListWithSelected("New List")
advanceUntilIdle() advanceUntilIdle()
// Then - selection should not be cleared if creation fails // Then - selection should not be cleared if creation fails
assertEquals(initialSelection, viewModel.selectedItems.value) assertEquals(initialSelection, viewModel.selectedItems.value)
} }
...@@ -617,16 +687,332 @@ class ManagePlacesViewModelTest { ...@@ -617,16 +687,332 @@ class ManagePlacesViewModelTest {
@Test @Test
fun `selectAll should handle empty list gracefully`() = runTest { fun `selectAll should handle empty list gracefully`() = runTest {
// Given - empty list // 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) viewModel.setInitialList(testList.id)
advanceUntilIdle() advanceUntilIdle()
// When // When
viewModel.selectAll() viewModel.selectAll()
advanceUntilIdle() advanceUntilIdle()
// Then // Then
assertTrue(viewModel.selectedItems.value.isEmpty()) 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<ClipboardItem>(), 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<ClipboardItem>(), 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<ClipboardItem>(), 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