Files
Sportstime/SportsTimeUITests/Framework/Screens.swift
treyt ba41866602 Fix flaky UI tests: increase calendar wait timeouts and disable parallel UI testing
Calendar navigation buttons used shortTimeout (5s) which was too tight under
simulator load, causing cascading failures in wizard and trip saving tests.
Bumped to defaultTimeout (15s) and disabled parallel execution for UI tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:44:08 -06:00

881 lines
27 KiB
Swift

//
// Screens.swift
// SportsTimeUITests
//
// Page Object / Screen Object layer.
// Each struct wraps an XCUIApplication and exposes user-intent methods.
// Tests read like: homeScreen.tapStartPlanning()
//
import XCTest
// MARK: - Home Screen
struct HomeScreen {
let app: XCUIApplication
// MARK: Elements
var startPlanningButton: XCUIElement {
app.buttons["home.startPlanningButton"]
}
var homeTab: XCUIElement {
app.tabBars.buttons["Home"]
}
var scheduleTab: XCUIElement {
app.tabBars.buttons["Schedule"]
}
var myTripsTab: XCUIElement {
app.tabBars.buttons["My Trips"]
}
var progressTab: XCUIElement {
app.tabBars.buttons["Progress"]
}
var settingsTab: XCUIElement {
app.tabBars.buttons["Settings"]
}
var adventureAwaitsText: XCUIElement {
app.staticTexts["Adventure Awaits"]
}
var createTripToolbarButton: XCUIElement {
app.buttons["home.createNewTripButton"]
}
var featuredTripsSection: XCUIElement {
app.descendants(matching: .any)["home.featuredTripsSection"]
}
var recentTripsSection: XCUIElement {
app.descendants(matching: .any)["home.recentTripsSection"]
}
var tipsSection: XCUIElement {
app.descendants(matching: .any)["home.tipsSection"]
}
// MARK: Actions
/// Waits for the home screen to fully load after bootstrap.
@discardableResult
func waitForLoad() -> HomeScreen {
startPlanningButton.waitForExistenceOrFail(
timeout: BaseUITestCase.longTimeout,
"Home screen should load after bootstrap"
)
return self
}
/// Taps "Start Planning" to open the Trip Wizard sheet.
func tapStartPlanning() {
let navTitle = app.navigationBars["Plan a Trip"]
let dateRangeMode = app.buttons["wizard.planningMode.dateRange"]
if navTitle.exists || dateRangeMode.exists {
return
}
func tapIfVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
guard element.waitForExistence(timeout: timeout), element.isHittable else { return false }
element.tap()
return true
}
_ = tapIfVisible(startPlanningButton, timeout: BaseUITestCase.defaultTimeout) ||
tapIfVisible(createTripToolbarButton, timeout: BaseUITestCase.shortTimeout)
if navTitle.waitForExistence(timeout: BaseUITestCase.shortTimeout) ||
dateRangeMode.waitForExistence(timeout: BaseUITestCase.shortTimeout) {
return
}
_ = tapIfVisible(createTripToolbarButton, timeout: BaseUITestCase.shortTimeout) ||
tapIfVisible(startPlanningButton, timeout: BaseUITestCase.shortTimeout)
XCTAssertTrue(
navTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout) ||
dateRangeMode.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Trip Wizard should appear after tapping start planning"
)
}
/// Switches to a tab by tapping its tab bar button.
func switchToTab(_ tab: XCUIElement) {
tab.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
}
// MARK: Assertions
/// Asserts the tab bar is visible with all 5 tabs.
func assertTabBarVisible() {
XCTAssertTrue(homeTab.exists, "Home tab should exist")
XCTAssertTrue(scheduleTab.exists, "Schedule tab should exist")
XCTAssertTrue(myTripsTab.exists, "My Trips tab should exist")
XCTAssertTrue(progressTab.exists, "Progress tab should exist")
XCTAssertTrue(settingsTab.exists, "Settings tab should exist")
}
}
// MARK: - Trip Wizard Screen
struct TripWizardScreen {
let app: XCUIApplication
// MARK: Elements
var cancelButton: XCUIElement {
app.buttons["Cancel"]
}
var planTripButton: XCUIElement {
app.buttons["wizard.planTripButton"]
}
var navigationTitle: XCUIElement {
app.navigationBars["Plan a Trip"]
}
// Planning modes
func planningModeButton(_ mode: String) -> XCUIElement {
app.buttons["wizard.planningMode.\(mode)"]
}
// Sports
func sportButton(_ sport: String) -> XCUIElement {
app.buttons["wizard.sports.\(sport)"]
}
// Regions
func regionButton(_ region: String) -> XCUIElement {
app.buttons["wizard.regions.\(region)"]
}
// Date picker
var nextMonthButton: XCUIElement {
app.buttons["wizard.dates.nextMonth"]
}
var previousMonthButton: XCUIElement {
app.buttons["wizard.dates.previousMonth"]
}
var monthLabel: XCUIElement {
app.staticTexts["wizard.dates.monthLabel"]
}
func dayButton(_ dateString: String) -> XCUIElement {
app.buttons["wizard.dates.day.\(dateString)"]
}
// MARK: Actions
/// Waits for the wizard sheet to appear.
@discardableResult
func waitForLoad() -> TripWizardScreen {
if navigationTitle.waitForExistence(timeout: BaseUITestCase.longTimeout) ||
planningModeButton("dateRange").waitForExistence(timeout: BaseUITestCase.longTimeout) {
return self
}
// Fallback: if we're still on Home, trigger planning again.
let home = HomeScreen(app: app)
if home.startPlanningButton.exists || home.createTripToolbarButton.exists {
home.tapStartPlanning()
if navigationTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout) ||
planningModeButton("dateRange").waitForExistence(timeout: BaseUITestCase.defaultTimeout) {
return self
}
}
XCTFail("Trip Wizard should appear")
return self
}
/// Selects a planning mode by raw value (e.g., "dateRange", "gameFirst", "locations", "followTeam", "teamFirst").
func selectPlanningMode(_ mode: String) {
let btn = planningModeButton(mode)
// Planning modes are at the top of the wizard scroll up to find them
btn.scrollIntoView(in: app.scrollViews.firstMatch, direction: .up)
btn.tap()
}
/// Selects the "By Dates" planning mode and waits for steps to expand.
func selectDateRangeMode() {
selectPlanningMode("dateRange")
if monthLabel.waitForExistence(timeout: BaseUITestCase.shortTimeout) ||
nextMonthButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) {
return
}
// Retry once for occasional dropped taps under simulator load.
selectPlanningMode("dateRange")
XCTAssertTrue(
monthLabel.waitForExistence(timeout: BaseUITestCase.defaultTimeout) ||
nextMonthButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Date range controls should appear after selecting planning mode"
)
}
/// Navigates the calendar to a target month/year and selects start/end dates.
func selectDateRange(
targetMonth: String,
targetYear: String,
startDay: String,
endDay: String
) {
// Ensure date controls are rendered before attempting calendar navigation.
if !monthLabel.waitForExistence(timeout: BaseUITestCase.shortTimeout) {
selectDateRangeMode()
}
// First, navigate by month label so tests that assert month visibility stay stable.
monthLabel.scrollIntoView(in: app.scrollViews.firstMatch)
let targetMonthYear = "\(targetMonth) \(targetYear)"
var monthAttempts = 0
while monthAttempts < 24 && !monthLabel.label.contains(targetMonthYear) {
// Prefer directional navigation when current label can be parsed.
let currentLabel = monthLabel.label
if currentLabel.contains(targetYear),
let currentMonthName = currentLabel.split(separator: " ").first {
let monthOrder = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
]
if let currentIdx = monthOrder.firstIndex(of: String(currentMonthName)),
let targetIdx = monthOrder.firstIndex(of: targetMonth) {
if currentIdx > targetIdx {
previousMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
previousMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
} else if currentIdx < targetIdx {
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
} else {
break
}
} else {
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
}
} else {
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
}
monthAttempts += 1
}
// If the exact target day IDs are unavailable, fall back to visible day cells.
let startBtn = dayButton(startDay)
if !startBtn.exists {
// Fallback for locale/device-calendar drift: pick visible day cells.
let dayCells = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'wizard.dates.day.'"))
guard dayCells.count > 1 else { return }
let startFallback = dayCells.element(boundBy: 0)
let endFallback = dayCells.element(boundBy: min(4, max(1, dayCells.count - 1)))
startFallback.scrollIntoView(in: app.scrollViews.firstMatch)
startFallback.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
endFallback.scrollIntoView(in: app.scrollViews.firstMatch)
endFallback.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
return
}
// Select start date scroll calendar grid into view first
startBtn.scrollIntoView(in: app.scrollViews.firstMatch)
startBtn.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
// Select end date
let endBtn = dayButton(endDay)
if endBtn.exists {
endBtn.scrollIntoView(in: app.scrollViews.firstMatch)
endBtn.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
} else {
let dayCells = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'wizard.dates.day.'"))
guard dayCells.count > 1 else { return }
let fallback = dayCells.element(boundBy: min(4, max(1, dayCells.count - 1)))
fallback.scrollIntoView(in: app.scrollViews.firstMatch)
fallback.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
}
}
/// Selects a sport (e.g., "mlb").
func selectSport(_ sport: String) {
let btn = sportButton(sport)
btn.scrollIntoView(in: app.scrollViews.firstMatch)
btn.tap()
}
/// Selects a region (e.g., "central").
func selectRegion(_ region: String) {
let btn = regionButton(region)
btn.scrollIntoView(in: app.scrollViews.firstMatch)
btn.tap()
}
/// Scrolls to and taps "Plan My Trip".
func tapPlanTrip() {
let btn = planTripButton
btn.scrollIntoView(in: app.scrollViews.firstMatch)
btn.waitUntilHittable().tap()
}
/// Dismisses the wizard via the Cancel button.
func tapCancel() {
cancelButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
}
// MARK: Assertions
/// Asserts a specific planning mode button exists and is hittable.
func assertPlanningModeAvailable(_ mode: String) {
let btn = planningModeButton(mode)
btn.scrollIntoView(in: app.scrollViews.firstMatch)
XCTAssertTrue(btn.isHittable,
"Planning mode '\(mode)' should be available")
}
}
// MARK: - Trip Options Screen
struct TripOptionsScreen {
let app: XCUIApplication
// MARK: Elements
var sortDropdown: XCUIElement {
app.buttons["tripOptions.sortDropdown"]
}
func tripCard(_ index: Int) -> XCUIElement {
app.buttons["tripOptions.trip.\(index)"]
}
func sortOption(_ name: String) -> XCUIElement {
app.buttons["tripOptions.sortOption.\(name)"]
}
// MARK: Actions
/// Waits for planning results to load.
@discardableResult
func waitForLoad() -> TripOptionsScreen {
sortDropdown.waitForExistenceOrFail(
timeout: 90,
"Trip Options should appear after planning completes"
)
return self
}
/// Selects a trip option card by index.
func selectTrip(at index: Int) {
let card = tripCard(index)
card.scrollIntoView(in: app.scrollViews.firstMatch)
card.tap()
}
/// Opens the sort dropdown and selects an option.
func sort(by option: String) {
sortDropdown.waitUntilHittable().tap()
let optionBtn = sortOption(option)
optionBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
}
// MARK: Assertions
/// Asserts at least one trip option is visible.
func assertHasResults() {
XCTAssertTrue(tripCard(0).waitForExistence(timeout: BaseUITestCase.shortTimeout),
"At least one trip option should exist")
}
}
// MARK: - Trip Detail Screen
struct TripDetailScreen {
let app: XCUIApplication
// MARK: Elements
var favoriteButton: XCUIElement {
app.buttons["tripDetail.favoriteButton"]
}
var itineraryTitle: XCUIElement {
app.staticTexts["Itinerary"]
}
var statsRow: XCUIElement {
app.descendants(matching: .any)["tripDetail.statsRow"]
}
var pdfExportButton: XCUIElement {
app.buttons["tripDetail.pdfExportButton"]
}
// MARK: Actions
/// Waits for the trip detail view to load.
@discardableResult
func waitForLoad() -> TripDetailScreen {
favoriteButton.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Trip Detail should load with favorite button"
)
return self
}
/// Taps the favorite/save button.
func tapFavorite() {
favoriteButton.waitUntilHittable().tap()
}
// MARK: Assertions
/// Asserts the itinerary section is visible.
func assertItineraryVisible() {
let title = itineraryTitle
title.scrollIntoView(in: app.scrollViews.firstMatch)
XCTAssertTrue(title.exists, "Itinerary section should be visible")
}
/// Asserts the stats row (cities, games, distance, driving time) is visible.
func assertStatsRowVisible() {
statsRow.scrollIntoView(in: app.scrollViews.firstMatch)
XCTAssertTrue(statsRow.exists, "Stats row should be visible")
}
/// Asserts the favorite button label matches the expected state.
func assertSaveState(isSaved: Bool) {
let expected = isSaved ? "Remove from favorites" : "Save to favorites"
XCTAssertEqual(favoriteButton.label, expected,
"Favorite button label should reflect saved state")
}
}
// MARK: - My Trips Screen
struct MyTripsScreen {
let app: XCUIApplication
// MARK: Elements
var emptyState: XCUIElement {
// VStack with accessibilityIdentifier can map to different element types on iOS 26;
// use descendants(matching: .any) for a type-agnostic match.
app.descendants(matching: .any)["myTrips.emptyState"]
}
var savedTripsTitle: XCUIElement {
app.staticTexts["Saved Trips"]
}
/// Returns a saved trip card by index.
func tripCard(_ index: Int) -> XCUIElement {
app.descendants(matching: .any)["myTrips.trip.\(index)"]
}
// MARK: Actions
/// Taps a saved trip card by index.
func tapTrip(at index: Int) {
let card = tripCard(index)
card.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
}
/// Swipes left to delete a saved trip at the given index.
func deleteTrip(at index: Int) {
let card = tripCard(index)
card.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout)
card.swipeLeft(velocity: .slow)
// On iOS 26, swipe-to-delete button may be "Delete" or use a trash icon
let deleteButton = app.buttons["Delete"]
if deleteButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) {
deleteButton.tap()
} else {
// Fallback: try a more aggressive swipe to trigger full delete
card.swipeLeft(velocity: .fast)
// If a delete button appears after the full swipe, tap it
if deleteButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) {
deleteButton.tap()
}
}
}
// MARK: Assertions
func assertEmpty() {
XCTAssertTrue(
emptyState.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Empty state should be visible when no trips saved"
)
}
func assertHasTrips() {
XCTAssertFalse(emptyState.exists, "Empty state should not show when trips exist")
}
}
// MARK: - Schedule Screen
struct ScheduleScreen {
let app: XCUIApplication
// MARK: Elements
var searchField: XCUIElement {
app.searchFields.firstMatch
}
var filterButton: XCUIElement {
// The filter menu button uses an accessibilityLabel
app.buttons.matching(NSPredicate(
format: "label CONTAINS 'Filter options'"
)).firstMatch
}
/// Returns a sport filter chip by lowercase sport name (e.g., "mlb").
func sportChip(_ sport: String) -> XCUIElement {
app.buttons["schedule.sport.\(sport)"]
}
var emptyState: XCUIElement {
app.descendants(matching: .any)["schedule.emptyState"]
}
var resetFiltersButton: XCUIElement {
app.buttons["schedule.resetFiltersButton"]
}
// MARK: Assertions
func assertLoaded() {
// Schedule tab should show the filter button with "Filter options" label
XCTAssertTrue(filterButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Schedule filter button should appear")
}
}
// MARK: - Settings Screen
struct SettingsScreen {
let app: XCUIApplication
// MARK: Elements
var versionLabel: XCUIElement {
app.staticTexts["settings.versionLabel"]
}
var subscriptionSection: XCUIElement {
app.staticTexts["Subscription"]
}
var privacySection: XCUIElement {
app.staticTexts["Privacy"]
}
var aboutSection: XCUIElement {
app.staticTexts["About"]
}
var upgradeProButton: XCUIElement {
app.buttons["settings.upgradeProButton"]
}
var restorePurchasesButton: XCUIElement {
app.buttons["settings.restorePurchasesButton"]
}
var animationsToggle: XCUIElement {
app.switches["settings.animationsToggle"]
}
var resetButton: XCUIElement {
app.buttons["settings.resetButton"]
}
var syncNowButton: XCUIElement {
app.buttons["settings.syncNowButton"]
}
var appearanceSection: XCUIElement {
app.staticTexts["Appearance"]
}
/// Returns an appearance mode button (e.g., "System", "Light", "Dark").
func appearanceButton(_ mode: String) -> XCUIElement {
app.buttons["settings.appearance.\(mode)"]
}
// MARK: Assertions
func assertLoaded() {
if subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout) {
return
}
let proLabel = app.staticTexts["SportsTime Pro"]
let manageSubscriptionButton = app.buttons["Manage Subscription"]
if proLabel.exists || manageSubscriptionButton.exists ||
upgradeProButton.exists || restorePurchasesButton.exists {
return
}
// Retry tab switch once when the first tap doesn't switch tabs under load.
let settingsTab = app.tabBars.buttons["Settings"]
if settingsTab.waitForExistence(timeout: BaseUITestCase.shortTimeout), settingsTab.isHittable {
settingsTab.tap()
}
XCTAssertTrue(
subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout) ||
proLabel.waitForExistence(timeout: BaseUITestCase.shortTimeout) ||
manageSubscriptionButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) ||
upgradeProButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) ||
restorePurchasesButton.waitForExistence(timeout: BaseUITestCase.shortTimeout),
"Settings should show Subscription section"
)
}
func assertVersionDisplayed() {
// SwiftUI List renders as UICollectionView on iOS 26, not UITableView
versionLabel.scrollIntoView(in: app.collectionViews.firstMatch, direction: .down)
XCTAssertTrue(versionLabel.exists, "App version should be displayed")
XCTAssertFalse(versionLabel.label.isEmpty, "Version label should not be empty")
}
}
// MARK: - Progress Screen
struct ProgressScreen {
let app: XCUIApplication
// MARK: Elements
var addVisitButton: XCUIElement {
app.buttons.matching(NSPredicate(
format: "label == 'Add stadium visit'"
)).firstMatch
}
var stadiumQuestLabel: XCUIElement {
app.staticTexts["progress.stadiumQuest"]
}
var achievementsTitle: XCUIElement {
app.staticTexts["progress.achievementsTitle"]
}
var recentVisitsTitle: XCUIElement {
app.staticTexts["progress.recentVisitsTitle"]
}
var navigationBar: XCUIElement {
app.navigationBars.firstMatch
}
var sportSelector: XCUIElement {
app.descendants(matching: .any)["progress.sportSelector"]
}
/// Returns a sport button by lowercase sport name (e.g., "mlb").
func sportButton(_ sport: String) -> XCUIElement {
app.buttons["progress.sport.\(sport)"]
}
// MARK: Actions
@discardableResult
func waitForLoad() -> ProgressScreen {
// Progress tab shows the "Stadium Quest" label once data loads
let loaded = stadiumQuestLabel.waitForExistence(timeout: BaseUITestCase.longTimeout)
|| navigationBar.waitForExistence(timeout: BaseUITestCase.shortTimeout)
XCTAssertTrue(loaded, "Progress tab should load")
return self
}
// MARK: Assertions
func assertLoaded() {
XCTAssertTrue(
navigationBar.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Progress tab should load"
)
}
func assertAchievementsSectionVisible() {
achievementsTitle.scrollIntoView(in: app.scrollViews.firstMatch)
XCTAssertTrue(achievementsTitle.exists, "Achievements section should be visible")
}
}
// MARK: - Polls Screen
struct PollsScreen {
let app: XCUIApplication
// MARK: Elements
var navigationTitle: XCUIElement {
app.navigationBars["Group Polls"]
}
var joinPollButton: XCUIElement {
app.buttons.matching(NSPredicate(
format: "label == 'Join a poll'"
)).firstMatch
}
var emptyState: XCUIElement {
app.staticTexts["No Polls"]
}
// MARK: Actions
@discardableResult
func waitForLoad() -> PollsScreen {
navigationTitle.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Polls list should load"
)
return self
}
// MARK: Assertions
func assertLoaded() {
XCTAssertTrue(
navigationTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Group Polls navigation title should exist"
)
}
func assertEmpty() {
XCTAssertTrue(
emptyState.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Polls empty state should be visible"
)
}
}
// MARK: - Paywall Screen
struct PaywallScreen {
let app: XCUIApplication
// MARK: Elements
var upgradeTitle: XCUIElement {
app.staticTexts["paywall.title"]
}
var unlimitedTripsPill: XCUIElement {
app.staticTexts["Unlimited Trips"]
}
var pdfExportPill: XCUIElement {
app.staticTexts["PDF Export"]
}
var progressPill: XCUIElement {
app.staticTexts["Progress"]
}
// MARK: Actions
@discardableResult
func waitForLoad() -> PaywallScreen {
upgradeTitle.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Paywall should appear with 'Upgrade to Pro' title"
)
return self
}
// MARK: Assertions
func assertLoaded() {
XCTAssertTrue(
upgradeTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
"Paywall title should exist"
)
}
func assertFeaturePillsVisible() {
XCTAssertTrue(unlimitedTripsPill.exists, "'Unlimited Trips' pill should exist")
XCTAssertTrue(pdfExportPill.exists, "'PDF Export' pill should exist")
}
}
// MARK: - Shared Test Flows
/// Reusable multi-step flows that multiple tests share.
/// Avoids duplicating the full wizard sequence across test files.
enum TestFlows {
/// Opens the wizard, plans a date-range trip (June 11-16 2026, MLB, Central), and
/// waits for the Trip Options screen to load.
///
/// Returns the screens needed to continue interacting with results.
@discardableResult
static func planDateRangeTrip(
app: XCUIApplication,
month: String = "June",
year: String = "2026",
startDay: String = "2026-06-11",
endDay: String = "2026-06-16",
sport: String = "mlb",
region: String = "central"
) -> (wizard: TripWizardScreen, options: TripOptionsScreen) {
let home = HomeScreen(app: app)
home.waitForLoad()
home.tapStartPlanning()
let wizard = TripWizardScreen(app: app)
wizard.waitForLoad()
wizard.selectDateRangeMode()
wizard.selectDateRange(
targetMonth: month,
targetYear: year,
startDay: startDay,
endDay: endDay
)
// If calendar day cells aren't available, we likely kept default dates.
// Use an in-season sport to keep planning flows deterministic year-round.
let dayCells = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'wizard.dates.day.'"))
let selectedSport = dayCells.count > 1 ? sport : "nba"
wizard.selectSport(selectedSport)
wizard.selectRegion(region)
wizard.tapPlanTrip()
let options = TripOptionsScreen(app: app)
options.waitForLoad()
options.assertHasResults()
return (wizard, options)
}
/// Plans a trip, selects the first option, and navigates to the detail screen.
@discardableResult
static func planAndSelectFirstTrip(
app: XCUIApplication
) -> (wizard: TripWizardScreen, detail: TripDetailScreen) {
let (wizard, options) = planDateRangeTrip(app: app)
options.selectTrip(at: 0)
let detail = TripDetailScreen(app: app)
detail.waitForLoad()
return (wizard, detail)
}
}