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,166 @@
//
// MockCareScheduleRepository.swift
// PlantGuideTests
//
// Mock implementation of CareScheduleRepositoryProtocol for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
@testable import PlantGuide
// MARK: - MockCareScheduleRepository
/// Mock implementation of CareScheduleRepositoryProtocol for testing
final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @unchecked Sendable {
// MARK: - Storage
var schedules: [UUID: PlantCareSchedule] = [:]
// MARK: - Call Tracking
var saveCallCount = 0
var fetchForPlantCallCount = 0
var fetchAllCallCount = 0
var fetchAllTasksCallCount = 0
var updateTaskCallCount = 0
var deleteCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnSave = false
var shouldThrowOnFetch = false
var shouldThrowOnFetchAll = false
var shouldThrowOnFetchAllTasks = false
var shouldThrowOnUpdateTask = false
var shouldThrowOnDelete = false
var errorToThrow: Error = NSError(
domain: "MockError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Mock care schedule repository error"]
)
// MARK: - Captured Values
var lastSavedSchedule: PlantCareSchedule?
var lastFetchedPlantID: UUID?
var lastUpdatedTask: CareTask?
var lastDeletedPlantID: UUID?
// MARK: - CareScheduleRepositoryProtocol
func save(_ schedule: PlantCareSchedule) async throws {
saveCallCount += 1
lastSavedSchedule = schedule
if shouldThrowOnSave {
throw errorToThrow
}
schedules[schedule.plantID] = schedule
}
func fetch(for plantID: UUID) async throws -> PlantCareSchedule? {
fetchForPlantCallCount += 1
lastFetchedPlantID = plantID
if shouldThrowOnFetch {
throw errorToThrow
}
return schedules[plantID]
}
func fetchAll() async throws -> [PlantCareSchedule] {
fetchAllCallCount += 1
if shouldThrowOnFetchAll {
throw errorToThrow
}
return Array(schedules.values)
}
func fetchAllTasks() async throws -> [CareTask] {
fetchAllTasksCallCount += 1
if shouldThrowOnFetchAllTasks {
throw errorToThrow
}
return schedules.values.flatMap { $0.tasks }
}
func updateTask(_ task: CareTask) async throws {
updateTaskCallCount += 1
lastUpdatedTask = task
if shouldThrowOnUpdateTask {
throw errorToThrow
}
// Find and update the task in the appropriate schedule
for (plantID, var schedule) in schedules {
if let index = schedule.tasks.firstIndex(where: { $0.id == task.id }) {
schedule.tasks[index] = task
schedules[plantID] = schedule
break
}
}
}
func delete(for plantID: UUID) async throws {
deleteCallCount += 1
lastDeletedPlantID = plantID
if shouldThrowOnDelete {
throw errorToThrow
}
schedules.removeValue(forKey: plantID)
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
schedules = [:]
saveCallCount = 0
fetchForPlantCallCount = 0
fetchAllCallCount = 0
fetchAllTasksCallCount = 0
updateTaskCallCount = 0
deleteCallCount = 0
shouldThrowOnSave = false
shouldThrowOnFetch = false
shouldThrowOnFetchAll = false
shouldThrowOnFetchAllTasks = false
shouldThrowOnUpdateTask = false
shouldThrowOnDelete = false
lastSavedSchedule = nil
lastFetchedPlantID = nil
lastUpdatedTask = nil
lastDeletedPlantID = nil
}
/// Adds a schedule directly to storage (bypasses save method)
func addSchedule(_ schedule: PlantCareSchedule) {
schedules[schedule.plantID] = schedule
}
/// Adds multiple schedules directly to storage
func addSchedules(_ schedulesToAdd: [PlantCareSchedule]) {
for schedule in schedulesToAdd {
schedules[schedule.plantID] = schedule
}
}
/// Gets all tasks for a specific plant
func getTasks(for plantID: UUID) -> [CareTask] {
schedules[plantID]?.tasks ?? []
}
/// Gets overdue tasks across all schedules
func getOverdueTasks() -> [CareTask] {
schedules.values.flatMap { $0.overdueTasks }
}
/// Gets pending tasks across all schedules
func getPendingTasks() -> [CareTask] {
schedules.values.flatMap { $0.pendingTasks }
}
}

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

