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>
This commit is contained in:
Trey t
2026-02-18 10:36:54 -06:00
parent 681476a499
commit 1ae9c884c8
30 changed files with 1362 additions and 2379 deletions

View File

@@ -0,0 +1,48 @@
//
// CameraScreen.swift
// PlantGuideUITests
//
// Screen object for the Camera tab.
//
import XCTest
struct CameraScreen {
let app: XCUIApplication
// MARK: - Elements
var captureButton: XCUIElement {
app.buttons[UITestID.Camera.captureButton]
}
var permissionDeniedView: XCUIElement {
app.otherElements[UITestID.Camera.permissionDeniedView]
}
var openSettingsButton: XCUIElement {
app.buttons[UITestID.Camera.openSettingsButton]
}
var previewView: XCUIElement {
app.otherElements[UITestID.Camera.previewView]
}
// MARK: - State Checks
/// Returns `true` if any valid camera state is visible (authorized, denied, or requesting).
func hasValidState(timeout: TimeInterval = 5) -> Bool {
captureButton.waitForExistence(timeout: timeout)
|| permissionDeniedView.waitForExistence(timeout: 2)
// Fallback: look for any text that hints at camera permission
|| app.staticTexts.matching(
NSPredicate(format: "label CONTAINS[c] 'camera'")
).firstMatch.waitForExistence(timeout: 2)
}
// MARK: - Actions
func tapCapture() {
captureButton.tapWhenReady()
}
}

View File

@@ -0,0 +1,53 @@
//
// CollectionScreen.swift
// PlantGuideUITests
//
// Screen object for the Collection tab.
//
import XCTest
struct CollectionScreen {
let app: XCUIApplication
// MARK: - Elements
var navigationBar: XCUIElement { app.navigationBars["My Plants"] }
var searchField: XCUIElement { app.searchFields.firstMatch }
var viewModeToggle: XCUIElement {
app.buttons[UITestID.Collection.viewModeToggle]
}
var filterButton: XCUIElement {
app.buttons[UITestID.Collection.filterButton]
}
var emptyStateView: XCUIElement {
app.otherElements[UITestID.Collection.emptyStateView]
}
var scrollView: XCUIElement { app.scrollViews.firstMatch }
var tableView: XCUIElement { app.tables.firstMatch }
// MARK: - State Checks
@discardableResult
func waitForLoad(timeout: TimeInterval = 10) -> Bool {
navigationBar.waitForExistence(timeout: timeout)
}
var hasPlants: Bool {
scrollView.exists || tableView.exists
}
// MARK: - Actions
func search(_ text: String) {
XCTAssertTrue(searchField.waitForExistence(timeout: 5), "Search field missing")
searchField.tap()
searchField.typeText(text)
}
}

View File

@@ -0,0 +1,61 @@
//
// PlantDetailScreen.swift
// PlantGuideUITests
//
// Screen object for the Plant Detail view.
//
import XCTest
struct PlantDetailScreen {
let app: XCUIApplication
// MARK: - Elements
var detailView: XCUIElement {
app.otherElements[UITestID.PlantDetail.detailView]
}
var plantName: XCUIElement {
app.staticTexts[UITestID.PlantDetail.plantName]
}
var favoriteButton: XCUIElement {
app.buttons[UITestID.PlantDetail.favoriteButton]
}
var editButton: XCUIElement {
app.buttons[UITestID.PlantDetail.editButton]
}
var deleteButton: XCUIElement {
app.buttons[UITestID.PlantDetail.deleteButton]
}
var careSection: XCUIElement {
app.otherElements[UITestID.PlantDetail.careSection]
}
var tasksSection: XCUIElement {
app.otherElements[UITestID.PlantDetail.tasksSection]
}
/// The back button in navigation bar (leads back to Collection).
var backButton: XCUIElement {
app.navigationBars.buttons.firstMatch
}
// MARK: - State Checks
@discardableResult
func waitForLoad(timeout: TimeInterval = 5) -> Bool {
// Wait for any navigation bar to appear (title is dynamic plant name)
app.navigationBars.firstMatch.waitForExistence(timeout: timeout)
}
// MARK: - Actions
func tapBack() {
backButton.tapWhenReady()
}
}

View File

