From 120b2f57236b8ba16aa012f027eced3ffa6d6b61 Mon Sep 17 00:00:00 2001 From: Ellen Poe Date: Mon, 6 Oct 2025 21:13:48 -0700 Subject: [PATCH] test: add tests for ManagePlacesViewModel --- cardinal-android/AGENTS.md | 182 +++++ .../ui/saved/ManagePlacesViewModel.kt | 8 +- .../ui/saved/ManagePlacesViewModelTest.kt | 632 ++++++++++++++++++ 3 files changed, 817 insertions(+), 5 deletions(-) create mode 100644 cardinal-android/AGENTS.md create mode 100644 cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt diff --git a/cardinal-android/AGENTS.md b/cardinal-android/AGENTS.md new file mode 100644 index 0000000..1cacb90 --- /dev/null +++ b/cardinal-android/AGENTS.md @@ -0,0 +1,182 @@ +# Cardinal Maps AGENTS.md + +Cardinal Maps is an Android mapping application built with a focus on usability. Material 3 and Material 3 Expressive components are used throughout. The app is based on a model-view-viewmodel architecture (MVVM) and is designed with the principle of separation of concerns in mind to ensure code quality and testability. Hilt is used for dependency injection, Room is used for data persistence. Robolectric, JUnit and mockk are the technologies we use for testing. + +## Project Structure + +``` +cardinal-android/ - Main Android application with Kotlin/Compose +├── app/ - Android app module with UI and business logic +├── context/ - Shared context module +└── gradle/ - Build configuration + +cardinal-geocoder/ - Rust-based geocoding engine with UniFFI bindings +├── src/ - Rust source code +├── dictionaries/ - Language-specific geocoding dictionaries +└── bin/ - UniFFI bindings generator +``` + +## Architecture + +### MVVM Implementation +- **View**: Jetpack Compose UI components in `ui/` directory +- **ViewModel**: Business logic and state management, annotated with `@HiltViewModel` +- **Model**: Data layer with repositories, Room database, and external API integrations + +### Key Architectural Patterns +- **Repository Pattern**: Abstracted data access through repository classes +- **Dependency Injection**: Hilt for managing dependencies and scoping +- **State Management**: StateFlow and MutableStateFlow for reactive UI updates +- **Navigation**: Jetpack Navigation with Compose for screen management + +### Data Flow +1. UI components observe StateFlow from ViewModels +2. ViewModels interact with repositories for data operations +3. Repositories handle local (Room) and remote (API) data sources +4. Geocoding requests are handled by the Rust-based geocoding service + +## Key Features + +### Mapping +- Interactive maps using MapLibre GL Native +- Custom map rendering with vector tiles +- Offline map capabilities +- Location tracking and compass integration + +### Geocoding & Search +- Offline geocoding using Rust-based engine +- Multi-language support with extensive dictionaries +- Place search with autocomplete +- Address formatting and parsing + +### Routing & Navigation +- Valhalla integration for routing calculations +- Ferrostar for turn-by-turn navigation +- Multiple routing profiles (driving, walking, cycling, transit) +- Offline routing capabilities + +### Place Management +- Save and organize places into lists +- Import/export functionality +- Custom place categorization +- Quick suggestions and favorites + +### Transit +- Public transportation directions +- Transit stop information +- Nearby transit stations +- Transit schedule integration + +## Technology Stack + +### Core Technologies +- **Kotlin**: Primary programming language +- **Jetpack Compose**: Modern UI toolkit +- **Material 3 & Material 3 Expressive**: Design system +- **Hilt**: Dependency injection +- **Room**: Database persistence +- **MapLibre GL Native**: Map rendering +- **Rust**: High-performance geocoding engine + +### External Services +- **Valhalla**: Open-source routing engine +- **Ferrostar**: Navigation framework +- **Pelias**: Geocoding service (configurable) + +### Build Tools +- **Gradle (Kotlin DSL)**: Build system +- **Cargo NDK**: Rust compilation for Android +- **UniFFI**: Rust-Kotlin bindings +- **KSP**: Kotlin Symbol Processing + +## Development Setup + +### Prerequisites +- Android Studio (latest version) +- JDK 8 or later +- Rust (for building geocoding component) +- Android SDK and NDK + +### Building the Project +1. Clone the repository +2. Run `./gradlew build` to build the Android application +3. The Rust geocoding component will be automatically compiled + +### Running the Application +- Debug build: `./gradlew installDebug` +- Release build: `./gradlew assembleRelease` + +### Debugging Tips +- Use Android Studio's debugger for Kotlin code +- Logcat for runtime logs +- Rust debugger for geocoding component issues +- Chrome DevTools for web-based components + +## Development Tips + +* Maps applications are inherently complex, and despite our best efforts, some of the logic is very tricky. When writing tests, write only one test at a time, then run it with `./gradlew test` to ensure it passes before moving on. +* The Android ecosystem moves fast, and some of the patterns, components and libraries we use may be from after your knowledge cutoff date. Fortunately, this is not a green-field project. Use the surrounding context to see how things work. +* Run `./gradlew check` regularly. +* We have a comprehensive linting and CI process, so don't concern yourself with unused imports or other trivial tasks. + +## Testing + +### Test Structure +- **Unit Tests**: Located in `src/test/` for business logic +- **Integration Tests**: Located in `src/androidTest/` for UI components +- **Robolectric**: For testing Android components without device +- **Mockk**: For mocking dependencies in tests + +### Running Tests +- Run all tests: `./gradlew test` +- Run specific test: `./gradlew test` +- Run integration tests: `./gradlew connectedAndroidTest` + +### Testing Guidelines +- Write focused, single-purpose tests +- Mock external dependencies (geocoding services, APIs) +- Test both success and failure scenarios +- Use test fixtures for consistent test data + +## Key UI Components + +### Navigation +- **Bottom Navigation**: Main app navigation with search, favorites, nearby, transit, and offline areas +- **Bottom Sheets**: Expandable panels for place details, search results, and directions +- **Place Cards**: Detailed information display for locations +- **Search Interface**: Expandable search with autocomplete suggestions + +### Main Screens +- **Home Search**: Main search interface with map +- **Place Card**: Detailed place information +- **Directions**: Route planning and display +- **Nearby Points of Interest**: Discover nearby places +- **Transit**: Public transportation information +- **Offline Areas**: Manage downloaded map regions +- **Settings**: App configuration and preferences + +## Data Models + +### Core Entities +- **Place**: Represents a location with coordinates, name, and address. +- **SavedPlace**: User-saved places with custom metadata. +- **Route**: Navigation route with turn-by-turn instructions. +- **Area**: Offline map region with bounding box. +- **GeocodeResult**: Geocoding search results. Prefer using `Place` when possible. + +### Database Schema +- Room database with DAOs for data access +- Relationships between places, lists, and user data +- Migration helpers for schema updates + +## External Configuration + +### API Endpoints +- **Pelias Geocoding**: Configurable base URL and API key +- **Valhalla Routing**: Configurable base URL and API key +- **Fallback to offline geocoding** when APIs are unavailable + +### Build Configuration +- Architecture-specific builds (arm64, x86_64) +- Debug and release variants +- Custom build types for different scenarios diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt index 37771c7..05eb991 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModel.kt @@ -60,10 +60,6 @@ class ManagePlacesViewModel @Inject constructor( } } - // Navigation stack to track the path through lists - private val _navigationStack = MutableStateFlow>(emptyList()) - val navigationStack: StateFlow> = _navigationStack - // Current list being displayed private val _currentListId = MutableStateFlow(null) val currentListId: StateFlow = _currentListId @@ -161,7 +157,7 @@ class ManagePlacesViewModel @Inject constructor( } } - // Placeholder for creating new list with selected places + // Create new list with selected places fun createNewListWithSelected(name: String) { viewModelScope.launch { val currentListId = _currentListId.value ?: return@launch @@ -173,12 +169,14 @@ class ManagePlacesViewModel @Inject constructor( _selectedItems.value.forEachIndexed { itemIndex, itemId -> listItemDao.moveItem(itemId, newListId = newListId, itemIndex + itemsInListCount) } + clearSelection() } } fun cutSelected() { val newClipboard = _selectedItems.value.toSet() cutPasteRepository.clipboard.value = newClipboard + clearSelection() } fun pasteSelected() { diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt new file mode 100644 index 0000000..87067ba --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/saved/ManagePlacesViewModelTest.kt @@ -0,0 +1,632 @@ +package earth.maps.cardinal.ui.saved + +import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.data.CutPasteRepository +import earth.maps.cardinal.data.Place +import earth.maps.cardinal.data.room.ItemType +import earth.maps.cardinal.data.room.ListItem +import earth.maps.cardinal.data.room.ListItemDao +import earth.maps.cardinal.data.room.SavedList +import earth.maps.cardinal.data.room.SavedListRepository +import earth.maps.cardinal.data.room.SavedPlace +import earth.maps.cardinal.data.room.SavedPlaceRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ManagePlacesViewModelTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + // Mock dependencies + private val mockSavedListRepository = mockk(relaxed = false) + private val mockSavedPlaceRepository = mockk(relaxed = false) + private val mockListItemDao = mockk(relaxed = false) + private val mockCutPasteRepository = mockk(relaxed = false) + + // Test data + private val rootList = SavedList( + id = "root-list-id", + name = "Saved Places", + description = null, + isRoot = true, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val testList = SavedList( + id = "test-list-id", + name = "Test List", + description = "Test Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val nestedList = SavedList( + id = "nested-list-id", + name = "Nested List", + description = "Nested Description", + isRoot = false, + isCollapsed = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val testPlace = SavedPlace( + id = "test-place-id", + placeId = null, + customName = null, + customDescription = null, + isPinned = false, + name = "Test Place", + type = "place", + icon = "icon", + latitude = 40.7128, + longitude = -74.0060, + houseNumber = null, + road = null, + city = null, + state = null, + postcode = null, + country = null, + countryCode = null, + isTransitStop = false, + transitStopId = null, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + private val listItem1 = ListItem( + listId = testList.id, + itemId = testPlace.id, + itemType = ItemType.PLACE, + position = 0, + addedAt = System.currentTimeMillis() + ) + + private lateinit var viewModel: ManagePlacesViewModel + + @Before + fun setup() { + // 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.getItemsInList(any()) } returns Result.success(emptyList()) + 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.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") + + // 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.toPlace(any()) } returns Place( + id = testPlace.id, + name = testPlace.name, + description = testPlace.type, + icon = testPlace.icon, + latLng = earth.maps.cardinal.data.LatLng(testPlace.latitude, testPlace.longitude), + address = null, + isTransitStop = testPlace.isTransitStop, + transitStopId = testPlace.transitStopId + ) + coEvery { mockSavedPlaceRepository.deletePlace(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()) + + viewModel = ManagePlacesViewModel( + savedPlaceRepository = mockSavedPlaceRepository, + savedListRepository = mockSavedListRepository, + listItemDao = mockListItemDao, + cutPasteRepository = mockCutPasteRepository + ) + } + + @Test + 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 + val listName = viewModel.currentListName.first() + assertEquals("Saved Places", listName) + } + + @Test + 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) + } + + @Test + fun `setInitialList with invalid listId should not navigate`() = runTest { + // When + viewModel.setInitialList("invalid-id") + + // Then + assertEquals(null, viewModel.currentList.value) + } + + @Test + fun `currentListName should return current list name`() = runTest { + // Given + viewModel.setInitialList(testList.id) + + // When + val listName = viewModel.currentListName.first() + + // Then + assertEquals("Test List", listName) + } + + @Test + fun `currentListName should return root list name when no current list`() = runTest { + // Given - ViewModel initialized with no current list + val freshViewModel = ManagePlacesViewModel( + savedPlaceRepository = mockSavedPlaceRepository, + savedListRepository = mockSavedListRepository, + listItemDao = mockListItemDao, + cutPasteRepository = mockCutPasteRepository + ) + + // When + val listName = freshViewModel.currentListName.first() + + // Then + assertEquals("Saved Places", listName) + } + + @Test + 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)) + } + + @Test + fun `toggleSelection should remove item from selection when already selected`() = runTest { + // Given + viewModel.setInitialList(testList.id) + viewModel.toggleSelection(testPlace.id) + + // When + viewModel.toggleSelection(testPlace.id) + + // Then + assertFalse(viewModel.selectedItems.value.contains(testPlace.id)) + } + + @Test + fun `selectAll should select all items in current list`() = runTest { + // Given + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.selectAll() + advanceUntilIdle() + + // Then + assertTrue(viewModel.selectedItems.value.contains(testPlace.id)) + } + + @Test + fun `clearSelection should clear all selected items`() = runTest { + // Given + viewModel.setInitialList(testList.id) + viewModel.toggleSelection(testPlace.id) + + // When + viewModel.clearSelection() + + // Then + assertTrue(viewModel.selectedItems.value.isEmpty()) + } + + @Test + fun `isAllSelected should be true when all items are selected`() = runTest { + // Given + viewModel.setInitialList(testList.id) + advanceUntilIdle() + viewModel.toggleSelection(testPlace.id) // Manually select the item + advanceUntilIdle() + + // When + val result = viewModel.isAllSelected.first() + + // Then + assertTrue(result) + } + + @Test + fun `isAllSelected should be false when not all items are selected`() = runTest { + // Given + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + val result = viewModel.isAllSelected.first() + + // Then + assertFalse(result) + } + + @Test + fun `deleteSelected should delete selected place`() = runTest { + // Given + viewModel.setInitialList(testList.id) + advanceUntilIdle() + viewModel.toggleSelection(testPlace.id) + + // When + viewModel.deleteSelected() + advanceUntilIdle() + + // Then + coVerify { mockSavedPlaceRepository.deletePlace(testPlace.id) } + assertTrue(viewModel.selectedItems.value.isEmpty()) + } + + @Test + fun `deleteSelected should delete selected list`() = runTest { + // Given - We need to mock a list that contains the nested list + val listItemWithNestedList = ListItem( + listId = testList.id, + itemId = nestedList.id, + itemType = ItemType.LIST, + position = 1, + addedAt = System.currentTimeMillis() + ) + + 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()) + } + + @Test + 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 { 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) } + assertTrue(viewModel.selectedItems.value.isEmpty()) // Selection should be cleared + } + + @Test + fun `cutSelected should update clipboard with selected items`() = runTest { + // Given + val clipboardFlow = MutableStateFlow>(emptySet()) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + viewModel.toggleSelection(testPlace.id) + + // When + viewModel.cutSelected() + + // Then + assertEquals(setOf(testPlace.id), 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 clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then + coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } + assertEquals(emptySet(), clipboardFlow.value) + } + + @Test + fun `clipboard should reflect CutPasteRepository clipboard`() = runTest { + // Given + val clipboardItems = setOf(testPlace.id) + every { mockCutPasteRepository.clipboard } returns MutableStateFlow(clipboardItems) + + // When + val freshViewModel = ManagePlacesViewModel( + savedPlaceRepository = mockSavedPlaceRepository, + savedListRepository = mockSavedListRepository, + listItemDao = mockListItemDao, + cutPasteRepository = mockCutPasteRepository + ) + + // Then + assertEquals(clipboardItems, freshViewModel.clipboard.first()) + } + + @Test + fun `updatePlace should call repository with correct parameters`() = runTest { + // Given + val customName = "Custom Name" + val customDescription = "Custom Description" + val isPinned = true + + // When + viewModel.updatePlace(testPlace.id, customName, customDescription, isPinned) + advanceUntilIdle() + + // Then + coVerify { + mockSavedPlaceRepository.updatePlace( + placeId = testPlace.id, + customName = customName, + customDescription = customDescription, + isPinned = isPinned + ) + } + } + + @Test + fun `updateList should call repository with correct parameters`() = runTest { + // Given + val newName = "New List Name" + val newDescription = "New Description" + + // When + viewModel.updateList(testList.id, newName, newDescription) + advanceUntilIdle() + + // Then + coVerify { + mockSavedListRepository.updateList( + listId = testList.id, + name = newName, + description = newDescription + ) + } + } + + @Test + 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) + } + + @Test + 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) + } + + @Test + fun `cutSelected should clear selection after cutting`() = runTest { + // Given + viewModel.setInitialList(testList.id) + advanceUntilIdle() + viewModel.toggleSelection(testPlace.id) + + // When + viewModel.cutSelected() + + // Then + assertTrue(viewModel.selectedItems.value.isEmpty()) + } + + @Test + 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 { 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()) + } + + @Test + fun `pasteSelected should handle multiple items correctly`() = runTest { + // Given + val testPlace2 = SavedPlace( + id = "test-place-id-2", + placeId = null, + customName = null, + customDescription = null, + isPinned = false, + name = "Test Place 2", + type = "place", + icon = "icon", + latitude = 40.7128, + longitude = -74.0060, + houseNumber = null, + road = null, + city = null, + state = null, + postcode = null, + country = null, + countryCode = null, + isTransitStop = false, + transitStopId = null, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + val clipboardItems = setOf(testPlace.id, testPlace2.id) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then + coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 1) } + coVerify { mockListItemDao.moveItem(testPlace2.id, testList.id, 2) } + assertEquals(emptySet(), clipboardFlow.value) + } + + @Test + fun `pasteSelected should handle empty target list`() = runTest { + // Given + val clipboardItems = setOf(testPlace.id) + val clipboardFlow = MutableStateFlow(clipboardItems) + every { mockCutPasteRepository.clipboard } returns clipboardFlow + + // Mock empty target list + coEvery { mockListItemDao.getItemsInList(testList.id) } returns emptyList() + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.pasteSelected() + advanceUntilIdle() + + // Then + coVerify { mockListItemDao.moveItem(testPlace.id, testList.id, 0) } + assertEquals(emptySet(), clipboardFlow.value) + } + + @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()) + } + + @Test + fun `createNewListWithSelected should handle creation failures`() = runTest { + // Given + 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) + } + + @Test + fun `selectAll should handle empty list gracefully`() = runTest { + // Given - empty list + coEvery { mockSavedListRepository.getItemsInList(testList.id) } returns Result.success(emptyList()) + + viewModel.setInitialList(testList.id) + advanceUntilIdle() + + // When + viewModel.selectAll() + advanceUntilIdle() + + // Then + assertTrue(viewModel.selectedItems.value.isEmpty()) + } +} \ No newline at end of file -- GitLab