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,622 @@
//
// 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)
}
}