View File

@@ -0,0 +1,291 @@
//
// MockNetworkService.swift
// PlantGuideTests
//
// Mock implementations for network-related services for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
import Network
@testable import PlantGuide
// MARK: - MockNetworkMonitor
/// Mock implementation of NetworkMonitor for testing
/// Note: This creates a testable version that doesn't actually monitor network state
@Observable
final class MockNetworkMonitor: @unchecked Sendable {
// MARK: - Properties
/// Current network connectivity status (configurable for tests)
var isConnected: Bool = true
/// Current connection type (configurable for tests)
var connectionType: ConnectionType = .wifi
// MARK: - Call Tracking
private(set) var startMonitoringCallCount = 0
private(set) var stopMonitoringCallCount = 0
// MARK: - Initialization
init(isConnected: Bool = true, connectionType: ConnectionType = .wifi) {
self.isConnected = isConnected
self.connectionType = connectionType
}
// MARK: - Public Methods
func startMonitoring() {
startMonitoringCallCount += 1
}
func stopMonitoring() {
stopMonitoringCallCount += 1
}
// MARK: - Test Helper Methods
/// Simulates a connection state change
func simulateConnectionChange(isConnected: Bool, connectionType: ConnectionType = .wifi) {
self.isConnected = isConnected
self.connectionType = connectionType
}
/// Simulates going offline
func simulateDisconnect() {
isConnected = false
connectionType = .unknown
}
/// Simulates connecting to WiFi
func simulateWiFiConnection() {
isConnected = true
connectionType = .wifi
}
/// Simulates connecting to cellular
func simulateCellularConnection() {
isConnected = true
connectionType = .cellular
}
/// Resets all state for clean test setup
func reset() {
isConnected = true
connectionType = .wifi
startMonitoringCallCount = 0
stopMonitoringCallCount = 0
}
}
// MARK: - MockURLSession
/// Mock implementation of URLSession for testing network requests
final class MockURLSession: @unchecked Sendable {
// MARK: - Call Tracking
private(set) var dataCallCount = 0
private(set) var uploadCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnData = false
var shouldThrowOnUpload = false
var errorToThrow: Error = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorNotConnectedToInternet,
userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]
)
// MARK: - Return Value Configuration
var dataToReturn: Data = Data()
var responseToReturn: URLResponse?
var statusCodeToReturn: Int = 200
// MARK: - Captured Values
private(set) var lastRequestedURL: URL?
private(set) var lastUploadData: Data?
// MARK: - Mock Methods
func data(from url: URL) async throws -> (Data, URLResponse) {
dataCallCount += 1
lastRequestedURL = url
if shouldThrowOnData {
throw errorToThrow
}
let response = responseToReturn ?? HTTPURLResponse(
url: url,
statusCode: statusCodeToReturn,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
return (dataToReturn, response)
}
func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) {
uploadCallCount += 1
lastRequestedURL = request.url
lastUploadData = bodyData
if shouldThrowOnUpload {
throw errorToThrow
}
let response = responseToReturn ?? HTTPURLResponse(
url: request.url!,
statusCode: statusCodeToReturn,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
return (dataToReturn, response)
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
dataCallCount = 0
uploadCallCount = 0
shouldThrowOnData = false
shouldThrowOnUpload = false
dataToReturn = Data()
responseToReturn = nil
statusCodeToReturn = 200
lastRequestedURL = nil
lastUploadData = nil
}
/// Configures the mock to return JSON data
func configureJSONResponse<T: Encodable>(_ value: T, statusCode: Int = 200) throws {
let encoder = JSONEncoder()
dataToReturn = try encoder.encode(value)
statusCodeToReturn = statusCode
}
/// Configures the mock to return an error response
func configureErrorResponse(statusCode: Int, message: String = "Error") {
statusCodeToReturn = statusCode
dataToReturn = Data(message.utf8)
}
/// Configures the mock to simulate a network error
func configureNetworkError(_ error: URLError.Code = .notConnectedToInternet) {
shouldThrowOnData = true
shouldThrowOnUpload = true
errorToThrow = URLError(error)
}
/// Configures the mock to simulate a timeout
func configureTimeout() {
shouldThrowOnData = true
shouldThrowOnUpload = true
errorToThrow = URLError(.timedOut)
}
}
// MARK: - MockPlantNetAPIService
/// Mock implementation of PlantNet API service for testing
final class MockPlantNetAPIService: @unchecked Sendable {
// MARK: - PlantNet Response Types
struct PlantNetResponse: Codable {
let results: [PlantNetResult]
}
struct PlantNetResult: Codable {
let score: Double
let species: PlantNetSpecies
}
struct PlantNetSpecies: Codable {
let scientificNameWithoutAuthor: String
let commonNames: [String]
}
// MARK: - Call Tracking
private(set) var identifyCallCount = 0
// MARK: - Error Configuration
var shouldThrow = false
var errorToThrow: Error = NSError(
domain: "PlantNetError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Mock PlantNet API error"]
)
// MARK: - Return Value Configuration
var resultsToReturn: [PlantNetResult] = []
// MARK: - Captured Values
private(set) var lastImageData: Data?
// MARK: - Mock Methods
func identify(imageData: Data) async throws -> PlantNetResponse {
identifyCallCount += 1
lastImageData = imageData
if shouldThrow {
throw errorToThrow
}
return PlantNetResponse(results: resultsToReturn)
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
identifyCallCount = 0
shouldThrow = false
resultsToReturn = []
lastImageData = nil
}
/// Configures mock to return successful plant identification
func configureSuccessfulIdentification() {
resultsToReturn = [
PlantNetResult(
score: 0.95,
species: PlantNetSpecies(
scientificNameWithoutAuthor: "Monstera deliciosa",
commonNames: ["Swiss Cheese Plant", "Monstera"]
)
),
PlantNetResult(
score: 0.72,
species: PlantNetSpecies(
scientificNameWithoutAuthor: "Philodendron bipinnatifidum",
commonNames: ["Split Leaf Philodendron"]
)
)
]
}
/// Configures mock to return no results
func configureNoResults() {
resultsToReturn = []
}
}

