Replace brittle localized-string selectors and broken wait helpers with a robust, identifier-first UI test infrastructure. All 41 UI tests pass on iOS 26.2 simulator (iPhone 17). Foundation: - BaseUITestCase with deterministic launch helpers (launchClean, launchOffline) - WaitHelpers (waitUntilHittable, waitUntilGone, tapWhenReady) replacing sleep() - UITestID enum mirroring AccessibilityIdentifiers from the app target - Screen objects: TabBarScreen, CameraScreen, CollectionScreen, TodayScreen, SettingsScreen, PlantDetailScreen Key fixes: - Tab navigation uses waitForExistence+tap instead of isHittable (unreliable in iOS 26 simulator) - Tests handle real app state (empty collection, no camera permission) - Increased timeouts for parallel clone execution - Added NetworkMonitorProtocol and protocol-typed DI for testability - Fixed actor-isolation issues in unit test mocks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
12 KiB
Swift
363 lines
12 KiB
Swift
//
|
|
// IdentifyPlantOnDeviceUseCaseTests.swift
|
|
// PlantGuideTests
|
|
//
|
|
// Unit tests for IdentifyPlantOnDeviceUseCase - the use case for identifying
|
|
// plants using on-device machine learning.
|
|
//
|
|
|
|
import XCTest
|
|
import UIKit
|
|
@testable import PlantGuide
|
|
|
|
// MARK: - IdentifyPlantOnDeviceUseCaseTests
|
|
|
|
final class IdentifyPlantOnDeviceUseCaseTests: XCTestCase {
|
|
|
|
// MARK: - Properties
|
|
|
|
private var sut: IdentifyPlantOnDeviceUseCase!
|
|
private var mockPreprocessor: MockImagePreprocessor!
|
|
private var mockClassificationService: MockPlantClassificationService!
|
|
|
|
// MARK: - Test Lifecycle
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
mockPreprocessor = MockImagePreprocessor()
|
|
mockClassificationService = MockPlantClassificationService()
|
|
|
|
sut = IdentifyPlantOnDeviceUseCase(
|
|
imagePreprocessor: mockPreprocessor,
|
|
classificationService: mockClassificationService
|
|
)
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
sut = nil
|
|
mockPreprocessor = nil
|
|
await mockClassificationService.reset()
|
|
try await super.tearDown()
|
|
}
|
|
|
|
// MARK: - Test Helpers
|
|
|
|
private func createTestImage(size: CGSize = CGSize(width: 224, height: 224)) -> UIImage {
|
|
let renderer = UIGraphicsImageRenderer(size: size)
|
|
return renderer.image { context in
|
|
UIColor.green.setFill()
|
|
context.fill(CGRect(origin: .zero, size: size))
|
|
}
|
|
}
|
|
|
|
// MARK: - execute() Successful Identification Tests
|
|
|
|
func testExecute_WhenSuccessfulIdentification_ReturnsViewPredictions() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
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"]
|
|
)
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
XCTAssertEqual(result.count, 2)
|
|
XCTAssertEqual(result[0].speciesName, "Monstera deliciosa")
|
|
XCTAssertEqual(result[0].commonName, "Swiss Cheese Plant")
|
|
XCTAssertEqual(result[0].confidence, 0.92, accuracy: 0.001)
|
|
XCTAssertEqual(result[1].speciesName, "Philodendron bipinnatifidum")
|
|
}
|
|
|
|
func testExecute_WhenSinglePrediction_ReturnsOnePrediction() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(
|
|
speciesIndex: 0,
|
|
confidence: 0.95,
|
|
scientificName: "Epipremnum aureum",
|
|
commonNames: ["Pothos", "Devil's Ivy"]
|
|
)
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
XCTAssertEqual(result.count, 1)
|
|
XCTAssertEqual(result[0].speciesName, "Epipremnum aureum")
|
|
XCTAssertEqual(result[0].commonName, "Pothos")
|
|
}
|
|
|
|
func testExecute_MapsConfidenceCorrectly() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
let highConfidence: Float = 0.98
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(
|
|
speciesIndex: 0,
|
|
confidence: highConfidence,
|
|
scientificName: "Test Plant",
|
|
commonNames: ["Common Name"]
|
|
)
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
XCTAssertEqual(result[0].confidence, Double(highConfidence), accuracy: 0.001)
|
|
}
|
|
|
|
func testExecute_WhenNoCommonNames_ReturnsNilCommonName() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(
|
|
speciesIndex: 0,
|
|
confidence: 0.85,
|
|
scientificName: "Rare Plant Species",
|
|
commonNames: [] // No common names
|
|
)
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
XCTAssertEqual(result.count, 1)
|
|
XCTAssertEqual(result[0].speciesName, "Rare Plant Species")
|
|
XCTAssertNil(result[0].commonName)
|
|
}
|
|
|
|
// MARK: - execute() Low Confidence Tests
|
|
|
|
func testExecute_WhenLowConfidence_StillReturnsPredictions() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(
|
|
speciesIndex: 0,
|
|
confidence: 0.35, // Low confidence
|
|
scientificName: "Unknown Plant",
|
|
commonNames: []
|
|
)
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
XCTAssertEqual(result.count, 1)
|
|
XCTAssertEqual(result[0].confidence, 0.35, accuracy: 0.001)
|
|
}
|
|
|
|
func testExecute_WhenVeryLowConfidence_StillReturnsPredictions() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(
|
|
speciesIndex: 0,
|
|
confidence: 0.10, // Very low confidence
|
|
scientificName: "Uncertain Plant",
|
|
commonNames: []
|
|
)
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
XCTAssertEqual(result.count, 1)
|
|
XCTAssertLessThan(result[0].confidence, 0.2)
|
|
}
|
|
|
|
// MARK: - execute() No Results Tests
|
|
|
|
func testExecute_WhenNoMatchesFound_ThrowsNoMatchesFound() async {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
// Empty predictions
|
|
await mockClassificationService.configureMockPredictions([])
|
|
|
|
// When/Then
|
|
do {
|
|
_ = try await sut.execute(image: testImage)
|
|
XCTFail("Expected noMatchesFound error to be thrown")
|
|
} catch let error as IdentifyPlantOnDeviceUseCaseError {
|
|
XCTAssertEqual(error, .noMatchesFound)
|
|
} catch {
|
|
XCTFail("Expected IdentifyPlantOnDeviceUseCaseError, got \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - execute() Preprocessing Tests
|
|
|
|
func testExecute_CallsPreprocessorWithImage() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
await mockClassificationService.configureDefaultPredictions()
|
|
|
|
// When
|
|
_ = try await sut.execute(image: testImage)
|
|
|
|
// Then
|
|
// The preprocessor should have been called
|
|
// (We can't directly verify call count on struct mock without additional tracking)
|
|
let classifyCount = await mockClassificationService.classifyCallCount
|
|
XCTAssertEqual(classifyCount, 1)
|
|
}
|
|
|
|
func testExecute_WhenPreprocessingFails_PropagatesError() async {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
mockPreprocessor.shouldThrow = true
|
|
mockPreprocessor.errorToThrow = ImagePreprocessorError.cgImageCreationFailed
|
|
|
|
// Recreate SUT with failing preprocessor
|
|
sut = IdentifyPlantOnDeviceUseCase(
|
|
imagePreprocessor: mockPreprocessor,
|
|
classificationService: mockClassificationService
|
|
)
|
|
|
|
// When/Then
|
|
do {
|
|
_ = try await sut.execute(image: testImage)
|
|
XCTFail("Expected preprocessing error to be thrown")
|
|
} catch let error as ImagePreprocessorError {
|
|
XCTAssertEqual(error, .cgImageCreationFailed)
|
|
} catch {
|
|
// Other error types are also acceptable since the error is propagated
|
|
XCTAssertNotNil(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - execute() Classification Service Tests
|
|
|
|
func testExecute_WhenClassificationFails_PropagatesError() async {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await MainActor.run {
|
|
Task {
|
|
await mockClassificationService.reset()
|
|
}
|
|
}
|
|
|
|
// Configure mock to throw via actor-isolated method
|
|
await mockClassificationService.setThrowBehavior(
|
|
shouldThrow: true,
|
|
error: PlantClassificationError.modelLoadFailed
|
|
)
|
|
|
|
// Verify the error propagates
|
|
do {
|
|
_ = try await sut.execute(image: testImage)
|
|
XCTFail("Expected classification error to be thrown")
|
|
} catch {
|
|
XCTAssertNotNil(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Description Tests
|
|
|
|
func testIdentifyPlantOnDeviceUseCaseError_NoMatchesFound_HasCorrectDescription() {
|
|
// Given
|
|
let error = IdentifyPlantOnDeviceUseCaseError.noMatchesFound
|
|
|
|
// Then
|
|
XCTAssertNotNil(error.errorDescription)
|
|
XCTAssertTrue(error.errorDescription?.contains("No plant matches") ?? false)
|
|
}
|
|
|
|
// MARK: - Protocol Conformance Tests
|
|
|
|
func testIdentifyPlantOnDeviceUseCase_ConformsToProtocol() {
|
|
XCTAssertTrue(sut is IdentifyPlantUseCaseProtocol)
|
|
}
|
|
|
|
// MARK: - Edge Cases
|
|
|
|
func testExecute_WithMultiplePredictions_ReturnsSortedByConfidence() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
// Predictions in random order
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(speciesIndex: 2, confidence: 0.45, scientificName: "Low", commonNames: []),
|
|
PlantPrediction(speciesIndex: 0, confidence: 0.92, scientificName: "High", commonNames: []),
|
|
PlantPrediction(speciesIndex: 1, confidence: 0.75, scientificName: "Medium", commonNames: [])
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then - Results should maintain the order from classification service
|
|
// (which should already be sorted by confidence descending)
|
|
XCTAssertEqual(result.count, 3)
|
|
}
|
|
|
|
func testExecute_WithLargeImage_Succeeds() async throws {
|
|
// Given
|
|
let largeImage = createTestImage(size: CGSize(width: 4000, height: 3000))
|
|
await mockClassificationService.configureDefaultPredictions()
|
|
|
|
// When
|
|
let result = try await sut.execute(image: largeImage)
|
|
|
|
// Then
|
|
XCTAssertFalse(result.isEmpty)
|
|
}
|
|
|
|
func testExecute_WithSmallImage_Succeeds() async throws {
|
|
// Given
|
|
let smallImage = createTestImage(size: CGSize(width: 224, height: 224))
|
|
await mockClassificationService.configureDefaultPredictions()
|
|
|
|
// When
|
|
let result = try await sut.execute(image: smallImage)
|
|
|
|
// Then
|
|
XCTAssertFalse(result.isEmpty)
|
|
}
|
|
|
|
func testExecute_EachPredictionHasUniqueID() async throws {
|
|
// Given
|
|
let testImage = createTestImage()
|
|
|
|
await mockClassificationService.configureMockPredictions([
|
|
PlantPrediction(speciesIndex: 0, confidence: 0.9, scientificName: "Plant 1", commonNames: []),
|
|
PlantPrediction(speciesIndex: 1, confidence: 0.8, scientificName: "Plant 2", commonNames: []),
|
|
PlantPrediction(speciesIndex: 2, confidence: 0.7, scientificName: "Plant 3", commonNames: [])
|
|
])
|
|
|
|
// When
|
|
let result = try await sut.execute(image: testImage)
|
|
|
|
// Then - All predictions should have unique IDs
|
|
let ids = result.map { $0.id }
|
|
let uniqueIds = Set(ids)
|
|
XCTAssertEqual(ids.count, uniqueIds.count, "All prediction IDs should be unique")
|
|
}
|
|
}
|