// // CollectionViewModelTests.swift // PlantGuideTests // // Unit tests for CollectionViewModel - the view model managing plant collection display, // filtering, search, and user interactions like favoriting and deleting plants. // import XCTest @testable import PlantGuide // MARK: - Mock Protocols /// Mock implementation of FetchCollectionUseCaseProtocol for testing final class MockFetchCollectionUseCase: FetchCollectionUseCaseProtocol, @unchecked Sendable { var plantsToReturn: [Plant] = [] var statisticsToReturn: CollectionStatistics? var errorToThrow: Error? var executeCallCount = 0 var executeWithFilterCallCount = 0 var fetchStatisticsCallCount = 0 var lastFilter: PlantFilter? func execute() async throws -> [Plant] { executeCallCount += 1 if let error = errorToThrow { throw error } return plantsToReturn } func execute(filter: PlantFilter) async throws -> [Plant] { executeWithFilterCallCount += 1 lastFilter = filter if let error = errorToThrow { throw error } return plantsToReturn } func fetchStatistics() async throws -> CollectionStatistics { fetchStatisticsCallCount += 1 if let error = errorToThrow { throw error } return statisticsToReturn ?? CollectionStatistics( totalPlants: plantsToReturn.count, favoriteCount: plantsToReturn.filter { $0.isFavorite }.count, familyDistribution: [:], identificationSourceBreakdown: [:], plantsAddedThisMonth: 0, upcomingTasksCount: 0, overdueTasksCount: 0 ) } } /// Mock implementation of ToggleFavoriteUseCaseProtocol for testing final class MockToggleFavoriteUseCase: ToggleFavoriteUseCaseProtocol, @unchecked Sendable { var resultToReturn: Bool = true var errorToThrow: Error? var executeCallCount = 0 var lastPlantID: UUID? func execute(plantID: UUID) async throws -> Bool { executeCallCount += 1 lastPlantID = plantID if let error = errorToThrow { throw error } return resultToReturn } } /// Mock implementation of DeletePlantUseCaseProtocol for testing final class MockDeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable { var errorToThrow: Error? var executeCallCount = 0 var lastPlantID: UUID? func execute(plantID: UUID) async throws { executeCallCount += 1 lastPlantID = plantID if let error = errorToThrow { throw error } } } // MARK: - CollectionViewModelTests @MainActor final class CollectionViewModelTests: XCTestCase { // MARK: - Properties private var sut: CollectionViewModel! private var mockFetchUseCase: MockFetchCollectionUseCase! private var mockToggleFavoriteUseCase: MockToggleFavoriteUseCase! private var mockDeleteUseCase: MockDeletePlantUseCase! // MARK: - Test Lifecycle override func setUp() { super.setUp() mockFetchUseCase = MockFetchCollectionUseCase() mockToggleFavoriteUseCase = MockToggleFavoriteUseCase() mockDeleteUseCase = MockDeletePlantUseCase() sut = CollectionViewModel( fetchCollectionUseCase: mockFetchUseCase, toggleFavoriteUseCase: mockToggleFavoriteUseCase, deletePlantUseCase: mockDeleteUseCase ) } override func tearDown() { sut = nil mockFetchUseCase = nil mockToggleFavoriteUseCase = nil mockDeleteUseCase = nil super.tearDown() } // MARK: - Test Helpers private func createTestPlant( id: UUID = UUID(), scientificName: String = "Monstera deliciosa", commonNames: [String] = ["Swiss Cheese Plant"], family: String = "Araceae", isFavorite: Bool = false ) -> Plant { Plant( id: id, scientificName: scientificName, commonNames: commonNames, family: family, genus: "Monstera", identificationSource: .onDeviceML, isFavorite: isFavorite ) } // MARK: - loadPlants Tests func testLoadPlants_WhenSuccessful_LoadsAndFiltersPlantsCorrectly() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]), createTestPlant(scientificName: "Sansevieria trifasciata", commonNames: ["Snake Plant"]) ] mockFetchUseCase.plantsToReturn = testPlants // When await sut.loadPlants() // Then XCTAssertEqual(sut.plants.count, 3) XCTAssertFalse(sut.isLoading) XCTAssertNil(sut.error) XCTAssertEqual(mockFetchUseCase.executeWithFilterCallCount, 1) } func testLoadPlants_WhenFailed_SetsErrorState() async { // Given mockFetchUseCase.errorToThrow = FetchCollectionError.repositoryFetchFailed(NSError(domain: "test", code: -1)) // When await sut.loadPlants() // Then XCTAssertTrue(sut.plants.isEmpty) XCTAssertFalse(sut.isLoading) XCTAssertNotNil(sut.error) } func testLoadPlants_WhenAlreadyLoading_DoesNotStartAnotherLoad() async { // Given mockFetchUseCase.plantsToReturn = [createTestPlant()] // When - Start first load let loadTask = Task { await sut.loadPlants() } // Immediately start another load await sut.loadPlants() await loadTask.value // Then - Should only have called execute once because second call was ignored XCTAssertEqual(mockFetchUseCase.executeWithFilterCallCount, 1) } func testLoadPlants_AppliesCurrentFilter() async { // Given let customFilter = PlantFilter(isFavorite: true) sut.currentFilter = customFilter mockFetchUseCase.plantsToReturn = [createTestPlant(isFavorite: true)] // When await sut.loadPlants() // Then XCTAssertEqual(mockFetchUseCase.lastFilter?.isFavorite, true) } // MARK: - refreshPlants Tests func testRefreshPlants_WhenSuccessful_RefreshesCollection() async { // Given let initialPlants = [createTestPlant(scientificName: "Plant A")] let refreshedPlants = [ createTestPlant(scientificName: "Plant A"), createTestPlant(scientificName: "Plant B") ] mockFetchUseCase.plantsToReturn = initialPlants await sut.loadPlants() mockFetchUseCase.plantsToReturn = refreshedPlants // When await sut.refreshPlants() // Then XCTAssertEqual(sut.plants.count, 2) XCTAssertFalse(sut.isLoading) } func testRefreshPlants_WhenFailed_SetsErrorButKeepsExistingData() async { // Given let initialPlants = [createTestPlant()] mockFetchUseCase.plantsToReturn = initialPlants await sut.loadPlants() mockFetchUseCase.errorToThrow = FetchCollectionError.repositoryFetchFailed(NSError(domain: "test", code: -1)) // When await sut.refreshPlants() // Then XCTAssertNotNil(sut.error) XCTAssertFalse(sut.isLoading) } // MARK: - toggleFavorite Tests func testToggleFavorite_WhenSuccessful_UpdatesPlantFavoriteStatusOptimistically() async { // Given let plantID = UUID() let testPlant = createTestPlant(id: plantID, isFavorite: false) mockFetchUseCase.plantsToReturn = [testPlant] await sut.loadPlants() mockToggleFavoriteUseCase.resultToReturn = true // When await sut.toggleFavorite(plantID: plantID) // Then XCTAssertEqual(mockToggleFavoriteUseCase.executeCallCount, 1) XCTAssertEqual(mockToggleFavoriteUseCase.lastPlantID, plantID) // After successful toggle, the plant should be marked as favorite if let updatedPlant = sut.plants.first(where: { $0.id == plantID }) { XCTAssertTrue(updatedPlant.isFavorite) } } func testToggleFavorite_WhenFailed_RollsBackOptimisticUpdate() async { // Given let plantID = UUID() let testPlant = createTestPlant(id: plantID, isFavorite: false) mockFetchUseCase.plantsToReturn = [testPlant] await sut.loadPlants() mockToggleFavoriteUseCase.errorToThrow = ToggleFavoriteError.updateFailed(NSError(domain: "test", code: -1)) // When await sut.toggleFavorite(plantID: plantID) // Then XCTAssertNotNil(sut.error) // After failed toggle, the plant should be rolled back to original state (not favorite) if let plant = sut.plants.first(where: { $0.id == plantID }) { XCTAssertFalse(plant.isFavorite) } } func testToggleFavorite_WhenPlantNotFound_DoesNotCallUseCase() async { // Given mockFetchUseCase.plantsToReturn = [] await sut.loadPlants() let nonExistentPlantID = UUID() // When await sut.toggleFavorite(plantID: nonExistentPlantID) // Then XCTAssertEqual(mockToggleFavoriteUseCase.executeCallCount, 1) } // MARK: - deletePlant Tests func testDeletePlant_WhenSuccessful_RemovesPlantWithOptimisticUpdate() async { // Given let plantID = UUID() let testPlants = [ createTestPlant(id: plantID, scientificName: "Plant to Delete"), createTestPlant(scientificName: "Plant to Keep") ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() XCTAssertEqual(sut.plants.count, 2) // When await sut.deletePlant(plantID: plantID) // Then XCTAssertEqual(mockDeleteUseCase.executeCallCount, 1) XCTAssertEqual(mockDeleteUseCase.lastPlantID, plantID) XCTAssertEqual(sut.plants.count, 1) XCTAssertFalse(sut.plants.contains(where: { $0.id == plantID })) } func testDeletePlant_WhenFailed_RollsBackAndAddsPlantBack() async { // Given let plantID = UUID() let plantToDelete = createTestPlant(id: plantID, scientificName: "Plant to Delete") mockFetchUseCase.plantsToReturn = [plantToDelete] await sut.loadPlants() mockDeleteUseCase.errorToThrow = DeletePlantError.repositoryDeleteFailed(NSError(domain: "test", code: -1)) // When await sut.deletePlant(plantID: plantID) // Then XCTAssertNotNil(sut.error) // Plant should be restored after failed deletion XCTAssertEqual(sut.plants.count, 1) XCTAssertTrue(sut.plants.contains(where: { $0.id == plantID })) } // MARK: - Search Debouncing Tests func testSearchText_WhenSetToEmptyString_AppliesFilterImmediately() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]) ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() sut.searchText = "Monstera" // Wait for debounce try? await Task.sleep(nanoseconds: 400_000_000) // When sut.searchText = "" // Then - Filter should be applied immediately for empty string XCTAssertEqual(sut.plants.count, 2) } func testSearchText_WhenSet_DebouncesAndFiltersPlants() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]), createTestPlant(scientificName: "Sansevieria", commonNames: ["Snake Plant"]) ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() // When sut.searchText = "Monstera" // Wait for debounce (300ms + some buffer) try? await Task.sleep(nanoseconds: 400_000_000) // Then XCTAssertEqual(sut.plants.count, 1) XCTAssertEqual(sut.plants.first?.scientificName, "Monstera deliciosa") } func testSearchText_WhenTypingQuickly_CancelsIntermediateSearches() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]) ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() // When - Simulate rapid typing sut.searchText = "M" sut.searchText = "Mo" sut.searchText = "Mon" sut.searchText = "Ficus" // Wait for final debounce try? await Task.sleep(nanoseconds: 400_000_000) // Then - Only final search should be applied XCTAssertEqual(sut.plants.count, 1) XCTAssertEqual(sut.plants.first?.scientificName, "Ficus lyrata") } func testSearchText_FiltersOnScientificName() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]) ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() // When sut.searchText = "lyrata" try? await Task.sleep(nanoseconds: 400_000_000) // Then XCTAssertEqual(sut.plants.count, 1) XCTAssertEqual(sut.plants.first?.scientificName, "Ficus lyrata") } func testSearchText_FiltersOnCommonNames() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]) ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() // When sut.searchText = "Fiddle" try? await Task.sleep(nanoseconds: 400_000_000) // Then XCTAssertEqual(sut.plants.count, 1) XCTAssertEqual(sut.plants.first?.scientificName, "Ficus lyrata") } func testSearchText_FiltersOnFamily() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", family: "Araceae"), createTestPlant(scientificName: "Ficus lyrata", family: "Moraceae") ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() // When sut.searchText = "Moraceae" try? await Task.sleep(nanoseconds: 400_000_000) // Then XCTAssertEqual(sut.plants.count, 1) XCTAssertEqual(sut.plants.first?.family, "Moraceae") } func testSearchText_IsCaseInsensitive() async { // Given let testPlants = [ createTestPlant(scientificName: "Monstera deliciosa", commonNames: ["Swiss Cheese Plant"]), createTestPlant(scientificName: "Ficus lyrata", commonNames: ["Fiddle Leaf Fig"]) ] mockFetchUseCase.plantsToReturn = testPlants await sut.loadPlants() // When sut.searchText = "MONSTERA" try? await Task.sleep(nanoseconds: 400_000_000) // Then XCTAssertEqual(sut.plants.count, 1) XCTAssertEqual(sut.plants.first?.scientificName, "Monstera deliciosa") } // MARK: - applyFilter Tests func testApplyFilter_AppliesNewFilterAndReloadsCollection() async { // Given let favoritePlants = [createTestPlant(isFavorite: true)] mockFetchUseCase.plantsToReturn = favoritePlants let newFilter = PlantFilter(isFavorite: true) // When await sut.applyFilter(newFilter) // Then XCTAssertEqual(sut.currentFilter.isFavorite, true) XCTAssertEqual(mockFetchUseCase.lastFilter?.isFavorite, true) XCTAssertEqual(sut.plants.count, 1) } func testApplyFilter_SavesFilterToPreferences() async { // Given let newFilter = PlantFilter(sortBy: .name, sortAscending: true) mockFetchUseCase.plantsToReturn = [] // When await sut.applyFilter(newFilter) // Then XCTAssertEqual(sut.currentFilter.sortBy, .name) XCTAssertEqual(sut.currentFilter.sortAscending, true) } // MARK: - toggleViewMode Tests func testToggleViewMode_SwitchesFromGridToList() { // Given sut.viewMode = .grid // When sut.toggleViewMode() // Then XCTAssertEqual(sut.viewMode, .list) } func testToggleViewMode_SwitchesFromListToGrid() { // Given sut.viewMode = .list // When sut.toggleViewMode() // Then XCTAssertEqual(sut.viewMode, .grid) } func testToggleViewMode_PersistsViewModePreference() { // Given sut.viewMode = .grid // When sut.toggleViewMode() // Then - The viewMode property setter should save to preferences XCTAssertEqual(sut.viewMode, .list) } // MARK: - Computed Properties Tests func testIsEmpty_WhenNoPlantsAndNotLoading_ReturnsTrue() async { // Given mockFetchUseCase.plantsToReturn = [] await sut.loadPlants() // Then XCTAssertTrue(sut.isEmpty) } func testIsEmpty_WhenHasPlants_ReturnsFalse() async { // Given mockFetchUseCase.plantsToReturn = [createTestPlant()] await sut.loadPlants() // Then XCTAssertFalse(sut.isEmpty) } func testHasActiveSearch_WhenSearchTextIsEmpty_ReturnsFalse() { // Given sut.searchText = "" // Then XCTAssertFalse(sut.hasActiveSearch) } func testHasActiveSearch_WhenSearchTextIsNotEmpty_ReturnsTrue() { // Given sut.searchText = "Monstera" // Then XCTAssertTrue(sut.hasActiveSearch) } func testHasActiveFilters_WhenDefaultFilter_ReturnsFalse() { // Given sut.currentFilter = .default // Then XCTAssertFalse(sut.hasActiveFilters) } func testHasActiveFilters_WhenCustomFilter_ReturnsTrue() { // Given sut.currentFilter = PlantFilter(isFavorite: true) // Then XCTAssertTrue(sut.hasActiveFilters) } // MARK: - clearError Tests func testClearError_RemovesErrorState() async { // Given mockFetchUseCase.errorToThrow = FetchCollectionError.repositoryFetchFailed(NSError(domain: "test", code: -1)) await sut.loadPlants() XCTAssertNotNil(sut.error) // When sut.clearError() // Then XCTAssertNil(sut.error) } }