Harden test harness and UI suite
This commit is contained in:
@@ -59,6 +59,33 @@ class BaseUITestCase: XCTestCase {
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
}
|
||||
|
||||
/// Polls until the condition becomes true or the timeout expires.
|
||||
@discardableResult
|
||||
func waitUntil(
|
||||
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
|
||||
pollInterval: TimeInterval = 0.2,
|
||||
_ message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line,
|
||||
condition: @escaping () -> Bool
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
let interval = min(pollInterval, max(0.01, remaining))
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(interval))
|
||||
}
|
||||
|
||||
let success = condition()
|
||||
XCTAssertTrue(success, message ?? "Condition was not met within \(timeout)s", file: file, line: line)
|
||||
return success
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wait Helpers
|
||||
|
||||
@@ -45,10 +45,12 @@ final class AppLaunchTests: BaseUITestCase {
|
||||
|
||||
// Background the app
|
||||
XCUIDevice.shared.press(.home)
|
||||
sleep(2)
|
||||
|
||||
// Foreground
|
||||
app.activate()
|
||||
waitUntil(timeout: BaseUITestCase.longTimeout, "App should return to the foreground") {
|
||||
self.app.state == .runningForeground
|
||||
}
|
||||
|
||||
// Assert: Home still loaded, no re-bootstrap
|
||||
XCTAssertTrue(
|
||||
|
||||
@@ -85,12 +85,10 @@ final class HomeTests: BaseUITestCase {
|
||||
// Tap refresh and verify no crash
|
||||
refreshButton.tap()
|
||||
|
||||
// Wait briefly for reload
|
||||
sleep(2)
|
||||
|
||||
// Featured section should still exist after refresh
|
||||
XCTAssertTrue(section.exists,
|
||||
"Featured trips section should remain after refresh")
|
||||
waitUntil(timeout: BaseUITestCase.longTimeout, "Featured trips section should remain after refresh") {
|
||||
section.exists && refreshButton.exists
|
||||
}
|
||||
|
||||
captureScreenshot(named: "F015-FeaturedTripsRefresh")
|
||||
}
|
||||
@@ -307,14 +305,17 @@ final class HomeTests: BaseUITestCase {
|
||||
home.waitForLoad()
|
||||
home.switchToTab(home.myTripsTab)
|
||||
|
||||
// Wait briefly for My Trips content to load
|
||||
sleep(1)
|
||||
let myTrips = MyTripsScreen(app: app)
|
||||
waitUntil(timeout: BaseUITestCase.longTimeout, "My Trips should load before refreshing") {
|
||||
myTrips.emptyState.exists || myTrips.tripCard(0).exists || self.app.staticTexts["Group Polls"].exists
|
||||
}
|
||||
|
||||
// Pull down to refresh
|
||||
app.swipeDown(velocity: .slow)
|
||||
|
||||
// Wait for any refresh to complete
|
||||
sleep(2)
|
||||
waitUntil(timeout: BaseUITestCase.longTimeout, "My Trips should still be loaded after pull to refresh") {
|
||||
myTrips.emptyState.exists || myTrips.tripCard(0).exists || self.app.staticTexts["Group Polls"].exists
|
||||
}
|
||||
|
||||
// Verify the tab is still functional (no crash)
|
||||
let groupPolls = app.staticTexts["Group Polls"]
|
||||
|
||||
@@ -206,22 +206,14 @@ final class ProgressTests: BaseUITestCase {
|
||||
visitSheet.tapSave()
|
||||
visitSheet.navigationBar.waitForNonExistence(timeout: BaseUITestCase.defaultTimeout)
|
||||
|
||||
// Wait for data to reload
|
||||
sleep(2)
|
||||
|
||||
// Progress should have updated — verify the progress circle label changed
|
||||
let updatedCircle = app.descendants(matching: .any).matching(NSPredicate(
|
||||
format: "label CONTAINS 'stadiums visited'"
|
||||
)).firstMatch
|
||||
|
||||
XCTAssertTrue(updatedCircle.waitForExistence(timeout: BaseUITestCase.longTimeout),
|
||||
"Progress circle should exist after adding a visit")
|
||||
|
||||
// If we had an initial label, verify it changed
|
||||
if !initialLabel.isEmpty {
|
||||
// The new label should have a higher visited count
|
||||
XCTAssertNotEqual(updatedCircle.label, initialLabel,
|
||||
"Progress label should update after adding a visit")
|
||||
waitUntil(timeout: BaseUITestCase.longTimeout, "Progress label should update after adding a visit") {
|
||||
guard updatedCircle.exists else { return false }
|
||||
return initialLabel.isEmpty || updatedCircle.label != initialLabel
|
||||
}
|
||||
|
||||
captureScreenshot(named: "F099-ProgressPercentageUpdated")
|
||||
|
||||
@@ -127,9 +127,10 @@ final class ScheduleTests: BaseUITestCase {
|
||||
"Search field should exist")
|
||||
searchField.tap()
|
||||
searchField.typeText("Yankees")
|
||||
|
||||
// Wait for results to filter
|
||||
sleep(1)
|
||||
XCTAssertTrue(
|
||||
((searchField.value as? String) ?? "").contains("Yankees"),
|
||||
"Search field should contain the typed team name"
|
||||
)
|
||||
|
||||
captureScreenshot(named: "F089-SearchByTeam")
|
||||
}
|
||||
@@ -150,9 +151,10 @@ final class ScheduleTests: BaseUITestCase {
|
||||
"Search field should exist")
|
||||
searchField.tap()
|
||||
searchField.typeText("Wrigley")
|
||||
|
||||
// Wait for results to filter
|
||||
sleep(1)
|
||||
XCTAssertTrue(
|
||||
((searchField.value as? String) ?? "").contains("Wrigley"),
|
||||
"Search field should contain the typed venue name"
|
||||
)
|
||||
|
||||
captureScreenshot(named: "F090-SearchByVenue")
|
||||
}
|
||||
@@ -174,19 +176,15 @@ final class ScheduleTests: BaseUITestCase {
|
||||
searchField.tap()
|
||||
searchField.typeText("ZZZZNONEXISTENTTEAMZZZZ")
|
||||
|
||||
// Wait for empty state
|
||||
sleep(1)
|
||||
|
||||
// Empty state or "no results" text should appear
|
||||
let emptyState = schedule.emptyState
|
||||
let noResults = app.staticTexts.matching(NSPredicate(
|
||||
format: "label CONTAINS[c] 'no' AND label CONTAINS[c] 'game'"
|
||||
)).firstMatch
|
||||
|
||||
let hasEmptyIndicator = emptyState.waitForExistence(timeout: BaseUITestCase.shortTimeout)
|
||||
|| noResults.waitForExistence(timeout: BaseUITestCase.shortTimeout)
|
||||
XCTAssertTrue(hasEmptyIndicator,
|
||||
"Empty state should appear when no games match search")
|
||||
waitUntil(timeout: BaseUITestCase.shortTimeout, "Empty state should appear when no games match search") {
|
||||
emptyState.exists || noResults.exists
|
||||
}
|
||||
|
||||
captureScreenshot(named: "F092-ScheduleEmptyState")
|
||||
}
|
||||
|
||||
@@ -162,10 +162,10 @@ final class SettingsTests: BaseUITestCase {
|
||||
let switchCoord = toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
|
||||
switchCoord.tap()
|
||||
|
||||
// Small wait for the toggle animation to complete
|
||||
sleep(1)
|
||||
|
||||
// Value should have changed
|
||||
waitUntil(timeout: BaseUITestCase.shortTimeout, "Toggle value should change after tapping the switch") {
|
||||
(toggle.value as? String) != initialValue
|
||||
}
|
||||
let newValue = toggle.value as? String
|
||||
XCTAssertNotEqual(initialValue, newValue,
|
||||
"Toggle value should change after tap (was '\(initialValue ?? "nil")', now '\(newValue ?? "nil")')")
|
||||
|
||||
@@ -157,8 +157,11 @@ final class TripOptionsTests: BaseUITestCase {
|
||||
"'5' cities filter button should exist")
|
||||
fiveCitiesButton.tap()
|
||||
|
||||
// Results should update; verify no crash
|
||||
sleep(1)
|
||||
let firstTrip = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'tripOptions.trip.'")).firstMatch
|
||||
XCTAssertTrue(
|
||||
firstTrip.waitForExistence(timeout: BaseUITestCase.shortTimeout),
|
||||
"Trip results should remain visible after applying the cities filter"
|
||||
)
|
||||
|
||||
captureScreenshot(named: "F057-CitiesFilter-5")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user