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:
Trey t
2026-01-23 12:18:01 -06:00
parent d3ab29eb84
commit 136dfbae33
187 changed files with 69001 additions and 0 deletions

View 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)
}
}