@@ -0,0 +1,44 @@
//
// SettingsScreen.swift
// PlantGuideUITests
//
// Screen object for the Settings tab.
//
import XCTest
struct SettingsScreen {
let app: XCUIApplication
// MARK: - Elements
var navigationBar: XCUIElement { app.navigationBars["Settings"] }
var notificationsToggle: XCUIElement {
app.switches[UITestID.Settings.notificationsToggle]
}
var clearCacheButton: XCUIElement {
app.buttons[UITestID.Settings.clearCacheButton]
}
var versionInfo: XCUIElement {
app.staticTexts[UITestID.Settings.versionInfo]
}
/// The settings form container SwiftUI Form renders as a table or collection view.
var formContainer: XCUIElement {
if app.tables.firstMatch.waitForExistence(timeout: 3) {
return app.tables.firstMatch
} else {
return app.collectionViews.firstMatch
}
}
// MARK: - State Checks
@discardableResult
func waitForLoad(timeout: TimeInterval = 10) -> Bool {
navigationBar.waitForExistence(timeout: timeout)
}
}

View File

@@ -0,0 +1,77 @@
//
// TabBarScreen.swift
// PlantGuideUITests
//
// Screen object for the main tab bar.
//
import XCTest
struct TabBarScreen {
let app: XCUIApplication
// MARK: - Elements
var tabBar: XCUIElement { app.tabBars.firstMatch }
var cameraTab: XCUIElement { tabBar.buttons[UITestID.TabBar.camera] }
var collectionTab: XCUIElement { tabBar.buttons[UITestID.TabBar.collection] }
var todayTab: XCUIElement { tabBar.buttons[UITestID.TabBar.today] }
var settingsTab: XCUIElement { tabBar.buttons[UITestID.TabBar.settings] }
var allTabLabels: [String] {
[UITestID.TabBar.camera,
UITestID.TabBar.collection,
UITestID.TabBar.today,
UITestID.TabBar.settings]
}
// MARK: - Actions
/// Taps a tab button using existence-based wait (not hittable tab bar buttons
/// report isHittable == false in iOS 26 simulator despite being tappable).
private func tapTab(_ tab: XCUIElement) {
XCTAssertTrue(tab.waitForExistence(timeout: 10),
"Tab '\(tab.label)' not found within 10s")
tab.tap()
}
@discardableResult
func tapCamera() -> CameraScreen {
tapTab(cameraTab)
return CameraScreen(app: app)
}
@discardableResult
func tapCollection() -> CollectionScreen {
tapTab(collectionTab)
return CollectionScreen(app: app)
}
@discardableResult
func tapToday() -> TodayScreen {
tapTab(todayTab)
return TodayScreen(app: app)
}
@discardableResult
func tapSettings() -> SettingsScreen {
tapTab(settingsTab)
return SettingsScreen(app: app)
}
// MARK: - Assertions
func assertAllTabsExist() {
XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Tab bar missing")
for label in allTabLabels {
XCTAssertTrue(tabBar.buttons[label].waitForExistence(timeout: 5),
"Tab '\(label)' missing")
}
}
func assertSelected(_ label: String) {
let tab = tabBar.buttons[label]
XCTAssertTrue(tab.waitForExistence(timeout: 5), "Tab '\(label)' not found")
XCTAssertTrue(tab.isSelected, "Tab '\(label)' not selected")
}
}

View File

@@ -0,0 +1,40 @@
//
// TodayScreen.swift
// PlantGuideUITests
//
// Screen object for the Today tab (care tasks dashboard).
//
import XCTest
struct TodayScreen {
let app: XCUIApplication
// MARK: - Elements
/// The Today view uses a dynamic greeting as navigation title.
/// We check for the nav bar existence rather than a fixed title.
var navigationBar: XCUIElement {
app.navigationBars.firstMatch
}
var todaySection: XCUIElement {
app.otherElements[UITestID.CareSchedule.todaySection]
}
var overdueSection: XCUIElement {
app.otherElements[UITestID.CareSchedule.overdueSection]
}
var emptyStateView: XCUIElement {
app.otherElements[UITestID.CareSchedule.emptyStateView]
}
// MARK: - State Checks
@discardableResult
func waitForLoad(timeout: TimeInterval = 10) -> Bool {
// The Today view has a dynamic nav title (greeting) so we just wait for any nav bar
navigationBar.waitForExistence(timeout: timeout)
}
}