Files
PlantGuide/PlantGuideUITests/CameraFlowUITests.swift
Trey t 136dfbae33 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>
2026-01-23 12:18:01 -06:00

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"
)
}
}
}