Add PlantGuide iOS app with plant identification and care management
- Implement camera capture and plant identification workflow - Add Core Data persistence for plants, care schedules, and cached API data - Create collection view with grid/list layouts and filtering - Build plant detail views with care information display - Integrate Trefle botanical API for plant care data - Add local image storage for captured plant photos - Implement dependency injection container for testability - Include accessibility support throughout the app Bug fixes in this commit: - Fix Trefle API decoding by removing duplicate CodingKeys - Fix LocalCachedImage to load from correct PlantImages directory - Set dateAdded when saving plants for proper collection sorting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
447
PlantGuideTests/FetchCollectionUseCaseTests.swift
Normal file
447
PlantGuideTests/FetchCollectionUseCaseTests.swift
Normal file
@@ -0,0 +1,447 @@
|
||||
//
|
||||
// FetchCollectionUseCaseTests.swift
|
||||
// PlantGuideTests
|
||||
//
|
||||
// Unit tests for FetchCollectionUseCase - the use case for fetching plants
|
||||
// from the user's collection with filtering and statistics.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PlantGuide
|
||||
|
||||
// MARK: - FetchCollectionUseCaseTests
|
||||
|
||||
final class FetchCollectionUseCaseTests: XCTestCase {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var sut: FetchCollectionUseCase!
|
||||
private var mockPlantRepository: MockPlantCollectionRepository!
|
||||
private var mockCareScheduleRepository: MockCareScheduleRepository!
|
||||
|
||||
// MARK: - Test Lifecycle
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockPlantRepository = MockPlantCollectionRepository()
|
||||
mockCareScheduleRepository = MockCareScheduleRepository()
|
||||
|
||||
sut = FetchCollectionUseCase(
|
||||
plantRepository: mockPlantRepository,
|
||||
careScheduleRepository: mockCareScheduleRepository
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
mockPlantRepository = nil
|
||||
mockCareScheduleRepository = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - execute() Basic Fetch Tests
|
||||
|
||||
func testExecute_WhenCollectionIsEmpty_ReturnsEmptyArray() async throws {
|
||||
// When
|
||||
let result = try await sut.execute()
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(result.isEmpty)
|
||||
XCTAssertEqual(mockPlantRepository.fetchAllCallCount, 1)
|
||||
}
|
||||
|
||||
func testExecute_WhenCollectionHasPlants_ReturnsAllPlants() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mockMonstera(),
|
||||
Plant.mockPothos(),
|
||||
Plant.mockSnakePlant()
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
// When
|
||||
let result = try await sut.execute()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 3)
|
||||
XCTAssertEqual(mockPlantRepository.fetchAllCallCount, 1)
|
||||
}
|
||||
|
||||
func testExecute_WhenFetchingAll_ReturnsSortedByDateDescending() async throws {
|
||||
// Given
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
let plant1 = Plant.mock(
|
||||
id: UUID(),
|
||||
scientificName: "First",
|
||||
dateIdentified: calendar.date(byAdding: .day, value: -2, to: now)!
|
||||
)
|
||||
let plant2 = Plant.mock(
|
||||
id: UUID(),
|
||||
scientificName: "Second",
|
||||
dateIdentified: calendar.date(byAdding: .day, value: -1, to: now)!
|
||||
)
|
||||
let plant3 = Plant.mock(
|
||||
id: UUID(),
|
||||
scientificName: "Third",
|
||||
dateIdentified: now
|
||||
)
|
||||
|
||||
mockPlantRepository.addPlants([plant1, plant2, plant3])
|
||||
|
||||
// When
|
||||
let result = try await sut.execute()
|
||||
|
||||
// Then - Should be sorted by dateIdentified descending
|
||||
XCTAssertEqual(result.count, 3)
|
||||
XCTAssertEqual(result[0].scientificName, "Third")
|
||||
XCTAssertEqual(result[1].scientificName, "Second")
|
||||
XCTAssertEqual(result[2].scientificName, "First")
|
||||
}
|
||||
|
||||
// MARK: - execute(filter:) Filter Tests
|
||||
|
||||
func testExecuteWithFilter_WhenFilteringByFavorites_ReturnsOnlyFavorites() async throws {
|
||||
// Given
|
||||
let favoriteMonster = Plant.mockMonstera(isFavorite: true)
|
||||
let regularPothos = Plant.mockPothos(isFavorite: false)
|
||||
let favoriteSnake = Plant.mockSnakePlant(isFavorite: true)
|
||||
|
||||
mockPlantRepository.addPlants([favoriteMonster, regularPothos, favoriteSnake])
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.isFavorite = true
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 2)
|
||||
XCTAssertTrue(result.allSatisfy { $0.isFavorite })
|
||||
XCTAssertEqual(mockPlantRepository.filterCallCount, 1)
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenFilteringByFamily_ReturnsMatchingFamily() async throws {
|
||||
// Given
|
||||
let araceaePlant1 = Plant.mockMonstera() // Family: Araceae
|
||||
let araceaePlant2 = Plant.mockPothos() // Family: Araceae
|
||||
let asparagaceaePlant = Plant.mockSnakePlant() // Family: Asparagaceae
|
||||
|
||||
mockPlantRepository.addPlants([araceaePlant1, araceaePlant2, asparagaceaePlant])
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.families = Set(["Araceae"])
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 2)
|
||||
XCTAssertTrue(result.allSatisfy { $0.family == "Araceae" })
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenFilteringByIdentificationSource_ReturnsMatchingSource() async throws {
|
||||
// Given
|
||||
let onDevicePlant = Plant.mock(identificationSource: .onDeviceML)
|
||||
let apiPlant = Plant.mock(identificationSource: .plantNetAPI)
|
||||
let manualPlant = Plant.mock(identificationSource: .userManual)
|
||||
|
||||
mockPlantRepository.addPlants([onDevicePlant, apiPlant, manualPlant])
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.identificationSource = .plantNetAPI
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 1)
|
||||
XCTAssertEqual(result.first?.identificationSource, .plantNetAPI)
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenSearchingByQuery_ReturnsMatchingPlants() async throws {
|
||||
// Given
|
||||
let monstera = Plant.mockMonstera()
|
||||
let pothos = Plant.mockPothos()
|
||||
let peaceLily = Plant.mockPeaceLily()
|
||||
|
||||
mockPlantRepository.addPlants([monstera, pothos, peaceLily])
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.searchQuery = "Monstera"
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 1)
|
||||
XCTAssertTrue(result.first?.scientificName.contains("Monstera") ?? false)
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenSortingByName_ReturnsSortedByName() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mock(scientificName: "Zebrina"),
|
||||
Plant.mock(scientificName: "Aloe vera"),
|
||||
Plant.mock(scientificName: "Monstera")
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .name
|
||||
filter.sortAscending = true
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 3)
|
||||
XCTAssertEqual(result[0].scientificName, "Aloe vera")
|
||||
XCTAssertEqual(result[1].scientificName, "Monstera")
|
||||
XCTAssertEqual(result[2].scientificName, "Zebrina")
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenSortingByFamily_ReturnsSortedByFamily() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mock(family: "Moraceae"),
|
||||
Plant.mock(family: "Araceae"),
|
||||
Plant.mock(family: "Asparagaceae")
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .family
|
||||
filter.sortAscending = true
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 3)
|
||||
XCTAssertEqual(result[0].family, "Araceae")
|
||||
XCTAssertEqual(result[1].family, "Asparagaceae")
|
||||
XCTAssertEqual(result[2].family, "Moraceae")
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenCombiningFilters_AppliesAllCriteria() async throws {
|
||||
// Given
|
||||
let favAraceae = Plant.mock(family: "Araceae", isFavorite: true)
|
||||
let notFavAraceae = Plant.mock(family: "Araceae", isFavorite: false)
|
||||
let favMoraceae = Plant.mock(family: "Moraceae", isFavorite: true)
|
||||
|
||||
mockPlantRepository.addPlants([favAraceae, notFavAraceae, favMoraceae])
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.families = Set(["Araceae"])
|
||||
filter.isFavorite = true
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 1)
|
||||
XCTAssertEqual(result.first?.family, "Araceae")
|
||||
XCTAssertTrue(result.first?.isFavorite ?? false)
|
||||
}
|
||||
|
||||
// MARK: - fetchStatistics() Tests
|
||||
|
||||
func testFetchStatistics_WhenCollectionIsEmpty_ReturnsZeroStatistics() async throws {
|
||||
// When
|
||||
let stats = try await sut.fetchStatistics()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(stats.totalPlants, 0)
|
||||
XCTAssertEqual(stats.favoriteCount, 0)
|
||||
XCTAssertEqual(mockPlantRepository.getStatisticsCallCount, 1)
|
||||
}
|
||||
|
||||
func testFetchStatistics_WhenCollectionHasPlants_ReturnsCorrectStatistics() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mockMonstera(isFavorite: true),
|
||||
Plant.mockPothos(isFavorite: false),
|
||||
Plant.mockSnakePlant(isFavorite: true)
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
// When
|
||||
let stats = try await sut.fetchStatistics()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(stats.totalPlants, 3)
|
||||
XCTAssertEqual(stats.favoriteCount, 2)
|
||||
}
|
||||
|
||||
func testFetchStatistics_ReturnsCorrectFamilyDistribution() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mockMonstera(), // Araceae
|
||||
Plant.mockPothos(), // Araceae
|
||||
Plant.mockSnakePlant() // Asparagaceae
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
// When
|
||||
let stats = try await sut.fetchStatistics()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(stats.familyDistribution["Araceae"], 2)
|
||||
XCTAssertEqual(stats.familyDistribution["Asparagaceae"], 1)
|
||||
}
|
||||
|
||||
func testFetchStatistics_ReturnsCorrectIdentificationSourceBreakdown() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mock(identificationSource: .onDeviceML),
|
||||
Plant.mock(identificationSource: .onDeviceML),
|
||||
Plant.mock(identificationSource: .plantNetAPI)
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
// When
|
||||
let stats = try await sut.fetchStatistics()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(stats.identificationSourceBreakdown[.onDeviceML], 2)
|
||||
XCTAssertEqual(stats.identificationSourceBreakdown[.plantNetAPI], 1)
|
||||
}
|
||||
|
||||
// MARK: - Error Handling Tests
|
||||
|
||||
func testExecute_WhenRepositoryFetchFails_ThrowsRepositoryFetchFailed() async {
|
||||
// Given
|
||||
mockPlantRepository.shouldThrowOnFetch = true
|
||||
mockPlantRepository.errorToThrow = NSError(domain: "CoreData", code: 500)
|
||||
|
||||
// When/Then
|
||||
do {
|
||||
_ = try await sut.execute()
|
||||
XCTFail("Expected repositoryFetchFailed error to be thrown")
|
||||
} catch let error as FetchCollectionError {
|
||||
switch error {
|
||||
case .repositoryFetchFailed(let underlyingError):
|
||||
XCTAssertEqual((underlyingError as NSError).domain, "CoreData")
|
||||
default:
|
||||
XCTFail("Expected repositoryFetchFailed error, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Expected FetchCollectionError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenFilterFails_ThrowsRepositoryFetchFailed() async {
|
||||
// Given
|
||||
mockPlantRepository.shouldThrowOnFilter = true
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.isFavorite = true
|
||||
|
||||
// When/Then
|
||||
do {
|
||||
_ = try await sut.execute(filter: filter)
|
||||
XCTFail("Expected error to be thrown")
|
||||
} catch let error as FetchCollectionError {
|
||||
switch error {
|
||||
case .repositoryFetchFailed:
|
||||
break // Expected
|
||||
default:
|
||||
XCTFail("Expected repositoryFetchFailed error, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Expected FetchCollectionError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testFetchStatistics_WhenCalculationFails_ThrowsStatisticsCalculationFailed() async {
|
||||
// Given
|
||||
mockPlantRepository.shouldThrowOnGetStatistics = true
|
||||
mockPlantRepository.errorToThrow = NSError(domain: "Stats", code: 1)
|
||||
|
||||
// When/Then
|
||||
do {
|
||||
_ = try await sut.fetchStatistics()
|
||||
XCTFail("Expected statisticsCalculationFailed error to be thrown")
|
||||
} catch let error as FetchCollectionError {
|
||||
switch error {
|
||||
case .statisticsCalculationFailed:
|
||||
break // Expected
|
||||
default:
|
||||
XCTFail("Expected statisticsCalculationFailed error, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Expected FetchCollectionError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Description Tests
|
||||
|
||||
func testFetchCollectionError_RepositoryFetchFailed_HasCorrectDescription() {
|
||||
// Given
|
||||
let underlyingError = NSError(domain: "Test", code: 123)
|
||||
let error = FetchCollectionError.repositoryFetchFailed(underlyingError)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("load plants") ?? false)
|
||||
}
|
||||
|
||||
func testFetchCollectionError_StatisticsCalculationFailed_HasCorrectDescription() {
|
||||
// Given
|
||||
let underlyingError = NSError(domain: "Test", code: 456)
|
||||
let error = FetchCollectionError.statisticsCalculationFailed(underlyingError)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("statistics") ?? false)
|
||||
}
|
||||
|
||||
func testFetchCollectionError_InvalidFilter_HasCorrectDescription() {
|
||||
// Given
|
||||
let error = FetchCollectionError.invalidFilter("Search query too long")
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("filter") ?? false)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Conformance Tests
|
||||
|
||||
func testFetchCollectionUseCase_ConformsToProtocol() {
|
||||
XCTAssertTrue(sut is FetchCollectionUseCaseProtocol)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
func testExecute_WithLargeCollection_HandlesCorrectly() async throws {
|
||||
// Given - Add 100 plants
|
||||
let plants = (0..<100).map { _ in Plant.mock() }
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
// When
|
||||
let result = try await sut.execute()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result.count, 100)
|
||||
}
|
||||
|
||||
func testExecuteWithFilter_WhenNoMatchesFound_ReturnsEmptyArray() async throws {
|
||||
// Given
|
||||
let plants = [
|
||||
Plant.mockMonstera(isFavorite: false),
|
||||
Plant.mockPothos(isFavorite: false)
|
||||
]
|
||||
mockPlantRepository.addPlants(plants)
|
||||
|
||||
var filter = PlantFilter()
|
||||
filter.isFavorite = true
|
||||
|
||||
// When
|
||||
let result = try await sut.execute(filter: filter)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(result.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user