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 @@
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<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(
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 {
private const val TAG = "SavedListRepository"
}
......
......@@ -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<ListContent?>(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<Flow<ListContent?>>,
clipboard: Set<String>,
clipboard: Set<ClipboardItem>,
selectedItems: Set<String>,
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) },
......
......@@ -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<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?) {
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?,
......
......@@ -264,4 +264,5 @@
<string name="category_transportation">Transportation</string>
<string name="category_entertainment">Entertainment</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>
/*
* 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
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<Context>()
private val mockSavedListRepository = mockk<SavedListRepository>(relaxed = false)
private val mockSavedPlaceRepository = mockk<SavedPlaceRepository>(relaxed = false)
private val mockListItemDao = mockk<ListItemDao>(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<Set<String>>(emptySet())
val clipboardFlow = MutableStateFlow<Set<ClipboardItem>>(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<String>(), clipboardFlow.value)
assertEquals(emptySet<ClipboardItem>(), 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<String>(), clipboardFlow.value)
assertEquals(emptySet<ClipboardItem>(), 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<String>(), clipboardFlow.value)
assertEquals(emptySet<ClipboardItem>(), 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<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