View File

@@ -0,0 +1,171 @@
//
// MockNotificationService.swift
// PlantGuideTests
//
// Mock implementation of NotificationServiceProtocol for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
import UserNotifications
@testable import PlantGuide
// MARK: - MockNotificationService
/// Mock implementation of NotificationServiceProtocol for testing
final actor MockNotificationService: NotificationServiceProtocol {
// MARK: - Storage
private var scheduledReminders: [UUID: (task: CareTask, plantName: String, plantID: UUID)] = [:]
private var pendingNotifications: [UNNotificationRequest] = []
// MARK: - Call Tracking
private(set) var requestAuthorizationCallCount = 0
private(set) var scheduleReminderCallCount = 0
private(set) var cancelReminderCallCount = 0
private(set) var cancelAllRemindersCallCount = 0
private(set) var updateBadgeCountCallCount = 0
private(set) var getPendingNotificationsCallCount = 0
private(set) var removeAllDeliveredNotificationsCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnRequestAuthorization = false
var shouldThrowOnScheduleReminder = false
var errorToThrow: Error = NotificationError.schedulingFailed(
NSError(domain: "MockError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Mock notification error"])
)
// MARK: - Return Value Configuration
var authorizationGranted = true
// MARK: - Captured Values
private(set) var lastScheduledTask: CareTask?
private(set) var lastScheduledPlantName: String?
private(set) var lastScheduledPlantID: UUID?
private(set) var lastCancelledTaskID: UUID?
private(set) var lastCancelledAllPlantID: UUID?
private(set) var lastBadgeCount: Int?
// MARK: - NotificationServiceProtocol
func requestAuthorization() async throws -> Bool {
requestAuthorizationCallCount += 1
if shouldThrowOnRequestAuthorization {
throw errorToThrow
}
if !authorizationGranted {
throw NotificationError.permissionDenied
}
return authorizationGranted
}
func scheduleReminder(for task: CareTask, plantName: String, plantID: UUID) async throws {
scheduleReminderCallCount += 1
lastScheduledTask = task
lastScheduledPlantName = plantName
lastScheduledPlantID = plantID
if shouldThrowOnScheduleReminder {
throw errorToThrow
}
// Validate that the scheduled date is in the future
guard task.scheduledDate > Date() else {
throw NotificationError.invalidTriggerDate
}
scheduledReminders[task.id] = (task, plantName, plantID)
}
func cancelReminder(for taskID: UUID) async {
cancelReminderCallCount += 1
lastCancelledTaskID = taskID
scheduledReminders.removeValue(forKey: taskID)
}
func cancelAllReminders(for plantID: UUID) async {
cancelAllRemindersCallCount += 1
lastCancelledAllPlantID = plantID
// Remove all reminders for this plant
let keysToRemove = scheduledReminders.filter { $0.value.plantID == plantID }.map { $0.key }
for key in keysToRemove {
scheduledReminders.removeValue(forKey: key)
}
}
func updateBadgeCount(_ count: Int) async {
updateBadgeCountCallCount += 1
lastBadgeCount = count
}
func getPendingNotifications() async -> [UNNotificationRequest] {
getPendingNotificationsCallCount += 1
return pendingNotifications
}
func removeAllDeliveredNotifications() async {
removeAllDeliveredNotificationsCallCount += 1
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
scheduledReminders = [:]
pendingNotifications = []
requestAuthorizationCallCount = 0
scheduleReminderCallCount = 0
cancelReminderCallCount = 0
cancelAllRemindersCallCount = 0
updateBadgeCountCallCount = 0
getPendingNotificationsCallCount = 0
removeAllDeliveredNotificationsCallCount = 0
shouldThrowOnRequestAuthorization = false
shouldThrowOnScheduleReminder = false
authorizationGranted = true
lastScheduledTask = nil
lastScheduledPlantName = nil
lastScheduledPlantID = nil
lastCancelledTaskID = nil
lastCancelledAllPlantID = nil
lastBadgeCount = nil
}
/// Gets the count of scheduled reminders
var scheduledReminderCount: Int {
scheduledReminders.count
}
/// Gets scheduled reminders for a specific plant
func reminders(for plantID: UUID) -> [CareTask] {
scheduledReminders.values
.filter { $0.plantID == plantID }
.map { $0.task }
}
/// Checks if a reminder is scheduled for a task
func hasReminder(for taskID: UUID) -> Bool {
scheduledReminders[taskID] != nil
}
/// Gets all scheduled task IDs
var scheduledTaskIDs: [UUID] {
Array(scheduledReminders.keys)
}
/// Adds a pending notification for testing getPendingNotifications
func addPendingNotification(_ request: UNNotificationRequest) {
pendingNotifications.append(request)
}
}

