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:
457
PlantGuideTests/HybridIdentificationUseCaseTests.swift
Normal file
457
PlantGuideTests/HybridIdentificationUseCaseTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user