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

@@ -264,22 +264,19 @@ final class IdentifyPlantOnDeviceUseCaseTests: XCTestCase {
}
}
// Configure mock to throw
let service = mockClassificationService!
Task {
service.shouldThrowOnClassify = true
service.errorToThrow = PlantClassificationError.modelLoadFailed
// Configure mock to throw via actor-isolated method
await mockClassificationService.setThrowBehavior(
shouldThrow: true,
error: PlantClassificationError.modelLoadFailed
)
// Verify the error propagates
do {
_ = try await sut.execute(image: testImage)
XCTFail("Expected classification error to be thrown")
} catch {
XCTAssertNotNil(error)
}
// Give time for the configuration to apply
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
// Note: Due to actor isolation, we need to check this differently
// For now, verify the normal path works
await mockClassificationService.configureDefaultPredictions()
let result = try? await sut.execute(image: testImage)
XCTAssertNotNil(result)
}
// MARK: - Error Description Tests

View File

@@ -15,7 +15,7 @@ import Network
/// Mock implementation of NetworkMonitor for testing
/// Note: This creates a testable version that doesn't actually monitor network state
@Observable
final class MockNetworkMonitor: @unchecked Sendable {
final class MockNetworkMonitor: NetworkMonitorProtocol, @unchecked Sendable {
// MARK: - Properties

View File

@@ -131,6 +131,14 @@ final actor MockNotificationService: NotificationServiceProtocol {
removeAllDeliveredNotificationsCallCount += 1
}
func schedulePhotoReminder(for plantID: UUID, plantName: String, interval: PhotoReminderInterval) async throws {
// no-op for tests
}
func cancelPhotoReminder(for plantID: UUID) async {
// no-op for tests
}
// MARK: - Helper Methods
/// Resets all state for clean test setup

View File

@@ -87,6 +87,12 @@ final actor MockPlantClassificationService: PlantClassificationServiceProtocol {
]
}
/// Configures throw behavior from outside the actor
func setThrowBehavior(shouldThrow: Bool, error: Error) {
shouldThrowOnClassify = shouldThrow
errorToThrow = error
}
/// Configures low confidence predictions for testing fallback behavior
func configureLowConfidencePredictions() {
predictionsToReturn = [