- 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>
551 lines
19 KiB
Swift
551 lines
19 KiB
Swift
//
|
|
// 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"
|
|
)
|
|
}
|
|
}
|
|
}
|