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,194 @@
//
// MockImageStorage.swift
// PlantGuideTests
//
// Mock implementation of ImageStorageProtocol for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
import UIKit
@testable import PlantGuide
// MARK: - MockImageStorage
/// Mock implementation of ImageStorageProtocol for testing
final actor MockImageStorage: ImageStorageProtocol {
// MARK: - Storage
private var storedImages: [String: UIImage] = [:]
private var plantImages: [UUID: [String]] = [:]
// MARK: - Call Tracking
private(set) var saveCallCount = 0
private(set) var loadCallCount = 0
private(set) var deleteCallCount = 0
private(set) var deleteAllCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnSave = false
var shouldThrowOnLoad = false
var shouldThrowOnDelete = false
var shouldThrowOnDeleteAll = false
var errorToThrow: Error = ImageStorageError.writeFailed(
NSError(domain: "MockError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Mock storage error"])
)
// MARK: - Captured Values
private(set) var lastSavedImage: UIImage?
private(set) var lastSavedPlantID: UUID?
private(set) var lastLoadedPath: String?
private(set) var lastDeletedPath: String?
private(set) var lastDeletedAllPlantID: UUID?
// MARK: - Generated Path Control
var pathToReturn: String?
// MARK: - ImageStorageProtocol
func save(_ image: UIImage, for plantID: UUID) async throws -> String {
saveCallCount += 1
lastSavedImage = image
lastSavedPlantID = plantID
if shouldThrowOnSave {
throw errorToThrow
}
// Generate or use provided path
let path = pathToReturn ?? "\(plantID.uuidString)/\(UUID().uuidString).jpg"
// Store the image
storedImages[path] = image
// Track images per plant
var paths = plantImages[plantID] ?? []
paths.append(path)
plantImages[plantID] = paths
return path
}
func load(path: String) async -> UIImage? {
loadCallCount += 1
lastLoadedPath = path
return storedImages[path]
}
func delete(path: String) async throws {
deleteCallCount += 1
lastDeletedPath = path
if shouldThrowOnDelete {
throw errorToThrow
}
guard storedImages[path] != nil else {
throw ImageStorageError.fileNotFound
}
storedImages.removeValue(forKey: path)
// Remove from plant tracking
for (plantID, var paths) in plantImages {
if let index = paths.firstIndex(of: path) {
paths.remove(at: index)
plantImages[plantID] = paths
break
}
}
}
func deleteAll(for plantID: UUID) async throws {
deleteAllCallCount += 1
lastDeletedAllPlantID = plantID
if shouldThrowOnDeleteAll {
throw errorToThrow
}
// Remove all images for this plant
if let paths = plantImages[plantID] {
for path in paths {
storedImages.removeValue(forKey: path)
}
plantImages.removeValue(forKey: plantID)
}
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
storedImages = [:]
plantImages = [:]
saveCallCount = 0
loadCallCount = 0
deleteCallCount = 0
deleteAllCallCount = 0
shouldThrowOnSave = false
shouldThrowOnLoad = false
shouldThrowOnDelete = false
shouldThrowOnDeleteAll = false
lastSavedImage = nil
lastSavedPlantID = nil
lastLoadedPath = nil
lastDeletedPath = nil
lastDeletedAllPlantID = nil
pathToReturn = nil
}
/// Adds an image directly to storage (bypasses save method)
func addImage(_ image: UIImage, at path: String, for plantID: UUID) {
storedImages[path] = image
var paths = plantImages[plantID] ?? []
paths.append(path)
plantImages[plantID] = paths
}
/// Gets stored image count
var imageCount: Int {
storedImages.count
}
/// Gets image count for a specific plant
func imageCount(for plantID: UUID) -> Int {
plantImages[plantID]?.count ?? 0
}
/// Gets all paths for a plant
func paths(for plantID: UUID) -> [String] {
plantImages[plantID] ?? []
}
/// Checks if an image exists at a path
func imageExists(at path: String) -> Bool {
storedImages[path] != nil
}
}
// MARK: - Test Image Creation Helper
extension MockImageStorage {
/// Creates a simple test image for use in tests
static func createTestImage(
color: UIColor = .red,
size: CGSize = CGSize(width: 100, height: 100)
) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
}