// // AccessibilityUITests.swift // PlantGuideUITests // // Created on 2026-01-21. // // UI tests for accessibility features including VoiceOver support // and Dynamic Type compatibility. // import XCTest final class AccessibilityUITests: XCTestCase { // MARK: - Properties var app: XCUIApplication! // MARK: - Setup & Teardown override func setUpWithError() throws { continueAfterFailure = false app = XCUIApplication() } override func tearDownWithError() throws { app = nil } // MARK: - VoiceOver Label Tests /// Tests that tab bar buttons have VoiceOver labels. @MainActor func testTabBarAccessibilityLabels() throws { // Given: App launched app.launchWithMockData() let tabBar = app.tabBars.firstMatch XCTAssertTrue(tabBar.waitForExistence(timeout: 5), "Tab bar should exist") // Then: Each tab should have an accessibility label let expectedLabels = ["Camera", "Collection", "Care", "Settings"] for label in expectedLabels { let tab = tabBar.buttons[label] XCTAssertTrue( tab.exists, "Tab '\(label)' should have accessibility label" ) XCTAssertFalse( tab.label.isEmpty, "Tab '\(label)' label should not be empty" ) } } /// Tests that camera capture button has VoiceOver label and hint. @MainActor func testCameraCaptureButtonAccessibility() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.camera) // When: Camera is authorized let captureButton = app.buttons["Capture photo"] if captureButton.waitForExistence(timeout: 5) { // Then: Button should have proper accessibility XCTAssertEqual( captureButton.label, "Capture photo", "Capture button should have descriptive label" ) } } /// Tests that retake button has VoiceOver label. @MainActor func testRetakeButtonAccessibility() throws { // Given: App with captured image state app.launchWithConfiguration(mockData: true, additionalEnvironment: [ "MOCK_CAPTURED_IMAGE": "YES" ]) app.navigateToTab(AccessibilityID.TabBar.camera) // When: Preview mode is active let retakeButton = app.buttons["Retake photo"] if retakeButton.waitForExistence(timeout: 5) { // Then: Button should have proper accessibility XCTAssertEqual( retakeButton.label, "Retake photo", "Retake button should have descriptive label" ) } } /// Tests that use photo button has VoiceOver label and hint. @MainActor func testUsePhotoButtonAccessibility() throws { // Given: App with captured image state app.launchWithConfiguration(mockData: true, additionalEnvironment: [ "MOCK_CAPTURED_IMAGE": "YES" ]) app.navigateToTab(AccessibilityID.TabBar.camera) // When: Preview mode is active let usePhotoButton = app.buttons["Use this photo"] if usePhotoButton.waitForExistence(timeout: 5) { // Then: Button should have proper accessibility XCTAssertEqual( usePhotoButton.label, "Use this photo", "Use photo button should have descriptive label" ) } } /// Tests that collection view mode toggle has VoiceOver label. @MainActor func testCollectionViewModeToggleAccessibility() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.collection) // Wait for collection to load let collectionTitle = app.navigationBars["My Plants"] XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load") // Then: View mode toggle should have accessibility label let viewModeButton = app.buttons.matching( NSPredicate(format: "label CONTAINS[c] 'view'") ).firstMatch if viewModeButton.waitForExistence(timeout: 3) { XCTAssertFalse( viewModeButton.label.isEmpty, "View mode button should have accessibility label" ) } } /// Tests that filter button has VoiceOver label. @MainActor func testFilterButtonAccessibility() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.collection) // Wait for collection to load let collectionTitle = app.navigationBars["My Plants"] XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load") // Then: Filter button should have accessibility label let filterButton = app.buttons["Filter plants"] XCTAssertTrue( filterButton.waitForExistence(timeout: 3), "Filter button should exist with accessibility label" ) XCTAssertEqual( filterButton.label, "Filter plants", "Filter button should have descriptive label" ) } /// Tests that search field has accessibility. @MainActor func testSearchFieldAccessibility() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.collection) // Then: Search field should be accessible let searchField = app.searchFields.firstMatch XCTAssertTrue( searchField.waitForExistence(timeout: 5), "Search field should be accessible" ) } /// Tests that plant options menu has accessibility label. @MainActor func testPlantOptionsMenuAccessibility() throws { // Given: App launched and navigated to plant detail app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.collection) let collectionTitle = app.navigationBars["My Plants"] XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load") // Navigate to plant detail let scrollView = app.scrollViews.firstMatch if scrollView.waitForExistence(timeout: 3) { let plantCell = scrollView.buttons.firstMatch.exists ? scrollView.buttons.firstMatch : scrollView.otherElements.firstMatch if plantCell.waitForExistence(timeout: 3) { plantCell.tap() // Wait for detail to load if app.navigationBars.buttons["My Plants"].waitForExistence(timeout: 5) { // Then: Options menu should have accessibility label let optionsButton = app.buttons["Plant options"] if optionsButton.waitForExistence(timeout: 3) { XCTAssertEqual( optionsButton.label, "Plant options", "Options button should have accessibility label" ) } } } } } /// Tests that care schedule filter has accessibility. @MainActor func testCareScheduleFilterAccessibility() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.care) // Wait for care schedule to load let careTitle = app.navigationBars["Care Schedule"] XCTAssertTrue(careTitle.waitForExistence(timeout: 5), "Care schedule should load") // Then: Filter button in toolbar should be accessible let filterButton = app.buttons.matching( NSPredicate(format: "identifier CONTAINS[c] 'filter' OR label CONTAINS[c] 'filter'") ).firstMatch // The care schedule uses a Menu for filtering // Just verify the toolbar area is accessible XCTAssertTrue(careTitle.exists, "Care schedule should be accessible") } // MARK: - Dynamic Type Tests /// Tests that app doesn't crash with extra large Dynamic Type. @MainActor func testAppWithExtraLargeDynamicType() throws { // Given: App launched with accessibility settings // Note: We can't programmatically change Dynamic Type in UI tests, // but we can verify the app handles different content sizes app.launchWithConfiguration( mockData: true, additionalEnvironment: [ // Environment variable to simulate large text preference "UIPreferredContentSizeCategoryName": "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge" ] ) // When: Navigate through the app let tabBar = app.tabBars.firstMatch XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Tab bar should exist") // Navigate to each tab to verify no crashes app.navigateToTab(AccessibilityID.TabBar.collection) let collectionTitle = app.navigationBars["My Plants"] XCTAssertTrue( collectionTitle.waitForExistence(timeout: 5), "Collection should load without crashing" ) app.navigateToTab(AccessibilityID.TabBar.care) let careTitle = app.navigationBars["Care Schedule"] XCTAssertTrue( careTitle.waitForExistence(timeout: 5), "Care schedule should load without crashing" ) app.navigateToTab(AccessibilityID.TabBar.settings) let settingsTitle = app.navigationBars["Settings"] XCTAssertTrue( settingsTitle.waitForExistence(timeout: 5), "Settings should load without crashing" ) // Then: App should not crash and remain functional XCTAssertTrue(app.exists, "App should not crash with large Dynamic Type") } /// Tests that collection view adapts to larger text sizes. @MainActor func testCollectionViewWithLargeText() throws { // Given: App launched app.launchWithConfiguration( mockData: true, additionalEnvironment: [ "UIPreferredContentSizeCategoryName": "UICTContentSizeCategoryAccessibilityLarge" ] ) // When: Navigate to Collection app.navigateToTab(AccessibilityID.TabBar.collection) // Then: View should still be scrollable and functional let scrollView = app.scrollViews.firstMatch let tableView = app.tables.firstMatch let hasScrollableContent = scrollView.waitForExistence(timeout: 5) || tableView.waitForExistence(timeout: 3) XCTAssertTrue( hasScrollableContent || app.navigationBars["My Plants"].exists, "Collection should be functional with large text" ) } /// Tests that care schedule view handles large text without crashing. @MainActor func testCareScheduleWithLargeText() throws { // Given: App launched with large text setting app.launchWithConfiguration( mockData: true, additionalEnvironment: [ "UIPreferredContentSizeCategoryName": "UICTContentSizeCategoryAccessibilityExtraLarge" ] ) // When: Navigate to Care Schedule app.navigateToTab(AccessibilityID.TabBar.care) // Then: View should load without crashing let careTitle = app.navigationBars["Care Schedule"] XCTAssertTrue( careTitle.waitForExistence(timeout: 5), "Care schedule should handle large text" ) // Verify list is accessible let taskList = app.tables.firstMatch let emptyState = app.staticTexts["No Tasks Scheduled"] let viewLoaded = taskList.waitForExistence(timeout: 3) || emptyState.waitForExistence(timeout: 2) XCTAssertTrue( viewLoaded || careTitle.exists, "Care schedule content should be visible" ) } // MARK: - Accessibility Element Tests /// Tests that interactive elements are accessible. @MainActor func testInteractiveElementsAreAccessible() throws { // Given: App launched app.launchWithMockData() // When: Check various interactive elements across views // Collection view app.navigateToTab(AccessibilityID.TabBar.collection) let searchField = app.searchFields.firstMatch XCTAssertTrue( searchField.waitForExistence(timeout: 5), "Search field should be accessible" ) // Settings view app.navigateToTab(AccessibilityID.TabBar.settings) let settingsTitle = app.navigationBars["Settings"] XCTAssertTrue( settingsTitle.waitForExistence(timeout: 5), "Settings should be accessible" ) // Camera view app.navigateToTab(AccessibilityID.TabBar.camera) // Either permission view or camera controls should be accessible let hasAccessibleContent = app.staticTexts["Camera Access Required"].exists || app.staticTexts["Camera Access Denied"].exists || app.buttons["Capture photo"].exists XCTAssertTrue( hasAccessibleContent, "Camera view should have accessible content" ) } /// Tests that images have accessibility labels where appropriate. @MainActor func testImageAccessibility() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.collection) // Navigate to plant detail let scrollView = app.scrollViews.firstMatch if scrollView.waitForExistence(timeout: 3) { let plantCell = scrollView.buttons.firstMatch.exists ? scrollView.buttons.firstMatch : scrollView.otherElements.firstMatch if plantCell.waitForExistence(timeout: 3) { plantCell.tap() // Wait for detail to load if app.navigationBars.buttons["My Plants"].waitForExistence(timeout: 5) { // Then: Decorative images shouldn't interfere with VoiceOver // and important images should be labeled // Check for any images let images = app.images XCTAssertTrue( images.count >= 0, "Images should exist without crashing accessibility" ) } } } } // MARK: - Trait Tests /// Tests that headers are properly identified for VoiceOver. @MainActor func testHeaderTraitsInCareSchedule() throws { // Given: App launched app.launchWithMockData() app.navigateToTab(AccessibilityID.TabBar.care) let careTitle = app.navigationBars["Care Schedule"] XCTAssertTrue(careTitle.waitForExistence(timeout: 5), "Care schedule should load") // Then: Section headers should be present // The CareScheduleView has sections like "Today", "Overdue", etc. let todaySection = app.staticTexts["Today"] let overdueSection = app.staticTexts["Overdue"] // These may or may not exist depending on data // Just verify the view is functional XCTAssertTrue( careTitle.exists, "Care schedule should have accessible headers" ) } /// Tests that navigation titles are accessible. @MainActor func testNavigationTitlesAccessibility() throws { // Given: App launched app.launchWithMockData() // Then: Each view should have accessible navigation title app.navigateToTab(AccessibilityID.TabBar.collection) XCTAssertTrue( app.navigationBars["My Plants"].waitForExistence(timeout: 5), "Collection title should be accessible" ) app.navigateToTab(AccessibilityID.TabBar.care) XCTAssertTrue( app.navigationBars["Care Schedule"].waitForExistence(timeout: 5), "Care schedule title should be accessible" ) app.navigateToTab(AccessibilityID.TabBar.settings) XCTAssertTrue( app.navigationBars["Settings"].waitForExistence(timeout: 5), "Settings title should be accessible" ) } // MARK: - Button State Tests /// Tests that disabled buttons are properly announced. @MainActor func testDisabledButtonAccessibility() throws { // Given: App launched with camera view app.launchWithConfiguration(mockData: true, additionalEnvironment: [ "MOCK_API_RESPONSE_DELAY": "5" // Slow response to see disabled state ]) app.navigateToTab(AccessibilityID.TabBar.camera) // When: Capture button might be disabled during capture let captureButton = app.buttons["Capture photo"] if captureButton.waitForExistence(timeout: 5) { // Trigger capture if captureButton.isEnabled { captureButton.tap() // During capture, button may be disabled // Just verify no crash occurs XCTAssertTrue(app.exists, "App should handle disabled state accessibly") } } } // MARK: - Empty State Tests /// Tests that empty states are accessible. @MainActor func testEmptyStatesAccessibility() throws { // Given: App launched with clean state (no data) app.launchWithCleanState() // When: Navigate to Collection app.navigateToTab(AccessibilityID.TabBar.collection) // Then: Empty state message should be accessible let emptyMessage = app.staticTexts["Your plant collection is empty"] if emptyMessage.waitForExistence(timeout: 5) { XCTAssertTrue( emptyMessage.exists, "Empty state message should be accessible" ) // Help text should also be accessible let helpText = app.staticTexts["Identify plants to add them to your collection"] XCTAssertTrue( helpText.exists, "Empty state help text should be accessible" ) } } /// Tests that care schedule empty state is accessible. @MainActor func testCareScheduleEmptyStateAccessibility() throws { // Given: App launched with clean state app.launchWithCleanState() // When: Navigate to Care Schedule app.navigateToTab(AccessibilityID.TabBar.care) // Then: Empty state should be accessible let emptyState = app.staticTexts["No Tasks Scheduled"] if emptyState.waitForExistence(timeout: 5) { XCTAssertTrue( emptyState.exists, "Care schedule empty state should be accessible" ) } } }