View File

@@ -0,0 +1,245 @@
//
// MockPlantClassificationService.swift
// PlantGuideTests
//
// Mock implementations for ML-related services for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
import CoreGraphics
import UIKit
@testable import PlantGuide
// MARK: - MockPlantClassificationService
/// Mock implementation of PlantClassificationServiceProtocol for testing
final actor MockPlantClassificationService: PlantClassificationServiceProtocol {
// MARK: - Call Tracking
private(set) var classifyCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnClassify = false
var errorToThrow: Error = PlantClassificationError.modelLoadFailed
// MARK: - Return Value Configuration
var predictionsToReturn: [PlantPrediction] = []
// MARK: - Captured Values
private(set) var lastClassifiedImage: CGImage?
// MARK: - PlantClassificationServiceProtocol
func classify(image: CGImage) async throws -> [PlantPrediction] {
classifyCallCount += 1
lastClassifiedImage = image
if shouldThrowOnClassify {
throw errorToThrow
}
// Return configured predictions or empty array
return predictionsToReturn
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
classifyCallCount = 0
shouldThrowOnClassify = false
errorToThrow = PlantClassificationError.modelLoadFailed
predictionsToReturn = []
lastClassifiedImage = nil
}
/// Configures the mock to return predictions for common test plants
func configureMockPredictions(_ predictions: [PlantPrediction]) {
predictionsToReturn = predictions
}
/// Creates a default set of mock predictions
func configureDefaultPredictions() {
predictionsToReturn = [
PlantPrediction(
speciesIndex: 0,
confidence: 0.92,
scientificName: "Monstera deliciosa",
commonNames: ["Swiss Cheese Plant", "Monstera"]
),
PlantPrediction(
speciesIndex: 1,
confidence: 0.75,
scientificName: "Philodendron bipinnatifidum",
commonNames: ["Split Leaf Philodendron"]
),
PlantPrediction(
speciesIndex: 2,
confidence: 0.45,
scientificName: "Epipremnum aureum",
commonNames: ["Pothos", "Devil's Ivy"]
)
]
}
/// Configures low confidence predictions for testing fallback behavior
func configureLowConfidencePredictions() {
predictionsToReturn = [
PlantPrediction(
speciesIndex: 0,
confidence: 0.35,
scientificName: "Unknown plant",
commonNames: ["Unidentified"]
)
]
}
}
// MARK: - MockImagePreprocessor
/// Mock implementation of ImagePreprocessorProtocol for testing
struct MockImagePreprocessor: ImagePreprocessorProtocol, Sendable {
// MARK: - Configuration
var shouldThrow = false
var errorToThrow: Error = ImagePreprocessorError.cgImageCreationFailed
// MARK: - Return Value Configuration
var imageToReturn: CGImage?
// MARK: - ImagePreprocessorProtocol
func preprocess(_ image: UIImage) async throws -> CGImage {
if shouldThrow {
throw errorToThrow
}
// Return configured image or create one from the input
if let configuredImage = imageToReturn {
return configuredImage
}
guard let cgImage = image.cgImage else {
throw ImagePreprocessorError.cgImageCreationFailed
}
return cgImage
}
}
// MARK: - MockIdentifyPlantUseCase
/// Mock implementation of IdentifyPlantUseCaseProtocol for testing
struct MockIdentifyPlantUseCase: IdentifyPlantUseCaseProtocol, Sendable {
// MARK: - Configuration
var shouldThrow = false
var errorToThrow: Error = IdentifyPlantOnDeviceUseCaseError.noMatchesFound
// MARK: - Return Value Configuration
var predictionsToReturn: [ViewPlantPrediction] = []
// MARK: - IdentifyPlantUseCaseProtocol
func execute(image: UIImage) async throws -> [ViewPlantPrediction] {
if shouldThrow {
throw errorToThrow
}
return predictionsToReturn
}
// MARK: - Factory Methods
/// Creates a mock that returns high-confidence predictions
static func withHighConfidencePredictions() -> MockIdentifyPlantUseCase {
var mock = MockIdentifyPlantUseCase()
mock.predictionsToReturn = [
ViewPlantPrediction(
id: UUID(),
speciesName: "Monstera deliciosa",
commonName: "Swiss Cheese Plant",
confidence: 0.95
)
]
return mock
}
/// Creates a mock that returns low-confidence predictions
static func withLowConfidencePredictions() -> MockIdentifyPlantUseCase {
var mock = MockIdentifyPlantUseCase()
mock.predictionsToReturn = [
ViewPlantPrediction(
id: UUID(),
speciesName: "Unknown",
commonName: nil,
confidence: 0.35
)
]
return mock
}
/// Creates a mock that throws an error
static func withError(_ error: Error = IdentifyPlantOnDeviceUseCaseError.noMatchesFound) -> MockIdentifyPlantUseCase {
var mock = MockIdentifyPlantUseCase()
mock.shouldThrow = true
mock.errorToThrow = error
return mock
}
}
// MARK: - MockIdentifyPlantOnlineUseCase
/// Mock implementation of IdentifyPlantOnlineUseCaseProtocol for testing
struct MockIdentifyPlantOnlineUseCase: IdentifyPlantOnlineUseCaseProtocol, Sendable {
// MARK: - Configuration
var shouldThrow = false
var errorToThrow: Error = IdentifyPlantOnlineUseCaseError.noMatchesFound
// MARK: - Return Value Configuration
var predictionsToReturn: [ViewPlantPrediction] = []
// MARK: - IdentifyPlantOnlineUseCaseProtocol
func execute(image: UIImage) async throws -> [ViewPlantPrediction] {
if shouldThrow {
throw errorToThrow
}
return predictionsToReturn
}
// MARK: - Factory Methods
/// Creates a mock that returns API predictions
static func withPredictions() -> MockIdentifyPlantOnlineUseCase {
var mock = MockIdentifyPlantOnlineUseCase()
mock.predictionsToReturn = [
ViewPlantPrediction(
id: UUID(),
speciesName: "Monstera deliciosa",
commonName: "Swiss Cheese Plant",
confidence: 0.98
)
]
return mock
}
/// Creates a mock that throws an error
static func withError(_ error: Error = IdentifyPlantOnlineUseCaseError.noMatchesFound) -> MockIdentifyPlantOnlineUseCase {
var mock = MockIdentifyPlantOnlineUseCase()
mock.shouldThrow = true
mock.errorToThrow = error
return mock
}
}

