- 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>
370 lines
14 KiB
Swift
370 lines
14 KiB
Swift
//
|
|
// CameraFlowUITests.swift
|
|
// PlantGuideUITests
|
|
//
|
|
// Created on 2026-01-21.
|
|
//
|
|
// UI tests for the camera and plant identification flow including
|
|
// permission handling, capture, and photo preview.
|
|
//
|
|
|
|
import XCTest
|
|
|
|
final class CameraFlowUITests: XCTestCase {
|
|
|
|
// MARK: - Properties
|
|
|
|
var app: XCUIApplication!
|
|
|
|
// MARK: - Setup & Teardown
|
|
|
|
override func setUpWithError() throws {
|
|
continueAfterFailure = false
|
|
app = XCUIApplication()
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
app = nil
|
|
}
|
|
|
|
// MARK: - Permission Request Tests
|
|
|
|
/// Tests that camera permission request view appears for new users.
|
|
///
|
|
/// Note: This test assumes the app is launched in a state where
|
|
/// camera permission has not been determined. The actual system
|
|
/// permission dialog behavior depends on the device state.
|
|
@MainActor
|
|
func testCameraPermissionRequestViewAppears() throws {
|
|
// Given: App launched with clean state (permission not determined)
|
|
app.launchWithCleanState()
|
|
|
|
// When: App is on Camera tab (default tab)
|
|
// The Camera tab should be selected by default based on MainTabView
|
|
|
|
// Then: Permission request view should display for new users
|
|
// Look for the permission request UI elements
|
|
let permissionTitle = app.staticTexts["Camera Access Required"]
|
|
let permissionDescription = app.staticTexts.matching(
|
|
NSPredicate(format: "label CONTAINS[c] 'camera access'")
|
|
).firstMatch
|
|
|
|
// Give time for the permission request view to appear
|
|
let titleExists = permissionTitle.waitForExistence(timeout: 5)
|
|
let descriptionExists = permissionDescription.waitForExistence(timeout: 2)
|
|
|
|
// At least one of these elements should exist if permission is not determined
|
|
// or the camera view itself if already authorized
|
|
let cameraIcon = app.images.matching(
|
|
NSPredicate(format: "identifier == 'camera.fill' OR label CONTAINS[c] 'camera'")
|
|
).firstMatch
|
|
|
|
XCTAssertTrue(
|
|
titleExists || descriptionExists || cameraIcon.waitForExistence(timeout: 2),
|
|
"Camera permission request view or camera UI should appear"
|
|
)
|
|
}
|
|
|
|
/// Tests that the permission denied view shows appropriate messaging.
|
|
///
|
|
/// Note: This test verifies the UI elements that appear when camera
|
|
/// access is denied. Actual permission state cannot be controlled in UI tests.
|
|
@MainActor
|
|
func testCameraPermissionDeniedViewElements() throws {
|
|
// Given: App launched (permission state depends on device)
|
|
app.launchWithCleanState()
|
|
|
|
// When: Camera permission is denied (if in denied state)
|
|
// We check for the presence of permission denied UI elements
|
|
|
|
// Then: Look for denied state elements
|
|
let deniedTitle = app.staticTexts["Camera Access Denied"]
|
|
let openSettingsButton = app.buttons["Open Settings"]
|
|
|
|
// These will exist only if permission is actually denied
|
|
// We verify the test setup is correct
|
|
if deniedTitle.waitForExistence(timeout: 3) {
|
|
XCTAssertTrue(deniedTitle.exists, "Denied title should be visible")
|
|
XCTAssertTrue(
|
|
openSettingsButton.waitForExistence(timeout: 2),
|
|
"Open Settings button should be visible when permission denied"
|
|
)
|
|
|
|
// Verify the description text
|
|
let description = app.staticTexts.matching(
|
|
NSPredicate(format: "label CONTAINS[c] 'enable camera access in Settings'")
|
|
).firstMatch
|
|
XCTAssertTrue(description.exists, "Description should explain how to enable camera")
|
|
}
|
|
}
|
|
|
|
// MARK: - Capture Button Tests
|
|
|
|
/// Tests that the capture button exists when camera is authorized.
|
|
///
|
|
/// Note: This test assumes camera permission has been granted.
|
|
/// The test will check for the capture button's presence.
|
|
@MainActor
|
|
func testCaptureButtonExistsWhenAuthorized() throws {
|
|
// Given: App launched (assuming camera permission granted)
|
|
app.launchWithMockData()
|
|
|
|
// When: Navigate to Camera tab (or stay if default)
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Then: Look for capture button (circular button with specific accessibility)
|
|
let captureButton = app.buttons["Capture photo"]
|
|
|
|
// If camera is authorized, capture button should exist
|
|
// If not authorized, we skip the assertion
|
|
if captureButton.waitForExistence(timeout: 5) {
|
|
XCTAssertTrue(captureButton.exists, "Capture button should exist when camera authorized")
|
|
XCTAssertTrue(captureButton.isEnabled, "Capture button should be enabled")
|
|
} else {
|
|
// Camera might not be authorized - check for permission views
|
|
let permissionView = app.staticTexts["Camera Access Required"].exists ||
|
|
app.staticTexts["Camera Access Denied"].exists
|
|
XCTAssertTrue(permissionView, "Should show either capture button or permission view")
|
|
}
|
|
}
|
|
|
|
/// Tests capture button has correct accessibility label and hint.
|
|
@MainActor
|
|
func testCaptureButtonAccessibility() throws {
|
|
// Given: App launched with camera access
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// When: Capture button is available
|
|
let captureButton = app.buttons["Capture photo"]
|
|
|
|
if captureButton.waitForExistence(timeout: 5) {
|
|
// Then: Check accessibility properties
|
|
XCTAssertEqual(
|
|
captureButton.label,
|
|
"Capture photo",
|
|
"Capture button should have correct accessibility label"
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Photo Preview Tests
|
|
|
|
/// Tests that photo preview appears after capture (mock scenario).
|
|
///
|
|
/// Note: In UI tests, we cannot actually trigger a real camera capture.
|
|
/// This test verifies the preview UI when the app is in the appropriate state.
|
|
@MainActor
|
|
func testPhotoPreviewUIElements() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Check if capture button exists (camera authorized)
|
|
let captureButton = app.buttons["Capture photo"]
|
|
|
|
if captureButton.waitForExistence(timeout: 5) {
|
|
// When: Capture button is tapped
|
|
// Note: This may not actually capture in simulator without mock
|
|
captureButton.tap()
|
|
|
|
// Then: Either capturing overlay or preview should appear
|
|
// Look for capturing state
|
|
let capturingText = app.staticTexts["Capturing..."]
|
|
let retakeButton = app.buttons["Retake photo"]
|
|
let usePhotoButton = app.buttons["Use this photo"]
|
|
|
|
// Wait for either capturing state or preview to appear
|
|
let capturingAppeared = capturingText.waitForExistence(timeout: 3)
|
|
let previewAppeared = retakeButton.waitForExistence(timeout: 5) ||
|
|
usePhotoButton.waitForExistence(timeout: 2)
|
|
|
|
// In a mocked environment, one of these states should occur
|
|
// If camera isn't available, we just verify no crash occurred
|
|
XCTAssertTrue(
|
|
capturingAppeared || previewAppeared || captureButton.exists,
|
|
"App should handle capture attempt gracefully"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Tests that retake button is functional in preview mode.
|
|
@MainActor
|
|
func testRetakeButtonInPreview() throws {
|
|
// Given: App with potential captured image state
|
|
app.launchWithConfiguration(mockData: true, additionalEnvironment: [
|
|
"MOCK_CAPTURED_IMAGE": "YES"
|
|
])
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Look for retake button (indicates preview state)
|
|
let retakeButton = app.buttons["Retake photo"]
|
|
|
|
if retakeButton.waitForExistence(timeout: 5) {
|
|
// When: Retake button exists and is tapped
|
|
XCTAssertTrue(retakeButton.isEnabled, "Retake button should be enabled")
|
|
retakeButton.tap()
|
|
|
|
// Then: Should return to camera view
|
|
let captureButton = app.buttons["Capture photo"]
|
|
XCTAssertTrue(
|
|
captureButton.waitForExistence(timeout: 5),
|
|
"Should return to camera view after retake"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Tests that "Use Photo" button is present in preview mode.
|
|
@MainActor
|
|
func testUsePhotoButtonInPreview() throws {
|
|
// Given: App with potential captured image state
|
|
app.launchWithConfiguration(mockData: true, additionalEnvironment: [
|
|
"MOCK_CAPTURED_IMAGE": "YES"
|
|
])
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Look for use photo button (indicates preview state)
|
|
let usePhotoButton = app.buttons["Use this photo"]
|
|
|
|
if usePhotoButton.waitForExistence(timeout: 5) {
|
|
// Then: Use Photo button should have correct properties
|
|
XCTAssertTrue(usePhotoButton.isEnabled, "Use Photo button should be enabled")
|
|
|
|
// Check for the prompt text
|
|
let promptText = app.staticTexts["Ready to identify this plant?"]
|
|
XCTAssertTrue(promptText.exists, "Prompt text should appear above Use Photo button")
|
|
}
|
|
}
|
|
|
|
// MARK: - Camera View State Tests
|
|
|
|
/// Tests camera view handles different permission states gracefully.
|
|
@MainActor
|
|
func testCameraViewStateHandling() throws {
|
|
// Given: App launched
|
|
app.launchWithCleanState()
|
|
|
|
// When: Camera tab is displayed
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Then: One of three states should be visible:
|
|
// 1. Permission request (not determined)
|
|
// 2. Permission denied
|
|
// 3. Camera preview with capture button
|
|
|
|
let permissionRequest = app.staticTexts["Camera Access Required"]
|
|
let permissionDenied = app.staticTexts["Camera Access Denied"]
|
|
let captureButton = app.buttons["Capture photo"]
|
|
|
|
let hasValidState = permissionRequest.waitForExistence(timeout: 3) ||
|
|
permissionDenied.waitForExistence(timeout: 2) ||
|
|
captureButton.waitForExistence(timeout: 2)
|
|
|
|
XCTAssertTrue(hasValidState, "Camera view should show a valid state")
|
|
}
|
|
|
|
/// Tests that camera controls are disabled during capture.
|
|
@MainActor
|
|
func testCameraControlsDisabledDuringCapture() throws {
|
|
// Given: App with camera access
|
|
app.launchWithConfiguration(mockData: true, additionalEnvironment: [
|
|
"MOCK_API_RESPONSE_DELAY": "3" // Slow response to observe disabled state
|
|
])
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
let captureButton = app.buttons["Capture photo"]
|
|
|
|
if captureButton.waitForExistence(timeout: 5) && captureButton.isEnabled {
|
|
// When: Capture is initiated
|
|
captureButton.tap()
|
|
|
|
// Then: During capture, controls may be disabled
|
|
// Look for capturing overlay
|
|
let capturingOverlay = app.staticTexts["Capturing..."]
|
|
if capturingOverlay.waitForExistence(timeout: 2) {
|
|
// Verify UI shows capturing state
|
|
XCTAssertTrue(capturingOverlay.exists, "Capturing indicator should be visible")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Handling Tests
|
|
|
|
/// Tests that camera errors are displayed to the user.
|
|
@MainActor
|
|
func testCameraErrorAlert() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Error alerts are shown via .alert modifier
|
|
// We verify the alert can be dismissed if it appears
|
|
let errorAlert = app.alerts["Error"]
|
|
|
|
if errorAlert.waitForExistence(timeout: 3) {
|
|
// Then: Error alert should have OK button to dismiss
|
|
let okButton = errorAlert.buttons["OK"]
|
|
XCTAssertTrue(okButton.exists, "Error alert should have OK button")
|
|
okButton.tap()
|
|
|
|
// Alert should dismiss
|
|
XCTAssertTrue(
|
|
errorAlert.waitForNonExistence(timeout: 2),
|
|
"Error alert should dismiss after tapping OK"
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation Tests
|
|
|
|
/// Tests that camera tab is the default selected tab.
|
|
@MainActor
|
|
func testCameraTabIsDefault() throws {
|
|
// Given: App freshly launched
|
|
app.launchWithCleanState()
|
|
|
|
// Then: Camera tab should be selected
|
|
let cameraTab = app.tabBars.buttons[AccessibilityID.TabBar.camera]
|
|
XCTAssertTrue(cameraTab.waitForExistence(timeout: 5), "Camera tab should exist")
|
|
XCTAssertTrue(cameraTab.isSelected, "Camera tab should be selected by default")
|
|
}
|
|
|
|
/// Tests navigation from camera to identification flow.
|
|
@MainActor
|
|
func testNavigationToIdentificationFlow() throws {
|
|
// Given: App with captured image ready
|
|
app.launchWithConfiguration(mockData: true, additionalEnvironment: [
|
|
"MOCK_CAPTURED_IMAGE": "YES"
|
|
])
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Look for use photo button
|
|
let usePhotoButton = app.buttons["Use this photo"]
|
|
|
|
if usePhotoButton.waitForExistence(timeout: 5) {
|
|
// When: Use Photo is tapped
|
|
usePhotoButton.tap()
|
|
|
|
// Then: Should navigate to identification view (full screen cover)
|
|
// The identification view might show loading or results
|
|
let identificationView = app.otherElements.matching(
|
|
NSPredicate(format: "identifier CONTAINS[c] 'identification'")
|
|
).firstMatch
|
|
|
|
// Or look for common identification view elements
|
|
let loadingText = app.staticTexts.matching(
|
|
NSPredicate(format: "label CONTAINS[c] 'identifying' OR label CONTAINS[c] 'analyzing'")
|
|
).firstMatch
|
|
|
|
let viewAppeared = identificationView.waitForExistence(timeout: 5) ||
|
|
loadingText.waitForExistence(timeout: 3)
|
|
|
|
// If mock data doesn't trigger full flow, just verify no crash
|
|
XCTAssertTrue(
|
|
viewAppeared || app.exists,
|
|
"App should handle navigation to identification"
|
|
)
|
|
}
|
|
}
|
|
}
|