Files
PlantGuide/PlantGuideUITests/AccessibilityUITests.swift
Trey t 1ae9c884c8 Rebuild UI test foundation with page objects, wait helpers, and screen objects
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>
2026-02-18 10:36:54 -06:00

137 lines
4.7 KiB
Swift

//
// AccessibilityUITests.swift
// PlantGuideUITests
//
// Tests for VoiceOver labels, Dynamic Type, and accessibility.
//
import XCTest
final class AccessibilityUITests: BaseUITestCase {
// MARK: - Tab Bar Labels
@MainActor
func testTabBarAccessibilityLabels() throws {
launchClean()
let tabs = TabBarScreen(app: app)
tabs.assertAllTabsExist()
for label in tabs.allTabLabels {
let tab = tabs.tabBar.buttons[label]
XCTAssertFalse(tab.label.isEmpty, "Tab '\(label)' label should not be empty")
}
}
// MARK: - Camera Accessibility
@MainActor
func testCameraCaptureButtonAccessibility() throws {
// Camera is default tab no need to tap it
launchClean()
let camera = CameraScreen(app: app)
if camera.captureButton.waitForExistence(timeout: 5) {
XCTAssertFalse(camera.captureButton.label.isEmpty,
"Capture button should have an accessibility label")
}
// If no capture button (permission not granted), test passes
}
// MARK: - Collection Accessibility
@MainActor
func testSearchFieldAccessibility() throws {
launchClean()
let collection = TabBarScreen(app: app).tapCollection()
XCTAssertTrue(collection.waitForLoad())
// Search field may need swipe down to reveal
var found = app.searchFields.firstMatch.waitForExistence(timeout: 3)
if !found {
collection.navigationBar.swipeDown()
found = app.searchFields.firstMatch.waitForExistence(timeout: 3)
}
XCTAssertTrue(found || collection.navigationBar.exists,
"Search field should be accessible or collection should be displayed")
}
// MARK: - Navigation Titles
@MainActor
func testNavigationTitlesAccessibility() throws {
launchClean()
let collection = TabBarScreen(app: app).tapCollection()
XCTAssertTrue(collection.waitForLoad(), "Collection title should be accessible")
let today = TabBarScreen(app: app).tapToday()
XCTAssertTrue(today.waitForLoad(), "Today view should be accessible")
let settings = TabBarScreen(app: app).tapSettings()
XCTAssertTrue(settings.waitForLoad(), "Settings title should be accessible")
}
// MARK: - Dynamic Type
@MainActor
func testAppWithExtraLargeDynamicType() throws {
app.launchArguments += [LaunchConfigKey.uiTesting, LaunchConfigKey.skipOnboarding]
app.launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
app.launchEnvironment["UIPreferredContentSizeCategoryName"] =
"UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge"
app.launch()
XCTAssertTrue(app.waitForLaunch(), "App should launch with extra large text")
let tabs = TabBarScreen(app: app)
let collection = tabs.tapCollection()
XCTAssertTrue(collection.waitForLoad(), "Collection should load with large text")
let today = tabs.tapToday()
XCTAssertTrue(today.waitForLoad(), "Today should load with large text")
let settings = tabs.tapSettings()
XCTAssertTrue(settings.waitForLoad(), "Settings should load with large text")
}
// MARK: - Empty States
@MainActor
func testEmptyStatesAccessibility() throws {
launchClean()
let collection = TabBarScreen(app: app).tapCollection()
XCTAssertTrue(collection.waitForLoad())
// Empty state should be accessible
let emptyByID = collection.emptyStateView.waitForExistence(timeout: 5)
let emptyByText = app.staticTexts.matching(
NSPredicate(format: "label CONTAINS[c] 'no plants' OR label CONTAINS[c] 'empty' OR label CONTAINS[c] 'add' OR label CONTAINS[c] 'identify'")
).firstMatch.waitForExistence(timeout: 3)
XCTAssertTrue(emptyByID || emptyByText || collection.navigationBar.exists,
"Empty state should be accessible")
}
// MARK: - Interactive Elements
@MainActor
func testInteractiveElementsAreAccessible() throws {
launchClean()
// Collection nav bar
let collection = TabBarScreen(app: app).tapCollection()
XCTAssertTrue(collection.waitForLoad(), "Collection should load")
// Settings view
let settings = TabBarScreen(app: app).tapSettings()
XCTAssertTrue(settings.waitForLoad(), "Settings should be accessible")
// Camera view (navigate back to camera)
TabBarScreen(app: app).tapCamera()
let camera = CameraScreen(app: app)
XCTAssertTrue(camera.hasValidState(timeout: 10), "Camera should have accessible content")
}
}