View File

@@ -0,0 +1,275 @@
//
// MockPlantCollectionRepository.swift
// PlantGuideTests
//
// Mock implementation of PlantCollectionRepositoryProtocol for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
@testable import PlantGuide
// MARK: - MockPlantCollectionRepository
/// Mock implementation of PlantCollectionRepositoryProtocol for testing
final class MockPlantCollectionRepository: PlantCollectionRepositoryProtocol, @unchecked Sendable {
// MARK: - Storage
var plants: [UUID: Plant] = [:]
// MARK: - Call Tracking
var saveCallCount = 0
var fetchByIdCallCount = 0
var fetchAllCallCount = 0
var deleteCallCount = 0
var existsCallCount = 0
var updatePlantCallCount = 0
var searchCallCount = 0
var filterCallCount = 0
var getFavoritesCallCount = 0
var setFavoriteCallCount = 0
var getStatisticsCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnSave = false
var shouldThrowOnFetch = false
var shouldThrowOnDelete = false
var shouldThrowOnExists = false
var shouldThrowOnUpdate = false
var shouldThrowOnSearch = false
var shouldThrowOnFilter = false
var shouldThrowOnGetFavorites = false
var shouldThrowOnSetFavorite = false
var shouldThrowOnGetStatistics = false
var errorToThrow: Error = NSError(
domain: "MockError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Mock repository error"]
)
// MARK: - Captured Values
var lastSavedPlant: Plant?
var lastDeletedPlantID: UUID?
var lastUpdatedPlant: Plant?
var lastSearchQuery: String?
var lastFilter: PlantFilter?
var lastSetFavoritePlantID: UUID?
var lastSetFavoriteValue: Bool?
// MARK: - Statistics Configuration
var statisticsToReturn: CollectionStatistics?
// MARK: - PlantRepositoryProtocol
func save(_ plant: Plant) async throws {
saveCallCount += 1
lastSavedPlant = plant
if shouldThrowOnSave {
throw errorToThrow
}
plants[plant.id] = plant
}
func fetch(id: UUID) async throws -> Plant? {
fetchByIdCallCount += 1
if shouldThrowOnFetch {
throw errorToThrow
}
return plants[id]
}
func fetchAll() async throws -> [Plant] {
fetchAllCallCount += 1
if shouldThrowOnFetch {
throw errorToThrow
}
return Array(plants.values)
}
func delete(id: UUID) async throws {
deleteCallCount += 1
lastDeletedPlantID = id
if shouldThrowOnDelete {
throw errorToThrow
}
plants.removeValue(forKey: id)
}
// MARK: - PlantCollectionRepositoryProtocol Extensions
func exists(id: UUID) async throws -> Bool {
existsCallCount += 1
if shouldThrowOnExists {
throw errorToThrow
}
return plants[id] != nil
}
func updatePlant(_ plant: Plant) async throws {
updatePlantCallCount += 1
lastUpdatedPlant = plant
if shouldThrowOnUpdate {
throw errorToThrow
}
plants[plant.id] = plant
}
func searchPlants(query: String) async throws -> [Plant] {
searchCallCount += 1
lastSearchQuery = query
if shouldThrowOnSearch {
throw errorToThrow
}
let lowercaseQuery = query.lowercased()
return plants.values.filter { plant in
plant.scientificName.lowercased().contains(lowercaseQuery) ||
plant.commonNames.contains { $0.lowercased().contains(lowercaseQuery) } ||
(plant.notes?.lowercased().contains(lowercaseQuery) ?? false)
}
}
func filterPlants(by filter: PlantFilter) async throws -> [Plant] {
filterCallCount += 1
lastFilter = filter
if shouldThrowOnFilter {
throw errorToThrow
}
var result = Array(plants.values)
// Apply search query
if let query = filter.searchQuery, !query.isEmpty {
let lowercaseQuery = query.lowercased()
result = result.filter { plant in
plant.scientificName.lowercased().contains(lowercaseQuery) ||
plant.commonNames.contains { $0.lowercased().contains(lowercaseQuery) }
}
}
// Filter by favorites
if let isFavorite = filter.isFavorite {
result = result.filter { $0.isFavorite == isFavorite }
}
// Filter by families
if let families = filter.families, !families.isEmpty {
result = result.filter { families.contains($0.family) }
}
// Filter by identification source
if let source = filter.identificationSource {
result = result.filter { $0.identificationSource == source }
}
return result
}
func getFavorites() async throws -> [Plant] {
getFavoritesCallCount += 1
if shouldThrowOnGetFavorites {
throw errorToThrow
}
return plants.values.filter { $0.isFavorite }
.sorted { $0.dateIdentified > $1.dateIdentified }
}
func setFavorite(plantID: UUID, isFavorite: Bool) async throws {
setFavoriteCallCount += 1
lastSetFavoritePlantID = plantID
lastSetFavoriteValue = isFavorite
if shouldThrowOnSetFavorite {
throw errorToThrow
}
if var plant = plants[plantID] {
plant.isFavorite = isFavorite
plants[plantID] = plant
}
}
func getCollectionStatistics() async throws -> CollectionStatistics {
getStatisticsCallCount += 1
if shouldThrowOnGetStatistics {
throw errorToThrow
}
if let statistics = statisticsToReturn {
return statistics
}
// Calculate statistics from current plants
var familyDistribution: [String: Int] = [:]
var sourceBreakdown: [IdentificationSource: Int] = [:]
for plant in plants.values {
familyDistribution[plant.family, default: 0] += 1
sourceBreakdown[plant.identificationSource, default: 0] += 1
}
return CollectionStatistics(
totalPlants: plants.count,
favoriteCount: plants.values.filter { $0.isFavorite }.count,
familyDistribution: familyDistribution,
identificationSourceBreakdown: sourceBreakdown,
plantsAddedThisMonth: 0,
upcomingTasksCount: 0,
overdueTasksCount: 0
)
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
plants = [:]
saveCallCount = 0
fetchByIdCallCount = 0
fetchAllCallCount = 0
deleteCallCount = 0
existsCallCount = 0
updatePlantCallCount = 0
searchCallCount = 0
filterCallCount = 0
getFavoritesCallCount = 0
setFavoriteCallCount = 0
getStatisticsCallCount = 0
shouldThrowOnSave = false
shouldThrowOnFetch = false
shouldThrowOnDelete = false
shouldThrowOnExists = false
shouldThrowOnUpdate = false
shouldThrowOnSearch = false
shouldThrowOnFilter = false
shouldThrowOnGetFavorites = false
shouldThrowOnSetFavorite = false
shouldThrowOnGetStatistics = false
lastSavedPlant = nil
lastDeletedPlantID = nil
lastUpdatedPlant = nil
lastSearchQuery = nil
lastFilter = nil
lastSetFavoritePlantID = nil
lastSetFavoriteValue = nil
statisticsToReturn = nil
}
/// Adds a plant directly to storage (bypasses save method)
func addPlant(_ plant: Plant) {
plants[plant.id] = plant
}
/// Adds multiple plants directly to storage
func addPlants(_ plantsToAdd: [Plant]) {
for plant in plantsToAdd {
plants[plant.id] = plant
}
}
}