- 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>
1009 lines
33 KiB
Swift
1009 lines
33 KiB
Swift
//
|
|
// CoreDataCareScheduleStorageTests.swift
|
|
// PlantGuideTests
|
|
//
|
|
// Unit tests for CoreDataCareScheduleStorage - the Core Data implementation
|
|
// of care schedule persistence.
|
|
//
|
|
|
|
import XCTest
|
|
import CoreData
|
|
@testable import PlantGuide
|
|
|
|
// MARK: - Mock Core Data Stack
|
|
|
|
/// Mock implementation of CoreDataStackProtocol for testing
|
|
final class MockCoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
|
|
|
|
private let persistentContainer: NSPersistentContainer
|
|
|
|
init() {
|
|
// Create an in-memory persistent container for testing
|
|
let modelURL = Bundle.main.url(forResource: "PlantGuideModel", withExtension: "momd")
|
|
let managedObjectModel: NSManagedObjectModel
|
|
|
|
if let modelURL = modelURL {
|
|
managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)!
|
|
} else {
|
|
// Create a minimal model programmatically for testing if model file not found
|
|
managedObjectModel = MockCoreDataStack.createTestModel()
|
|
}
|
|
|
|
persistentContainer = NSPersistentContainer(name: "PlantGuideModel", managedObjectModel: managedObjectModel)
|
|
|
|
let description = NSPersistentStoreDescription()
|
|
description.type = NSInMemoryStoreType
|
|
description.shouldAddStoreAsynchronously = false
|
|
persistentContainer.persistentStoreDescriptions = [description]
|
|
|
|
persistentContainer.loadPersistentStores { _, error in
|
|
if let error = error {
|
|
fatalError("Failed to load in-memory store: \(error)")
|
|
}
|
|
}
|
|
|
|
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
|
|
}
|
|
|
|
func viewContext() -> NSManagedObjectContext {
|
|
return persistentContainer.viewContext
|
|
}
|
|
|
|
func newBackgroundContext() -> NSManagedObjectContext {
|
|
let context = persistentContainer.newBackgroundContext()
|
|
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
context.automaticallyMergesChangesFromParent = true
|
|
return context
|
|
}
|
|
|
|
func performBackgroundTask<T: Sendable>(_ block: @escaping @Sendable (NSManagedObjectContext) throws -> T) async throws -> T {
|
|
let context = newBackgroundContext()
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
context.perform {
|
|
do {
|
|
let result = try block(context)
|
|
|
|
if context.hasChanges {
|
|
try context.save()
|
|
}
|
|
|
|
continuation.resume(returning: result)
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func save(context: NSManagedObjectContext) throws {
|
|
guard context.hasChanges else { return }
|
|
try context.save()
|
|
}
|
|
|
|
func reset() throws {
|
|
let context = viewContext()
|
|
let entityNames = persistentContainer.managedObjectModel.entities.compactMap { $0.name }
|
|
|
|
for entityName in entityNames {
|
|
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
|
|
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
|
try context.execute(deleteRequest)
|
|
}
|
|
|
|
try save(context: context)
|
|
}
|
|
|
|
/// Creates a minimal test model programmatically
|
|
private static func createTestModel() -> NSManagedObjectModel {
|
|
let model = NSManagedObjectModel()
|
|
|
|
// CareScheduleMO Entity
|
|
let scheduleEntity = NSEntityDescription()
|
|
scheduleEntity.name = "CareScheduleMO"
|
|
scheduleEntity.managedObjectClassName = NSStringFromClass(CareScheduleMO.self)
|
|
|
|
let scheduleIdAttr = NSAttributeDescription()
|
|
scheduleIdAttr.name = "id"
|
|
scheduleIdAttr.attributeType = .UUIDAttributeType
|
|
|
|
let plantIdAttr = NSAttributeDescription()
|
|
plantIdAttr.name = "plantID"
|
|
plantIdAttr.attributeType = .UUIDAttributeType
|
|
|
|
let lightReqAttr = NSAttributeDescription()
|
|
lightReqAttr.name = "lightRequirement"
|
|
lightReqAttr.attributeType = .stringAttributeType
|
|
|
|
let wateringAttr = NSAttributeDescription()
|
|
wateringAttr.name = "wateringSchedule"
|
|
wateringAttr.attributeType = .stringAttributeType
|
|
|
|
let tempMinAttr = NSAttributeDescription()
|
|
tempMinAttr.name = "temperatureMin"
|
|
tempMinAttr.attributeType = .integer32AttributeType
|
|
|
|
let tempMaxAttr = NSAttributeDescription()
|
|
tempMaxAttr.name = "temperatureMax"
|
|
tempMaxAttr.attributeType = .integer32AttributeType
|
|
|
|
let fertilizerAttr = NSAttributeDescription()
|
|
fertilizerAttr.name = "fertilizerSchedule"
|
|
fertilizerAttr.attributeType = .stringAttributeType
|
|
|
|
scheduleEntity.properties = [
|
|
scheduleIdAttr, plantIdAttr, lightReqAttr, wateringAttr,
|
|
tempMinAttr, tempMaxAttr, fertilizerAttr
|
|
]
|
|
|
|
// CareTaskMO Entity
|
|
let taskEntity = NSEntityDescription()
|
|
taskEntity.name = "CareTaskMO"
|
|
taskEntity.managedObjectClassName = NSStringFromClass(CareTaskMO.self)
|
|
|
|
let taskIdAttr = NSAttributeDescription()
|
|
taskIdAttr.name = "id"
|
|
taskIdAttr.attributeType = .UUIDAttributeType
|
|
|
|
let taskPlantIdAttr = NSAttributeDescription()
|
|
taskPlantIdAttr.name = "plantID"
|
|
taskPlantIdAttr.attributeType = .UUIDAttributeType
|
|
|
|
let typeAttr = NSAttributeDescription()
|
|
typeAttr.name = "type"
|
|
typeAttr.attributeType = .stringAttributeType
|
|
|
|
let scheduledDateAttr = NSAttributeDescription()
|
|
scheduledDateAttr.name = "scheduledDate"
|
|
scheduledDateAttr.attributeType = .dateAttributeType
|
|
|
|
let completedDateAttr = NSAttributeDescription()
|
|
completedDateAttr.name = "completedDate"
|
|
completedDateAttr.attributeType = .dateAttributeType
|
|
completedDateAttr.isOptional = true
|
|
|
|
let notesAttr = NSAttributeDescription()
|
|
notesAttr.name = "notes"
|
|
notesAttr.attributeType = .stringAttributeType
|
|
|
|
taskEntity.properties = [
|
|
taskIdAttr, taskPlantIdAttr, typeAttr, scheduledDateAttr, completedDateAttr, notesAttr
|
|
]
|
|
|
|
// Relationships
|
|
let tasksRelation = NSRelationshipDescription()
|
|
tasksRelation.name = "tasks"
|
|
tasksRelation.destinationEntity = taskEntity
|
|
tasksRelation.isOptional = true
|
|
tasksRelation.deleteRule = .cascadeDeleteRule
|
|
|
|
let scheduleRelation = NSRelationshipDescription()
|
|
scheduleRelation.name = "careSchedule"
|
|
scheduleRelation.destinationEntity = scheduleEntity
|
|
scheduleRelation.maxCount = 1
|
|
scheduleRelation.isOptional = true
|
|
|
|
tasksRelation.inverseRelationship = scheduleRelation
|
|
scheduleRelation.inverseRelationship = tasksRelation
|
|
|
|
scheduleEntity.properties.append(tasksRelation)
|
|
taskEntity.properties.append(scheduleRelation)
|
|
|
|
model.entities = [scheduleEntity, taskEntity]
|
|
|
|
return model
|
|
}
|
|
}
|
|
|
|
// MARK: - CoreDataCareScheduleStorageTests
|
|
|
|
final class CoreDataCareScheduleStorageTests: XCTestCase {
|
|
|
|
// MARK: - Properties
|
|
|
|
private var sut: CoreDataCareScheduleStorage!
|
|
private var mockCoreDataStack: MockCoreDataStack!
|
|
|
|
// MARK: - Test Lifecycle
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
mockCoreDataStack = MockCoreDataStack()
|
|
sut = CoreDataCareScheduleStorage(coreDataStack: mockCoreDataStack)
|
|
}
|
|
|
|
override func tearDown() {
|
|
try? mockCoreDataStack.reset()
|
|
sut = nil
|
|
mockCoreDataStack = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
// MARK: - Test Helpers
|
|
|
|
private func createTestSchedule(
|
|
id: UUID = UUID(),
|
|
plantID: UUID = UUID(),
|
|
lightRequirement: LightRequirement = .partialShade,
|
|
wateringSchedule: String = "Every 3 days",
|
|
temperatureRange: ClosedRange<Int> = 60...80,
|
|
fertilizerSchedule: String = "Monthly during growing season",
|
|
tasks: [CareTask] = []
|
|
) -> PlantCareSchedule {
|
|
PlantCareSchedule(
|
|
id: id,
|
|
plantID: plantID,
|
|
lightRequirement: lightRequirement,
|
|
wateringSchedule: wateringSchedule,
|
|
temperatureRange: temperatureRange,
|
|
fertilizerSchedule: fertilizerSchedule,
|
|
tasks: tasks
|
|
)
|
|
}
|
|
|
|
private func createTestTask(
|
|
id: UUID = UUID(),
|
|
plantID: UUID = UUID(),
|
|
type: CareTaskType = .watering,
|
|
scheduledDate: Date = Date(),
|
|
completedDate: Date? = nil,
|
|
notes: String = ""
|
|
) -> CareTask {
|
|
CareTask(
|
|
id: id,
|
|
plantID: plantID,
|
|
type: type,
|
|
scheduledDate: scheduledDate,
|
|
completedDate: completedDate,
|
|
notes: notes
|
|
)
|
|
}
|
|
|
|
// MARK: - save() and fetch() Round-Trip Tests
|
|
|
|
func testSaveAndFetch_WithValidSchedule_RoundTripsSuccessfully() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let schedule = createTestSchedule(
|
|
plantID: plantID,
|
|
lightRequirement: .fullSun,
|
|
wateringSchedule: "Every 2 days",
|
|
temperatureRange: 65...75,
|
|
fertilizerSchedule: "Weekly"
|
|
)
|
|
|
|
// When
|
|
try await sut.save(schedule)
|
|
let fetchedSchedule = try await sut.fetch(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertNotNil(fetchedSchedule)
|
|
XCTAssertEqual(fetchedSchedule?.plantID, plantID)
|
|
XCTAssertEqual(fetchedSchedule?.lightRequirement, .fullSun)
|
|
XCTAssertEqual(fetchedSchedule?.wateringSchedule, "Every 2 days")
|
|
XCTAssertEqual(fetchedSchedule?.temperatureRange, 65...75)
|
|
XCTAssertEqual(fetchedSchedule?.fertilizerSchedule, "Weekly")
|
|
}
|
|
|
|
func testSaveAndFetch_WithTasks_RoundTripsSuccessfully() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let task1 = createTestTask(plantID: plantID, type: .watering, notes: "Water thoroughly")
|
|
let task2 = createTestTask(plantID: plantID, type: .fertilizing, notes: "Use organic fertilizer")
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [task1, task2])
|
|
|
|
// When
|
|
try await sut.save(schedule)
|
|
let fetchedSchedule = try await sut.fetch(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertNotNil(fetchedSchedule)
|
|
XCTAssertEqual(fetchedSchedule?.tasks.count, 2)
|
|
|
|
let taskTypes = fetchedSchedule?.tasks.map { $0.type } ?? []
|
|
XCTAssertTrue(taskTypes.contains(.watering))
|
|
XCTAssertTrue(taskTypes.contains(.fertilizing))
|
|
}
|
|
|
|
func testSave_WhenScheduleAlreadyExists_UpdatesExistingSchedule() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let originalSchedule = createTestSchedule(
|
|
plantID: plantID,
|
|
wateringSchedule: "Weekly"
|
|
)
|
|
try await sut.save(originalSchedule)
|
|
|
|
let updatedSchedule = createTestSchedule(
|
|
plantID: plantID,
|
|
wateringSchedule: "Every 2 days"
|
|
)
|
|
|
|
// When
|
|
try await sut.save(updatedSchedule)
|
|
let fetchedSchedule = try await sut.fetch(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertEqual(fetchedSchedule?.wateringSchedule, "Every 2 days")
|
|
|
|
// Verify only one schedule exists for the plant
|
|
let allSchedules = try await sut.fetchAll()
|
|
let schedulesForPlant = allSchedules.filter { $0.plantID == plantID }
|
|
XCTAssertEqual(schedulesForPlant.count, 1)
|
|
}
|
|
|
|
func testFetch_WhenScheduleDoesNotExist_ReturnsNil() async throws {
|
|
// Given
|
|
let nonExistentPlantID = UUID()
|
|
|
|
// When
|
|
let fetchedSchedule = try await sut.fetch(for: nonExistentPlantID)
|
|
|
|
// Then
|
|
XCTAssertNil(fetchedSchedule)
|
|
}
|
|
|
|
func testSaveAndFetch_WithAllLightRequirements_RoundTripsCorrectly() async throws {
|
|
// Test each light requirement
|
|
for lightReq in LightRequirement.allCases {
|
|
// Given
|
|
let plantID = UUID()
|
|
let schedule = createTestSchedule(plantID: plantID, lightRequirement: lightReq)
|
|
|
|
// When
|
|
try await sut.save(schedule)
|
|
let fetchedSchedule = try await sut.fetch(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertEqual(fetchedSchedule?.lightRequirement, lightReq, "Failed for light requirement: \(lightReq)")
|
|
}
|
|
}
|
|
|
|
// MARK: - fetchAll() Tests
|
|
|
|
func testFetchAll_WhenEmpty_ReturnsEmptyArray() async throws {
|
|
// When
|
|
let schedules = try await sut.fetchAll()
|
|
|
|
// Then
|
|
XCTAssertTrue(schedules.isEmpty)
|
|
}
|
|
|
|
func testFetchAll_WithMultipleSchedules_ReturnsAllSchedules() async throws {
|
|
// Given
|
|
let schedule1 = createTestSchedule(plantID: UUID())
|
|
let schedule2 = createTestSchedule(plantID: UUID())
|
|
let schedule3 = createTestSchedule(plantID: UUID())
|
|
|
|
try await sut.save(schedule1)
|
|
try await sut.save(schedule2)
|
|
try await sut.save(schedule3)
|
|
|
|
// When
|
|
let schedules = try await sut.fetchAll()
|
|
|
|
// Then
|
|
XCTAssertEqual(schedules.count, 3)
|
|
}
|
|
|
|
func testFetchAll_SortsByPlantID() async throws {
|
|
// Given
|
|
let plantID1 = UUID()
|
|
let plantID2 = UUID()
|
|
let plantID3 = UUID()
|
|
|
|
try await sut.save(createTestSchedule(plantID: plantID2))
|
|
try await sut.save(createTestSchedule(plantID: plantID1))
|
|
try await sut.save(createTestSchedule(plantID: plantID3))
|
|
|
|
// When
|
|
let schedules = try await sut.fetchAll()
|
|
|
|
// Then
|
|
XCTAssertEqual(schedules.count, 3)
|
|
// Results should be sorted by plantID (UUID string comparison)
|
|
let plantIDs = schedules.map { $0.plantID }
|
|
XCTAssertEqual(plantIDs, plantIDs.sorted { $0.uuidString < $1.uuidString })
|
|
}
|
|
|
|
// MARK: - delete() Tests
|
|
|
|
func testDelete_WhenScheduleExists_RemovesSchedule() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let schedule = createTestSchedule(plantID: plantID)
|
|
try await sut.save(schedule)
|
|
|
|
// Verify schedule exists
|
|
let fetchedBefore = try await sut.fetch(for: plantID)
|
|
XCTAssertNotNil(fetchedBefore)
|
|
|
|
// When
|
|
try await sut.delete(for: plantID)
|
|
|
|
// Then
|
|
let fetchedAfter = try await sut.fetch(for: plantID)
|
|
XCTAssertNil(fetchedAfter)
|
|
}
|
|
|
|
func testDelete_WhenScheduleDoesNotExist_ThrowsScheduleNotFound() async {
|
|
// Given
|
|
let nonExistentPlantID = UUID()
|
|
|
|
// When/Then
|
|
do {
|
|
try await sut.delete(for: nonExistentPlantID)
|
|
XCTFail("Expected scheduleNotFound error to be thrown")
|
|
} catch let error as CareScheduleStorageError {
|
|
switch error {
|
|
case .scheduleNotFound(let id):
|
|
XCTAssertEqual(id, nonExistentPlantID)
|
|
default:
|
|
XCTFail("Expected scheduleNotFound error, got \(error)")
|
|
}
|
|
} catch {
|
|
XCTFail("Expected CareScheduleStorageError, got \(error)")
|
|
}
|
|
}
|
|
|
|
func testDelete_AlsoDeletesAssociatedTasks() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let task1 = createTestTask(plantID: plantID, type: .watering)
|
|
let task2 = createTestTask(plantID: plantID, type: .fertilizing)
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [task1, task2])
|
|
try await sut.save(schedule)
|
|
|
|
// Verify tasks exist
|
|
let tasksBefore = try await sut.fetchAllTasks()
|
|
XCTAssertEqual(tasksBefore.count, 2)
|
|
|
|
// When
|
|
try await sut.delete(for: plantID)
|
|
|
|
// Then - Tasks should also be deleted
|
|
let tasksAfter = try await sut.fetchAllTasks()
|
|
XCTAssertTrue(tasksAfter.isEmpty)
|
|
}
|
|
|
|
// MARK: - updateTask() Tests
|
|
|
|
func testUpdateTask_WhenTaskExists_UpdatesTask() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let taskID = UUID()
|
|
let originalTask = CareTask(
|
|
id: taskID,
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Date(),
|
|
completedDate: nil,
|
|
notes: "Original notes"
|
|
)
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [originalTask])
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let updatedTask = CareTask(
|
|
id: taskID,
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Date().addingTimeInterval(86400), // Tomorrow
|
|
completedDate: Date(),
|
|
notes: "Updated notes"
|
|
)
|
|
try await sut.updateTask(updatedTask)
|
|
|
|
// Then
|
|
let allTasks = try await sut.fetchAllTasks()
|
|
let fetchedTask = allTasks.first { $0.id == taskID }
|
|
XCTAssertNotNil(fetchedTask)
|
|
XCTAssertEqual(fetchedTask?.notes, "Updated notes")
|
|
XCTAssertNotNil(fetchedTask?.completedDate)
|
|
}
|
|
|
|
func testUpdateTask_WhenTaskDoesNotExist_ThrowsTaskNotFound() async {
|
|
// Given
|
|
let nonExistentTaskID = UUID()
|
|
let task = createTestTask(id: nonExistentTaskID)
|
|
|
|
// When/Then
|
|
do {
|
|
try await sut.updateTask(task)
|
|
XCTFail("Expected taskNotFound error to be thrown")
|
|
} catch let error as CareScheduleStorageError {
|
|
switch error {
|
|
case .taskNotFound(let id):
|
|
XCTAssertEqual(id, nonExistentTaskID)
|
|
default:
|
|
XCTFail("Expected taskNotFound error, got \(error)")
|
|
}
|
|
} catch {
|
|
XCTFail("Expected CareScheduleStorageError, got \(error)")
|
|
}
|
|
}
|
|
|
|
func testUpdateTask_UpdatesTaskType() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let taskID = UUID()
|
|
let task = CareTask(
|
|
id: taskID,
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Date(),
|
|
notes: ""
|
|
)
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [task])
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let updatedTask = CareTask(
|
|
id: taskID,
|
|
plantID: plantID,
|
|
type: .fertilizing,
|
|
scheduledDate: Date(),
|
|
notes: ""
|
|
)
|
|
try await sut.updateTask(updatedTask)
|
|
|
|
// Then
|
|
let allTasks = try await sut.fetchAllTasks()
|
|
let fetchedTask = allTasks.first { $0.id == taskID }
|
|
XCTAssertEqual(fetchedTask?.type, .fertilizing)
|
|
}
|
|
|
|
// MARK: - fetchUpcomingTasks() Tests
|
|
|
|
func testFetchUpcomingTasks_ReturnsTasksWithinSpecifiedDays() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let now = Date()
|
|
|
|
// Task scheduled tomorrow (should be included)
|
|
let tomorrowTask = CareTask(
|
|
id: UUID(),
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 1, to: now)!,
|
|
notes: "Tomorrow task"
|
|
)
|
|
|
|
// Task scheduled in 3 days (should be included for 7 days)
|
|
let threeDaysTask = CareTask(
|
|
id: UUID(),
|
|
plantID: plantID,
|
|
type: .fertilizing,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 3, to: now)!,
|
|
notes: "Three days task"
|
|
)
|
|
|
|
// Task scheduled in 10 days (should NOT be included for 7 days)
|
|
let tenDaysTask = CareTask(
|
|
id: UUID(),
|
|
plantID: plantID,
|
|
type: .pruning,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 10, to: now)!,
|
|
notes: "Ten days task"
|
|
)
|
|
|
|
// Task in the past (should NOT be included)
|
|
let pastTask = CareTask(
|
|
id: UUID(),
|
|
plantID: plantID,
|
|
type: .repotting,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: -1, to: now)!,
|
|
notes: "Past task"
|
|
)
|
|
|
|
// Completed task (should NOT be included)
|
|
let completedTask = CareTask(
|
|
id: UUID(),
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 2, to: now)!,
|
|
completedDate: now,
|
|
notes: "Completed task"
|
|
)
|
|
|
|
let schedule = createTestSchedule(
|
|
plantID: plantID,
|
|
tasks: [tomorrowTask, threeDaysTask, tenDaysTask, pastTask, completedTask]
|
|
)
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let upcomingTasks = try await sut.fetchUpcomingTasks(days: 7)
|
|
|
|
// Then
|
|
XCTAssertEqual(upcomingTasks.count, 2)
|
|
let notes = upcomingTasks.map { $0.notes }
|
|
XCTAssertTrue(notes.contains("Tomorrow task"))
|
|
XCTAssertTrue(notes.contains("Three days task"))
|
|
XCTAssertFalse(notes.contains("Ten days task"))
|
|
XCTAssertFalse(notes.contains("Past task"))
|
|
XCTAssertFalse(notes.contains("Completed task"))
|
|
}
|
|
|
|
func testFetchUpcomingTasks_ReturnsTasksSortedByScheduledDate() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let now = Date()
|
|
|
|
let task3Days = createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 3, to: now)!,
|
|
notes: "3 days"
|
|
)
|
|
let task1Day = createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 1, to: now)!,
|
|
notes: "1 day"
|
|
)
|
|
let task2Days = createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 2, to: now)!,
|
|
notes: "2 days"
|
|
)
|
|
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [task3Days, task1Day, task2Days])
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let upcomingTasks = try await sut.fetchUpcomingTasks(days: 7)
|
|
|
|
// Then
|
|
XCTAssertEqual(upcomingTasks.count, 3)
|
|
XCTAssertEqual(upcomingTasks[0].notes, "1 day")
|
|
XCTAssertEqual(upcomingTasks[1].notes, "2 days")
|
|
XCTAssertEqual(upcomingTasks[2].notes, "3 days")
|
|
}
|
|
|
|
func testFetchUpcomingTasks_WhenNoTasks_ReturnsEmptyArray() async throws {
|
|
// When
|
|
let upcomingTasks = try await sut.fetchUpcomingTasks(days: 7)
|
|
|
|
// Then
|
|
XCTAssertTrue(upcomingTasks.isEmpty)
|
|
}
|
|
|
|
func testFetchUpcomingTasks_WithZeroDays_ReturnsNoTasks() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let task = createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Date().addingTimeInterval(3600) // 1 hour from now
|
|
)
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [task])
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let upcomingTasks = try await sut.fetchUpcomingTasks(days: 0)
|
|
|
|
// Then
|
|
XCTAssertTrue(upcomingTasks.isEmpty)
|
|
}
|
|
|
|
// MARK: - fetchAllTasks() Tests
|
|
|
|
func testFetchAllTasks_ReturnsAllTasks() async throws {
|
|
// Given
|
|
let plantID1 = UUID()
|
|
let plantID2 = UUID()
|
|
|
|
let schedule1 = createTestSchedule(
|
|
plantID: plantID1,
|
|
tasks: [createTestTask(plantID: plantID1), createTestTask(plantID: plantID1)]
|
|
)
|
|
let schedule2 = createTestSchedule(
|
|
plantID: plantID2,
|
|
tasks: [createTestTask(plantID: plantID2)]
|
|
)
|
|
|
|
try await sut.save(schedule1)
|
|
try await sut.save(schedule2)
|
|
|
|
// When
|
|
let allTasks = try await sut.fetchAllTasks()
|
|
|
|
// Then
|
|
XCTAssertEqual(allTasks.count, 3)
|
|
}
|
|
|
|
// MARK: - exists() Tests
|
|
|
|
func testExists_WhenScheduleExists_ReturnsTrue() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let schedule = createTestSchedule(plantID: plantID)
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let exists = try await sut.exists(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertTrue(exists)
|
|
}
|
|
|
|
func testExists_WhenScheduleDoesNotExist_ReturnsFalse() async throws {
|
|
// Given
|
|
let nonExistentPlantID = UUID()
|
|
|
|
// When
|
|
let exists = try await sut.exists(for: nonExistentPlantID)
|
|
|
|
// Then
|
|
XCTAssertFalse(exists)
|
|
}
|
|
|
|
// MARK: - markTaskCompleted() Tests
|
|
|
|
func testMarkTaskCompleted_SetsCompletedDate() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let taskID = UUID()
|
|
let task = CareTask(
|
|
id: taskID,
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Date(),
|
|
completedDate: nil,
|
|
notes: ""
|
|
)
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [task])
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
try await sut.markTaskCompleted(taskID: taskID)
|
|
|
|
// Then
|
|
let allTasks = try await sut.fetchAllTasks()
|
|
let completedTask = allTasks.first { $0.id == taskID }
|
|
XCTAssertNotNil(completedTask?.completedDate)
|
|
}
|
|
|
|
func testMarkTaskCompleted_WhenTaskDoesNotExist_ThrowsTaskNotFound() async {
|
|
// Given
|
|
let nonExistentTaskID = UUID()
|
|
|
|
// When/Then
|
|
do {
|
|
try await sut.markTaskCompleted(taskID: nonExistentTaskID)
|
|
XCTFail("Expected taskNotFound error to be thrown")
|
|
} catch let error as CareScheduleStorageError {
|
|
switch error {
|
|
case .taskNotFound(let id):
|
|
XCTAssertEqual(id, nonExistentTaskID)
|
|
default:
|
|
XCTFail("Expected taskNotFound error, got \(error)")
|
|
}
|
|
} catch {
|
|
XCTFail("Expected CareScheduleStorageError, got \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - fetchOverdueTasks() Tests
|
|
|
|
func testFetchOverdueTasks_ReturnsOverdueIncompleteTasks() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let now = Date()
|
|
|
|
// Overdue task (past date, not completed)
|
|
let overdueTask = createTestTask(
|
|
plantID: plantID,
|
|
type: .watering,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: -2, to: now)!,
|
|
completedDate: nil,
|
|
notes: "Overdue"
|
|
)
|
|
|
|
// Past but completed (should NOT be included)
|
|
let completedPastTask = createTestTask(
|
|
plantID: plantID,
|
|
type: .fertilizing,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: -1, to: now)!,
|
|
completedDate: now,
|
|
notes: "Completed past"
|
|
)
|
|
|
|
// Future task (should NOT be included)
|
|
let futureTask = createTestTask(
|
|
plantID: plantID,
|
|
type: .pruning,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 1, to: now)!,
|
|
notes: "Future"
|
|
)
|
|
|
|
let schedule = createTestSchedule(
|
|
plantID: plantID,
|
|
tasks: [overdueTask, completedPastTask, futureTask]
|
|
)
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let overdueTasks = try await sut.fetchOverdueTasks()
|
|
|
|
// Then
|
|
XCTAssertEqual(overdueTasks.count, 1)
|
|
XCTAssertEqual(overdueTasks.first?.notes, "Overdue")
|
|
}
|
|
|
|
// MARK: - fetchTasks(for:) Tests
|
|
|
|
func testFetchTasksForPlant_ReturnsOnlyTasksForSpecifiedPlant() async throws {
|
|
// Given
|
|
let plantID1 = UUID()
|
|
let plantID2 = UUID()
|
|
|
|
let schedule1 = createTestSchedule(
|
|
plantID: plantID1,
|
|
tasks: [
|
|
createTestTask(plantID: plantID1, notes: "Plant 1 Task 1"),
|
|
createTestTask(plantID: plantID1, notes: "Plant 1 Task 2")
|
|
]
|
|
)
|
|
let schedule2 = createTestSchedule(
|
|
plantID: plantID2,
|
|
tasks: [createTestTask(plantID: plantID2, notes: "Plant 2 Task")]
|
|
)
|
|
|
|
try await sut.save(schedule1)
|
|
try await sut.save(schedule2)
|
|
|
|
// When
|
|
let tasksForPlant1 = try await sut.fetchTasks(for: plantID1)
|
|
|
|
// Then
|
|
XCTAssertEqual(tasksForPlant1.count, 2)
|
|
XCTAssertTrue(tasksForPlant1.allSatisfy { $0.plantID == plantID1 })
|
|
}
|
|
|
|
// MARK: - getTaskStatistics() Tests
|
|
|
|
func testGetTaskStatistics_ReturnsCorrectCounts() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let now = Date()
|
|
|
|
let tasks = [
|
|
// Upcoming (within 7 days)
|
|
createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 2, to: now)!
|
|
),
|
|
createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: 5, to: now)!
|
|
),
|
|
// Overdue
|
|
createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: -1, to: now)!
|
|
),
|
|
createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: -3, to: now)!
|
|
),
|
|
createTestTask(
|
|
plantID: plantID,
|
|
scheduledDate: Calendar.current.date(byAdding: .day, value: -5, to: now)!
|
|
)
|
|
]
|
|
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: tasks)
|
|
try await sut.save(schedule)
|
|
|
|
// When
|
|
let stats = try await sut.getTaskStatistics()
|
|
|
|
// Then
|
|
XCTAssertEqual(stats.upcoming, 2)
|
|
XCTAssertEqual(stats.overdue, 3)
|
|
}
|
|
|
|
// MARK: - Error Description Tests
|
|
|
|
func testCareScheduleStorageError_HasCorrectDescriptions() {
|
|
// Test scheduleNotFound
|
|
let scheduleNotFoundError = CareScheduleStorageError.scheduleNotFound(UUID())
|
|
XCTAssertNotNil(scheduleNotFoundError.errorDescription)
|
|
XCTAssertTrue(scheduleNotFoundError.errorDescription?.contains("not found") ?? false)
|
|
|
|
// Test taskNotFound
|
|
let taskNotFoundError = CareScheduleStorageError.taskNotFound(UUID())
|
|
XCTAssertNotNil(taskNotFoundError.errorDescription)
|
|
XCTAssertTrue(taskNotFoundError.errorDescription?.contains("not found") ?? false)
|
|
|
|
// Test saveFailed
|
|
let saveFailed = CareScheduleStorageError.saveFailed(NSError(domain: "test", code: 1))
|
|
XCTAssertNotNil(saveFailed.errorDescription)
|
|
|
|
// Test fetchFailed
|
|
let fetchFailed = CareScheduleStorageError.fetchFailed(NSError(domain: "test", code: 1))
|
|
XCTAssertNotNil(fetchFailed.errorDescription)
|
|
|
|
// Test deleteFailed
|
|
let deleteFailed = CareScheduleStorageError.deleteFailed(NSError(domain: "test", code: 1))
|
|
XCTAssertNotNil(deleteFailed.errorDescription)
|
|
|
|
// Test updateFailed
|
|
let updateFailed = CareScheduleStorageError.updateFailed(NSError(domain: "test", code: 1))
|
|
XCTAssertNotNil(updateFailed.errorDescription)
|
|
|
|
// Test invalidData
|
|
let invalidData = CareScheduleStorageError.invalidData("Test message")
|
|
XCTAssertNotNil(invalidData.errorDescription)
|
|
XCTAssertTrue(invalidData.errorDescription?.contains("Test message") ?? false)
|
|
|
|
// Test entityNotFound
|
|
let entityNotFound = CareScheduleStorageError.entityNotFound("TestEntity")
|
|
XCTAssertNotNil(entityNotFound.errorDescription)
|
|
XCTAssertTrue(entityNotFound.errorDescription?.contains("TestEntity") ?? false)
|
|
}
|
|
|
|
// MARK: - Protocol Conformance Tests
|
|
|
|
func testCoreDataCareScheduleStorage_ConformsToProtocol() {
|
|
// Then
|
|
XCTAssertTrue(sut is CareScheduleRepositoryProtocol)
|
|
}
|
|
|
|
// MARK: - Edge Cases
|
|
|
|
func testSaveAndFetch_WithEmptyTasks_WorksCorrectly() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: [])
|
|
|
|
// When
|
|
try await sut.save(schedule)
|
|
let fetchedSchedule = try await sut.fetch(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertNotNil(fetchedSchedule)
|
|
XCTAssertTrue(fetchedSchedule?.tasks.isEmpty ?? false)
|
|
}
|
|
|
|
func testSaveAndFetch_WithAllTaskTypes_WorksCorrectly() async throws {
|
|
// Given
|
|
let plantID = UUID()
|
|
let tasks = CareTaskType.allCases.map { type in
|
|
createTestTask(plantID: plantID, type: type)
|
|
}
|
|
let schedule = createTestSchedule(plantID: plantID, tasks: tasks)
|
|
|
|
// When
|
|
try await sut.save(schedule)
|
|
let fetchedSchedule = try await sut.fetch(for: plantID)
|
|
|
|
// Then
|
|
XCTAssertEqual(fetchedSchedule?.tasks.count, CareTaskType.allCases.count)
|
|
for type in CareTaskType.allCases {
|
|
XCTAssertTrue(fetchedSchedule?.tasks.contains { $0.type == type } ?? false)
|
|
}
|
|
}
|
|
|
|
func testConcurrentSaves_DoNotCorruptData() async throws {
|
|
// Given
|
|
let iterations = 10
|
|
|
|
// When
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for i in 0..<iterations {
|
|
group.addTask { [sut] in
|
|
let schedule = self.createTestSchedule(
|
|
plantID: UUID(),
|
|
wateringSchedule: "Schedule \(i)"
|
|
)
|
|
try? await sut!.save(schedule)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Then
|
|
let allSchedules = try await sut.fetchAll()
|
|
XCTAssertEqual(allSchedules.count, iterations)
|
|
}
|
|
}
|