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:
166
PlantGuideTests/Mocks/MockCareScheduleRepository.swift
Normal file
166
PlantGuideTests/Mocks/MockCareScheduleRepository.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
194
PlantGuideTests/Mocks/MockImageStorage.swift
Normal file
194
PlantGuideTests/Mocks/MockImageStorage.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
291
PlantGuideTests/Mocks/MockNetworkService.swift
Normal file
291
PlantGuideTests/Mocks/MockNetworkService.swift
Normal 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 = []
|
||||
}
|
||||
}
|
||||
171
PlantGuideTests/Mocks/MockNotificationService.swift
Normal file
171
PlantGuideTests/Mocks/MockNotificationService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
245
PlantGuideTests/Mocks/MockPlantClassificationService.swift
Normal file
245
PlantGuideTests/Mocks/MockPlantClassificationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
275
PlantGuideTests/Mocks/MockPlantCollectionRepository.swift
Normal file
275
PlantGuideTests/Mocks/MockPlantCollectionRepository.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user