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:
318
PlantGuideUITests/Helpers/XCUIApplication+Launch.swift
Normal file
318
PlantGuideUITests/Helpers/XCUIApplication+Launch.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
//
|
||||
// XCUIApplication+Launch.swift
|
||||
// PlantGuideUITests
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
// MARK: - Launch Configuration Keys
|
||||
|
||||
/// Keys used for launch argument and environment configuration.
|
||||
enum LaunchConfigKey {
|
||||
/// Launch arguments
|
||||
static let uiTesting = "-UITesting"
|
||||
static let cleanState = "-CleanState"
|
||||
static let mockData = "-MockData"
|
||||
static let offlineMode = "-OfflineMode"
|
||||
static let skipOnboarding = "-SkipOnboarding"
|
||||
|
||||
/// Environment keys
|
||||
static let isUITesting = "IS_UI_TESTING"
|
||||
static let useMockData = "USE_MOCK_DATA"
|
||||
static let isOfflineMode = "IS_OFFLINE_MODE"
|
||||
static let mockAPIResponseDelay = "MOCK_API_RESPONSE_DELAY"
|
||||
}
|
||||
|
||||
// MARK: - XCUIApplication Launch Extensions
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
// MARK: - Launch Configurations
|
||||
|
||||
/// Launches the app with a clean state, resetting all user data and preferences.
|
||||
///
|
||||
/// Use this for tests that need a fresh start without any prior data.
|
||||
/// This clears:
|
||||
/// - All saved plants in the collection
|
||||
/// - Care schedules and tasks
|
||||
/// - User preferences and settings
|
||||
/// - Cached images and API responses
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// let app = XCUIApplication()
|
||||
/// app.launchWithCleanState()
|
||||
/// ```
|
||||
func launchWithCleanState() {
|
||||
launchArguments.append(contentsOf: [
|
||||
LaunchConfigKey.uiTesting,
|
||||
LaunchConfigKey.cleanState,
|
||||
LaunchConfigKey.skipOnboarding
|
||||
])
|
||||
|
||||
launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
|
||||
launch()
|
||||
}
|
||||
|
||||
/// Launches the app with pre-populated mock data for testing.
|
||||
///
|
||||
/// Use this for tests that need existing plants, care schedules,
|
||||
/// or other data to be present. The mock data includes:
|
||||
/// - Sample plants with various characteristics
|
||||
/// - Active care schedules with upcoming and overdue tasks
|
||||
/// - Saved user preferences
|
||||
///
|
||||
/// - Parameter count: Number of mock plants to generate. Default is 5.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// let app = XCUIApplication()
|
||||
/// app.launchWithMockData()
|
||||
/// ```
|
||||
func launchWithMockData(plantCount: Int = 5) {
|
||||
launchArguments.append(contentsOf: [
|
||||
LaunchConfigKey.uiTesting,
|
||||
LaunchConfigKey.mockData,
|
||||
LaunchConfigKey.skipOnboarding
|
||||
])
|
||||
|
||||
launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
launchEnvironment[LaunchConfigKey.useMockData] = "YES"
|
||||
launchEnvironment["MOCK_PLANT_COUNT"] = String(plantCount)
|
||||
|
||||
launch()
|
||||
}
|
||||
|
||||
/// Launches the app in offline mode to simulate network unavailability.
|
||||
///
|
||||
/// Use this for tests that verify offline behavior:
|
||||
/// - Cached data is displayed correctly
|
||||
/// - Appropriate offline indicators appear
|
||||
/// - Network-dependent features show proper fallback UI
|
||||
/// - On-device ML identification still works
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// let app = XCUIApplication()
|
||||
/// app.launchOffline()
|
||||
/// ```
|
||||
func launchOffline() {
|
||||
launchArguments.append(contentsOf: [
|
||||
LaunchConfigKey.uiTesting,
|
||||
LaunchConfigKey.offlineMode,
|
||||
LaunchConfigKey.skipOnboarding
|
||||
])
|
||||
|
||||
launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
launchEnvironment[LaunchConfigKey.isOfflineMode] = "YES"
|
||||
|
||||
launch()
|
||||
}
|
||||
|
||||
/// Launches the app with custom configuration.
|
||||
///
|
||||
/// Use this for tests requiring specific combinations of settings.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cleanState: Whether to reset all app data
|
||||
/// - mockData: Whether to use pre-populated test data
|
||||
/// - offline: Whether to simulate offline mode
|
||||
/// - apiDelay: Simulated API response delay in seconds (0 = instant)
|
||||
/// - additionalArguments: Any extra launch arguments needed
|
||||
/// - additionalEnvironment: Any extra environment variables needed
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// let app = XCUIApplication()
|
||||
/// app.launchWithConfiguration(
|
||||
/// mockData: true,
|
||||
/// apiDelay: 2.0 // Slow API to test loading states
|
||||
/// )
|
||||
/// ```
|
||||
func launchWithConfiguration(
|
||||
cleanState: Bool = false,
|
||||
mockData: Bool = false,
|
||||
offline: Bool = false,
|
||||
apiDelay: TimeInterval = 0,
|
||||
additionalArguments: [String] = [],
|
||||
additionalEnvironment: [String: String] = [:]
|
||||
) {
|
||||
// Base arguments
|
||||
launchArguments.append(LaunchConfigKey.uiTesting)
|
||||
launchArguments.append(LaunchConfigKey.skipOnboarding)
|
||||
|
||||
// Optional arguments
|
||||
if cleanState {
|
||||
launchArguments.append(LaunchConfigKey.cleanState)
|
||||
}
|
||||
if mockData {
|
||||
launchArguments.append(LaunchConfigKey.mockData)
|
||||
}
|
||||
if offline {
|
||||
launchArguments.append(LaunchConfigKey.offlineMode)
|
||||
}
|
||||
|
||||
// Additional arguments
|
||||
launchArguments.append(contentsOf: additionalArguments)
|
||||
|
||||
// Environment variables
|
||||
launchEnvironment[LaunchConfigKey.isUITesting] = "YES"
|
||||
launchEnvironment[LaunchConfigKey.useMockData] = mockData ? "YES" : "NO"
|
||||
launchEnvironment[LaunchConfigKey.isOfflineMode] = offline ? "YES" : "NO"
|
||||
|
||||
if apiDelay > 0 {
|
||||
launchEnvironment[LaunchConfigKey.mockAPIResponseDelay] = String(apiDelay)
|
||||
}
|
||||
|
||||
// Additional environment
|
||||
for (key, value) in additionalEnvironment {
|
||||
launchEnvironment[key] = value
|
||||
}
|
||||
|
||||
launch()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Element Waiting Extensions
|
||||
|
||||
extension XCUIElement {
|
||||
|
||||
/// Waits for the element to exist with a configurable timeout.
|
||||
///
|
||||
/// - Parameter timeout: Maximum time to wait in seconds. Default is 5 seconds.
|
||||
/// - Returns: True if element exists within timeout, false otherwise.
|
||||
@discardableResult
|
||||
func waitForExistence(timeout: TimeInterval = 5) -> Bool {
|
||||
return self.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
/// Waits for the element to exist and be hittable.
|
||||
///
|
||||
/// - Parameter timeout: Maximum time to wait in seconds. Default is 5 seconds.
|
||||
/// - Returns: True if element is hittable within timeout, false otherwise.
|
||||
@discardableResult
|
||||
func waitForHittable(timeout: TimeInterval = 5) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == true AND isHittable == true")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
return result == .completed
|
||||
}
|
||||
|
||||
/// Waits for the element to not exist (disappear).
|
||||
///
|
||||
/// - Parameter timeout: Maximum time to wait in seconds. Default is 5 seconds.
|
||||
/// - Returns: True if element no longer exists within timeout, false otherwise.
|
||||
@discardableResult
|
||||
func waitForNonExistence(timeout: TimeInterval = 5) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
return result == .completed
|
||||
}
|
||||
|
||||
/// Waits for the element's value to match the expected value.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expectedValue: The value to wait for.
|
||||
/// - timeout: Maximum time to wait in seconds. Default is 5 seconds.
|
||||
/// - Returns: True if element's value matches within timeout, false otherwise.
|
||||
@discardableResult
|
||||
func waitForValue(_ expectedValue: String, timeout: TimeInterval = 5) -> Bool {
|
||||
let predicate = NSPredicate(format: "value == %@", expectedValue)
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
|
||||
return result == .completed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App State Verification Extensions
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
/// Verifies the app launched successfully by checking for the tab bar.
|
||||
///
|
||||
/// - Parameter timeout: Maximum time to wait for the tab bar. Default is 10 seconds.
|
||||
/// - Returns: True if tab bar appears, false otherwise.
|
||||
@discardableResult
|
||||
func verifyLaunched(timeout: TimeInterval = 10) -> Bool {
|
||||
let tabBar = self.tabBars.firstMatch
|
||||
return tabBar.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
/// Navigates to a specific tab by tapping on it.
|
||||
///
|
||||
/// - Parameter tabName: The accessible label of the tab (e.g., "Camera", "Collection").
|
||||
func navigateToTab(_ tabName: String) {
|
||||
let tabButton = self.tabBars.buttons[tabName]
|
||||
if tabButton.waitForExistence(timeout: 5) {
|
||||
tabButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessibility Identifier Constants
|
||||
|
||||
/// Accessibility identifiers used throughout the app.
|
||||
/// Use these constants in tests to locate elements reliably.
|
||||
enum AccessibilityID {
|
||||
// MARK: - Tab Bar
|
||||
enum TabBar {
|
||||
static let camera = "Camera"
|
||||
static let collection = "Collection"
|
||||
static let care = "Care"
|
||||
static let settings = "Settings"
|
||||
}
|
||||
|
||||
// MARK: - Camera View
|
||||
enum Camera {
|
||||
static let captureButton = "captureButton"
|
||||
static let retakeButton = "retakeButton"
|
||||
static let usePhotoButton = "usePhotoButton"
|
||||
static let permissionRequestView = "permissionRequestView"
|
||||
static let permissionDeniedView = "permissionDeniedView"
|
||||
static let cameraPreview = "cameraPreview"
|
||||
static let capturedImagePreview = "capturedImagePreview"
|
||||
}
|
||||
|
||||
// MARK: - Collection View
|
||||
enum Collection {
|
||||
static let gridView = "collectionGridView"
|
||||
static let listView = "collectionListView"
|
||||
static let searchField = "collectionSearchField"
|
||||
static let filterButton = "filterButton"
|
||||
static let viewModeToggle = "viewModeToggle"
|
||||
static let emptyState = "collectionEmptyState"
|
||||
static let plantCell = "plantCell"
|
||||
static let favoriteButton = "favoriteButton"
|
||||
static let deleteButton = "deleteButton"
|
||||
}
|
||||
|
||||
// MARK: - Settings View
|
||||
enum Settings {
|
||||
static let offlineModeToggle = "offlineModeToggle"
|
||||
static let clearCacheButton = "clearCacheButton"
|
||||
static let apiStatusSection = "apiStatusSection"
|
||||
static let versionLabel = "versionLabel"
|
||||
static let confirmClearCacheButton = "confirmClearCacheButton"
|
||||
}
|
||||
|
||||
// MARK: - Plant Detail View
|
||||
enum PlantDetail {
|
||||
static let headerSection = "plantHeaderSection"
|
||||
static let careInfoSection = "careInformationSection"
|
||||
static let upcomingTasksSection = "upcomingTasksSection"
|
||||
static let careScheduleButton = "careScheduleButton"
|
||||
}
|
||||
|
||||
// MARK: - Care Schedule View
|
||||
enum CareSchedule {
|
||||
static let taskList = "careTaskList"
|
||||
static let overdueSection = "overdueTasksSection"
|
||||
static let todaySection = "todayTasksSection"
|
||||
static let emptyState = "careEmptyState"
|
||||
static let filterButton = "careFilterButton"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user