- 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>
448 lines
14 KiB
Swift
448 lines
14 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|