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:
369
PlantGuideUITests/CameraFlowUITests.swift
Normal file
369
PlantGuideUITests/CameraFlowUITests.swift
Normal file
@@ -0,0 +1,369 @@
|
||||
//
|
||||
// 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user