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,457 @@
//
// HybridIdentificationUseCaseTests.swift
// PlantGuideTests
//
// Unit tests for HybridIdentificationUseCase - the use case for hybrid plant
// identification combining on-device ML and online API.
//
import XCTest
import UIKit
@testable import PlantGuide
// MARK: - HybridIdentificationUseCaseTests
final class HybridIdentificationUseCaseTests: XCTestCase {
// MARK: - Properties
private var sut: HybridIdentificationUseCase!
private var mockOnDeviceUseCase: MockIdentifyPlantUseCase!
private var mockOnlineUseCase: MockIdentifyPlantOnlineUseCase!
private var mockNetworkMonitor: MockNetworkMonitor!
// MARK: - Test Lifecycle
override func setUp() {
super.setUp()
mockOnDeviceUseCase = MockIdentifyPlantUseCase()
mockOnlineUseCase = MockIdentifyPlantOnlineUseCase()
mockNetworkMonitor = MockNetworkMonitor(isConnected: true)
sut = HybridIdentificationUseCase(
onDeviceUseCase: mockOnDeviceUseCase,
onlineUseCase: mockOnlineUseCase,
networkMonitor: mockNetworkMonitor
)
}
override func tearDown() {
sut = nil
mockOnDeviceUseCase = nil
mockOnlineUseCase = nil
mockNetworkMonitor = nil
super.tearDown()
}
// MARK: - Test Helpers
private func createTestImage() -> UIImage {
let size = CGSize(width: 224, height: 224)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
UIColor.green.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
private func createHighConfidencePredictions() -> [ViewPlantPrediction] {
[
ViewPlantPrediction(
id: UUID(),
speciesName: "Monstera deliciosa",
commonName: "Swiss Cheese Plant",
confidence: 0.95
)
]
}
private func createLowConfidencePredictions() -> [ViewPlantPrediction] {
[
ViewPlantPrediction(
id: UUID(),
speciesName: "Unknown Plant",
commonName: nil,
confidence: 0.35
)
]
}
private func createOnlinePredictions() -> [ViewPlantPrediction] {
[
ViewPlantPrediction(
id: UUID(),
speciesName: "Monstera deliciosa",
commonName: "Swiss Cheese Plant",
confidence: 0.98
)
]
}
// MARK: - onDeviceOnly Strategy Tests
func testExecute_WithOnDeviceOnlyStrategy_ReturnsOnDeviceResults() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions()
// When
let result = try await sut.execute(image: testImage, strategy: .onDeviceOnly)
// Then
XCTAssertEqual(result.source, .onDeviceML)
XCTAssertTrue(result.onDeviceAvailable)
XCTAssertFalse(result.predictions.isEmpty)
XCTAssertEqual(result.predictions[0].speciesName, "Monstera deliciosa")
}
func testExecute_WithOnDeviceOnlyStrategy_IgnoresOnlineAvailability() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions()
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
// When
let result = try await sut.execute(image: testImage, strategy: .onDeviceOnly)
// Then
XCTAssertEqual(result.source, .onDeviceML)
// Online should not be called
}
func testExecute_WithOnDeviceOnlyStrategy_WhenOnDeviceFails_ThrowsError() async {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.shouldThrow = true
mockOnDeviceUseCase.errorToThrow = IdentifyPlantOnDeviceUseCaseError.noMatchesFound
// When/Then
do {
_ = try await sut.execute(image: testImage, strategy: .onDeviceOnly)
XCTFail("Expected error to be thrown")
} catch let error as IdentifyPlantOnDeviceUseCaseError {
XCTAssertEqual(error, .noMatchesFound)
} catch {
XCTFail("Expected IdentifyPlantOnDeviceUseCaseError, got \(error)")
}
}
// MARK: - onlineOnly Strategy Tests
func testExecute_WithOnlineOnlyStrategy_WhenConnected_ReturnsOnlineResults() async throws {
// Given
let testImage = createTestImage()
mockNetworkMonitor.isConnected = true
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
// When
let result = try await sut.execute(image: testImage, strategy: .onlineOnly)
// Then
XCTAssertEqual(result.source, .plantNetAPI)
XCTAssertTrue(result.onlineAvailable)
XCTAssertFalse(result.predictions.isEmpty)
}
func testExecute_WithOnlineOnlyStrategy_WhenDisconnected_ThrowsNoNetworkError() async {
// Given
let testImage = createTestImage()
mockNetworkMonitor.isConnected = false
// When/Then
do {
_ = try await sut.execute(image: testImage, strategy: .onlineOnly)
XCTFail("Expected noNetworkForOnlineOnly error")
} catch let error as HybridIdentificationError {
XCTAssertEqual(error, .noNetworkForOnlineOnly)
} catch {
XCTFail("Expected HybridIdentificationError, got \(error)")
}
}
func testExecute_WithOnlineOnlyStrategy_WhenOnlineFails_ThrowsError() async {
// Given
let testImage = createTestImage()
mockNetworkMonitor.isConnected = true
mockOnlineUseCase.shouldThrow = true
mockOnlineUseCase.errorToThrow = IdentifyPlantOnlineUseCaseError.noMatchesFound
// When/Then
do {
_ = try await sut.execute(image: testImage, strategy: .onlineOnly)
XCTFail("Expected error to be thrown")
} catch let error as IdentifyPlantOnlineUseCaseError {
XCTAssertEqual(error, .noMatchesFound)
} catch {
XCTFail("Expected IdentifyPlantOnlineUseCaseError, got \(error)")
}
}
// MARK: - onDeviceFirst Strategy Tests
func testExecute_WithOnDeviceFirstStrategy_WhenHighConfidence_ReturnsOnDeviceResults() async throws {
// Given
let testImage = createTestImage()
let threshold = 0.8
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions() // 0.95 confidence
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: threshold)
)
// Then
XCTAssertEqual(result.source, .onDeviceML)
XCTAssertEqual(result.predictions[0].confidence, 0.95, accuracy: 0.001)
}
func testExecute_WithOnDeviceFirstStrategy_WhenLowConfidence_FallsBackToOnline() async throws {
// Given
let testImage = createTestImage()
let threshold = 0.8
mockOnDeviceUseCase.predictionsToReturn = createLowConfidencePredictions() // 0.35 confidence
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: threshold)
)
// Then
XCTAssertEqual(result.source, .plantNetAPI)
XCTAssertEqual(result.predictions[0].confidence, 0.98, accuracy: 0.001)
}
func testExecute_WithOnDeviceFirstStrategy_WhenLowConfidenceAndOffline_ReturnsOnDeviceResults() async throws {
// Given
let testImage = createTestImage()
let threshold = 0.8
mockOnDeviceUseCase.predictionsToReturn = createLowConfidencePredictions()
mockNetworkMonitor.isConnected = false
// When
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: threshold)
)
// Then
XCTAssertEqual(result.source, .onDeviceML)
XCTAssertFalse(result.onlineAvailable)
}
func testExecute_WithOnDeviceFirstStrategy_WhenOnlineFails_FallsBackToOnDevice() async throws {
// Given
let testImage = createTestImage()
let threshold = 0.8
mockOnDeviceUseCase.predictionsToReturn = createLowConfidencePredictions()
mockOnlineUseCase.shouldThrow = true
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: threshold)
)
// Then
XCTAssertEqual(result.source, .onDeviceML) // Falls back to on-device
}
// MARK: - parallel Strategy Tests
func testExecute_WithParallelStrategy_WhenBothSucceed_PrefersOnlineResults() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions()
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(image: testImage, strategy: .parallel)
// Then
XCTAssertEqual(result.source, .plantNetAPI)
XCTAssertTrue(result.onDeviceAvailable)
XCTAssertTrue(result.onlineAvailable)
}
func testExecute_WithParallelStrategy_WhenOffline_OnlyRunsOnDevice() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions()
mockNetworkMonitor.isConnected = false
// When
let result = try await sut.execute(image: testImage, strategy: .parallel)
// Then
XCTAssertEqual(result.source, .onDeviceML)
XCTAssertTrue(result.onDeviceAvailable)
XCTAssertFalse(result.onlineAvailable)
}
func testExecute_WithParallelStrategy_WhenOnlineFails_ReturnsOnDevice() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions()
mockOnlineUseCase.shouldThrow = true
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(image: testImage, strategy: .parallel)
// Then
XCTAssertEqual(result.source, .onDeviceML)
XCTAssertTrue(result.onDeviceAvailable)
}
func testExecute_WithParallelStrategy_WhenOnDeviceFails_ReturnsOnline() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.shouldThrow = true
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(image: testImage, strategy: .parallel)
// Then
XCTAssertEqual(result.source, .plantNetAPI)
XCTAssertTrue(result.onlineAvailable)
}
func testExecute_WithParallelStrategy_WhenBothFail_ThrowsBothSourcesFailed() async {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.shouldThrow = true
mockOnlineUseCase.shouldThrow = true
mockNetworkMonitor.isConnected = true
// When/Then
do {
_ = try await sut.execute(image: testImage, strategy: .parallel)
XCTFail("Expected bothSourcesFailed error")
} catch let error as HybridIdentificationError {
XCTAssertEqual(error, .bothSourcesFailed)
} catch {
XCTFail("Expected HybridIdentificationError, got \(error)")
}
}
// MARK: - Error Description Tests
func testHybridIdentificationError_NoNetworkForOnlineOnly_HasCorrectDescription() {
// Given
let error = HybridIdentificationError.noNetworkForOnlineOnly
// Then
XCTAssertNotNil(error.errorDescription)
XCTAssertTrue(error.errorDescription?.contains("network") ?? false)
}
func testHybridIdentificationError_BothSourcesFailed_HasCorrectDescription() {
// Given
let error = HybridIdentificationError.bothSourcesFailed
// Then
XCTAssertNotNil(error.errorDescription)
XCTAssertTrue(error.errorDescription?.contains("Unable to identify") ?? false)
}
// MARK: - HybridIdentificationResult Tests
func testHybridIdentificationResult_ContainsCorrectMetadata() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = createHighConfidencePredictions()
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(image: testImage, strategy: .onDeviceOnly)
// Then
XCTAssertTrue(result.onDeviceAvailable)
XCTAssertTrue(result.onlineAvailable) // Network is connected
XCTAssertEqual(result.source, .onDeviceML)
}
// MARK: - Protocol Conformance Tests
func testHybridIdentificationUseCase_ConformsToProtocol() {
XCTAssertTrue(sut is HybridIdentificationUseCaseProtocol)
}
// MARK: - Edge Cases
func testExecute_WithOnDeviceFirstStrategy_ExactlyAtThreshold_ReturnsOnDevice() async throws {
// Given
let testImage = createTestImage()
let threshold = 0.95
mockOnDeviceUseCase.predictionsToReturn = [
ViewPlantPrediction(
id: UUID(),
speciesName: "Test",
commonName: nil,
confidence: 0.95 // Exactly at threshold
)
]
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: threshold)
)
// Then
XCTAssertEqual(result.source, .onDeviceML)
}
func testExecute_WithOnDeviceFirstStrategy_JustBelowThreshold_UsesOnline() async throws {
// Given
let testImage = createTestImage()
let threshold = 0.95
mockOnDeviceUseCase.predictionsToReturn = [
ViewPlantPrediction(
id: UUID(),
speciesName: "Test",
commonName: nil,
confidence: 0.94 // Just below threshold
)
]
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
mockNetworkMonitor.isConnected = true
// When
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: threshold)
)
// Then
XCTAssertEqual(result.source, .plantNetAPI)
}
func testExecute_WithOnDeviceFirstStrategy_EmptyPredictions_UsesOnline() async throws {
// Given
let testImage = createTestImage()
mockOnDeviceUseCase.predictionsToReturn = [] // Empty - no predictions
mockOnlineUseCase.predictionsToReturn = createOnlinePredictions()
mockNetworkMonitor.isConnected = true
// When - This should use online since top confidence is 0.0
let result = try await sut.execute(
image: testImage,
strategy: .onDeviceFirst(apiThreshold: 0.5)
)
// Then
XCTAssertEqual(result.source, .plantNetAPI)
}
}