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,408 @@
//
// DeletePlantUseCaseTests.swift
// PlantGuideTests
//
// Unit tests for DeletePlantUseCase - the use case for deleting plants
// from the user's collection with proper cleanup of associated resources.
//
import XCTest
@testable import PlantGuide
// MARK: - DeletePlantUseCaseTests
final class DeletePlantUseCaseTests: XCTestCase {
// MARK: - Properties
private var sut: DeletePlantUseCase!
private var mockPlantRepository: MockPlantCollectionRepository!
private var mockImageStorage: MockImageStorage!
private var mockNotificationService: MockNotificationService!
private var mockCareScheduleRepository: MockCareScheduleRepository!
// MARK: - Test Lifecycle
override func setUp() {
super.setUp()
mockPlantRepository = MockPlantCollectionRepository()
mockImageStorage = MockImageStorage()
mockNotificationService = MockNotificationService()
mockCareScheduleRepository = MockCareScheduleRepository()
sut = DeletePlantUseCase(
plantRepository: mockPlantRepository,
imageStorage: mockImageStorage,
notificationService: mockNotificationService,
careScheduleRepository: mockCareScheduleRepository
)
}
override func tearDown() async throws {
sut = nil
mockPlantRepository = nil
await mockImageStorage.reset()
await mockNotificationService.reset()
mockCareScheduleRepository = nil
try await super.tearDown()
}
// MARK: - execute() Basic Delete Tests
func testExecute_WhenPlantExists_SuccessfullyDeletesPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// When
try await sut.execute(plantID: plant.id)
// Then
XCTAssertEqual(mockPlantRepository.deleteCallCount, 1)
XCTAssertEqual(mockPlantRepository.lastDeletedPlantID, plant.id)
XCTAssertNil(mockPlantRepository.plants[plant.id])
}
func testExecute_WhenPlantDoesNotExist_ThrowsPlantNotFound() async {
// Given
let nonExistentPlantID = UUID()
// When/Then
do {
try await sut.execute(plantID: nonExistentPlantID)
XCTFail("Expected plantNotFound error to be thrown")
} catch let error as DeletePlantError {
switch error {
case .plantNotFound(let plantID):
XCTAssertEqual(plantID, nonExistentPlantID)
default:
XCTFail("Expected plantNotFound error, got \(error)")
}
} catch {
XCTFail("Expected DeletePlantError, got \(error)")
}
XCTAssertEqual(mockPlantRepository.deleteCallCount, 0)
}
// MARK: - Notification Cancellation Tests
func testExecute_WhenDeleting_CancelsAllNotificationsForPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// Schedule some notifications for this plant
let task = CareTask.mockWatering(plantID: plant.id)
try await mockNotificationService.scheduleReminder(
for: task,
plantName: plant.displayName,
plantID: plant.id
)
// When
try await sut.execute(plantID: plant.id)
// Then
let cancelAllCount = await mockNotificationService.cancelAllRemindersCallCount
XCTAssertEqual(cancelAllCount, 1)
let lastCancelledPlantID = await mockNotificationService.lastCancelledAllPlantID
XCTAssertEqual(lastCancelledPlantID, plant.id)
}
func testExecute_WhenNotificationCancellationFails_StillDeletesPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// Note: Notification cancellation is non-throwing by design, so we just verify
// the plant is still deleted even if there were internal notification issues
// When
try await sut.execute(plantID: plant.id)
// Then - Plant should still be deleted
XCTAssertEqual(mockPlantRepository.deleteCallCount, 1)
XCTAssertNil(mockPlantRepository.plants[plant.id])
}
// MARK: - Image Cleanup Tests
func testExecute_WhenDeleting_DeletesAllImagesForPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// Add some images for this plant
let testImage = MockImageStorage.createTestImage()
_ = try await mockImageStorage.save(testImage, for: plant.id)
_ = try await mockImageStorage.save(testImage, for: plant.id)
let initialCount = await mockImageStorage.imageCount(for: plant.id)
XCTAssertEqual(initialCount, 2)
// When
try await sut.execute(plantID: plant.id)
// Then
let deleteAllCount = await mockImageStorage.deleteAllCallCount
XCTAssertEqual(deleteAllCount, 1)
let lastDeletedPlantID = await mockImageStorage.lastDeletedAllPlantID
XCTAssertEqual(lastDeletedPlantID, plant.id)
}
func testExecute_WhenImageDeletionFails_StillDeletesPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// Configure image storage to fail on delete
// Note: The use case logs but doesn't throw on image deletion failure
// When
try await sut.execute(plantID: plant.id)
// Then - Plant should still be deleted
XCTAssertEqual(mockPlantRepository.deleteCallCount, 1)
XCTAssertNil(mockPlantRepository.plants[plant.id])
}
// MARK: - Care Schedule Cleanup Tests
func testExecute_WhenDeleting_DeletesCareScheduleForPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// Add a care schedule for this plant
let schedule = PlantCareSchedule.mock(plantID: plant.id)
mockCareScheduleRepository.addSchedule(schedule)
XCTAssertNotNil(mockCareScheduleRepository.schedules[plant.id])
// When
try await sut.execute(plantID: plant.id)
// Then
XCTAssertEqual(mockCareScheduleRepository.deleteCallCount, 1)
XCTAssertEqual(mockCareScheduleRepository.lastDeletedPlantID, plant.id)
}
func testExecute_WhenCareScheduleDeletionFails_StillDeletesPlant() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
mockCareScheduleRepository.shouldThrowOnDelete = true
// When
try await sut.execute(plantID: plant.id)
// Then - Plant should still be deleted despite schedule deletion failure
XCTAssertEqual(mockPlantRepository.deleteCallCount, 1)
XCTAssertNil(mockPlantRepository.plants[plant.id])
}
// MARK: - Complete Cleanup Flow Tests
func testExecute_PerformsCleanupInCorrectOrder() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
let schedule = PlantCareSchedule.mock(plantID: plant.id)
mockCareScheduleRepository.addSchedule(schedule)
let testImage = MockImageStorage.createTestImage()
_ = try await mockImageStorage.save(testImage, for: plant.id)
// When
try await sut.execute(plantID: plant.id)
// Then - Verify all cleanup operations were called
let notificationCancelCount = await mockNotificationService.cancelAllRemindersCallCount
XCTAssertEqual(notificationCancelCount, 1, "Notifications should be cancelled")
let imageDeleteCount = await mockImageStorage.deleteAllCallCount
XCTAssertEqual(imageDeleteCount, 1, "Images should be deleted")
XCTAssertEqual(mockCareScheduleRepository.deleteCallCount, 1, "Care schedule should be deleted")
XCTAssertEqual(mockPlantRepository.deleteCallCount, 1, "Plant should be deleted")
}
func testExecute_WhenAllCleanupSucceeds_PlantIsDeleted() async throws {
// Given
let plant = Plant.mockComplete()
mockPlantRepository.addPlant(plant)
let schedule = PlantCareSchedule.mockWithMixedTasks(plantID: plant.id)
mockCareScheduleRepository.addSchedule(schedule)
let testImage = MockImageStorage.createTestImage()
_ = try await mockImageStorage.save(testImage, for: plant.id)
// When
try await sut.execute(plantID: plant.id)
// Then
XCTAssertNil(mockPlantRepository.plants[plant.id])
}
// MARK: - Error Handling Tests
func testExecute_WhenRepositoryDeleteFails_ThrowsRepositoryDeleteFailed() async {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
mockPlantRepository.shouldThrowOnDelete = true
mockPlantRepository.errorToThrow = NSError(domain: "CoreData", code: 500)
// When/Then
do {
try await sut.execute(plantID: plant.id)
XCTFail("Expected repositoryDeleteFailed error to be thrown")
} catch let error as DeletePlantError {
switch error {
case .repositoryDeleteFailed(let underlyingError):
XCTAssertEqual((underlyingError as NSError).domain, "CoreData")
default:
XCTFail("Expected repositoryDeleteFailed error, got \(error)")
}
} catch {
XCTFail("Expected DeletePlantError, got \(error)")
}
}
func testExecute_WhenExistsCheckFails_PropagatesError() async {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
mockPlantRepository.shouldThrowOnExists = true
// When/Then
do {
try await sut.execute(plantID: plant.id)
XCTFail("Expected error to be thrown")
} catch {
// Error should be propagated
XCTAssertNotNil(error)
}
XCTAssertEqual(mockPlantRepository.existsCallCount, 1)
XCTAssertEqual(mockPlantRepository.deleteCallCount, 0)
}
// MARK: - Error Description Tests
func testDeletePlantError_PlantNotFound_HasCorrectDescription() {
// Given
let plantID = UUID()
let error = DeletePlantError.plantNotFound(plantID: plantID)
// Then
XCTAssertNotNil(error.errorDescription)
XCTAssertTrue(error.errorDescription?.contains(plantID.uuidString) ?? false)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
func testDeletePlantError_RepositoryDeleteFailed_HasCorrectDescription() {
// Given
let underlyingError = NSError(domain: "Test", code: 123)
let error = DeletePlantError.repositoryDeleteFailed(underlyingError)
// Then
XCTAssertNotNil(error.errorDescription)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
func testDeletePlantError_ImageDeletionFailed_HasCorrectDescription() {
// Given
let underlyingError = NSError(domain: "ImageError", code: 456)
let error = DeletePlantError.imageDeletionFailed(underlyingError)
// Then
XCTAssertNotNil(error.errorDescription)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
func testDeletePlantError_CareScheduleDeletionFailed_HasCorrectDescription() {
// Given
let underlyingError = NSError(domain: "ScheduleError", code: 789)
let error = DeletePlantError.careScheduleDeletionFailed(underlyingError)
// Then
XCTAssertNotNil(error.errorDescription)
XCTAssertNotNil(error.failureReason)
XCTAssertNotNil(error.recoverySuggestion)
}
// MARK: - Protocol Conformance Tests
func testDeletePlantUseCase_ConformsToProtocol() {
XCTAssertTrue(sut is DeletePlantUseCaseProtocol)
}
// MARK: - Edge Cases
func testExecute_WhenPlantHasNoAssociatedData_SuccessfullyDeletes() async throws {
// Given - Plant with no images, no schedule, no notifications
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// When
try await sut.execute(plantID: plant.id)
// Then
XCTAssertEqual(mockPlantRepository.deleteCallCount, 1)
XCTAssertNil(mockPlantRepository.plants[plant.id])
}
func testExecute_WithConcurrentDeletes_HandlesCorrectly() async throws {
// Given
let plant1 = Plant.mock()
let plant2 = Plant.mock()
mockPlantRepository.addPlants([plant1, plant2])
// When - Delete both plants concurrently
await withTaskGroup(of: Void.self) { group in
group.addTask { [sut] in
try? await sut!.execute(plantID: plant1.id)
}
group.addTask { [sut] in
try? await sut!.execute(plantID: plant2.id)
}
}
// Then
XCTAssertEqual(mockPlantRepository.deleteCallCount, 2)
}
func testExecute_WhenDeletingSamePlantTwice_SecondAttemptFails() async throws {
// Given
let plant = Plant.mock()
mockPlantRepository.addPlant(plant)
// When - First delete
try await sut.execute(plantID: plant.id)
// Then - Second delete should fail with plantNotFound
do {
try await sut.execute(plantID: plant.id)
XCTFail("Expected plantNotFound error on second delete")
} catch let error as DeletePlantError {
switch error {
case .plantNotFound:
break // Expected
default:
XCTFail("Expected plantNotFound error, got \(error)")
}
} catch {
XCTFail("Expected DeletePlantError, got \(error)")
}
}
}