Files
Sportstime/SportsTimeUITests/Framework/Screens.swift
2026-02-19 23:49:29 -06:00

1126 lines
34 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: - Custom Item Elements
/// The "Add" button on any day header row (first match).
var addItemButton: XCUIElement {
app.buttons["tripDetail.addItemButton"].firstMatch
}
/// A custom item cell in the itinerary (first match).
var customItemCell: XCUIElement {
app.cells["tripDetail.customItem"].firstMatch
}
// MARK: - Custom Item Actions
/// Scrolls to and taps the first "Add" button on a day header.
func tapAddItem() {
let button = addItemButton
var scrollAttempts = 0
while !(button.exists && button.isHittable) && scrollAttempts < 15 {
app.swipeUp(velocity: .slow)
scrollAttempts += 1
}
button.waitUntilHittable().tap()
}
/// Taps a custom item cell to open the edit sheet.
func tapCustomItem() {
let cell = customItemCell
var scrollAttempts = 0
while !(cell.exists && cell.isHittable) && scrollAttempts < 15 {
app.swipeUp(velocity: .slow)
scrollAttempts += 1
}
cell.waitUntilHittable().tap()
}
/// Long-presses a custom item to show the context menu.
func longPressCustomItem() {
let cell = customItemCell
var scrollAttempts = 0
while !(cell.exists && cell.isHittable) && scrollAttempts < 15 {
app.swipeUp(velocity: .slow)
scrollAttempts += 1
}
cell.waitUntilHittable()
cell.press(forDuration: 1.0)
}
}
// 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)"]
}
var seeAllGamesHistoryButton: XCUIElement {
// NavigationLink may render as button or other element on iOS 26
let byIdentifier = app.buttons["progress.seeAllGamesHistory"]
if byIdentifier.exists { return byIdentifier }
// Fallback: match by label text
return app.buttons.matching(NSPredicate(
format: "label CONTAINS[c] 'See All'"
)).firstMatch
}
// 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
}
/// Opens the "Add Visit" menu and taps "Manual Entry".
func tapAddManualVisit() {
addVisitButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
let manualEntry = app.buttons["Manual Entry"]
manualEntry.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
}
// 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: - Stadium Visit Sheet Screen
struct StadiumVisitSheetScreen {
let app: XCUIApplication
// MARK: Elements
var navigationBar: XCUIElement {
app.navigationBars["Log Visit"]
}
var saveButton: XCUIElement {
app.buttons["visitSheet.saveButton"]
}
var cancelButton: XCUIElement {
navigationBar.buttons["Cancel"]
}
var stadiumButton: XCUIElement {
app.buttons["visitSheet.stadiumButton"]
}
// MARK: Actions
@discardableResult
func waitForLoad() -> StadiumVisitSheetScreen {
navigationBar.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Log Visit sheet should appear"
)
return self
}
/// Opens the stadium picker and selects the first stadium in the list.
func pickFirstStadium() {
stadiumButton.waitUntilHittable().tap()
// Wait for picker to appear
let pickerNav = app.navigationBars["Select Stadium"]
pickerNav.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Stadium picker should appear"
)
// Tap the first stadium row
let firstRow = app.buttons["stadiumPicker.stadiumRow"].firstMatch
firstRow.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap()
}
func tapSave() {
saveButton.waitUntilHittable().tap()
}
func tapCancel() {
cancelButton.waitUntilHittable().tap()
}
}
// MARK: - Quick Add Item Sheet Screen
struct QuickAddItemSheetScreen {
let app: XCUIApplication
// MARK: Elements
var titleField: XCUIElement {
app.textFields["quickAdd.titleField"]
}
var saveButton: XCUIElement {
app.buttons["quickAdd.saveButton"]
}
var cancelButton: XCUIElement {
app.navigationBars.buttons["Cancel"]
}
// MARK: Actions
@discardableResult
func waitForLoad() -> QuickAddItemSheetScreen {
titleField.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Quick add item sheet should appear with title field"
)
return self
}
/// Waits for the sheet to dismiss by checking the title field disappears.
func waitForDismiss() {
titleField.waitForNonExistence(
timeout: BaseUITestCase.defaultTimeout,
"Quick add item sheet should dismiss"
)
}
/// Types a title into the description field.
func typeTitle(_ text: String) {
titleField.waitUntilHittable().tap()
titleField.typeText(text)
}
/// Clears existing text and types a new title (for edit mode).
func clearAndTypeTitle(_ text: String) {
let field = titleField
field.waitUntilHittable().tap()
// Triple-tap to select all existing text
field.tap(withNumberOfTaps: 3, numberOfTouches: 1)
field.typeText(text)
}
func tapSave() {
saveButton.waitUntilHittable().tap()
}
func tapCancel() {
cancelButton.waitUntilHittable().tap()
}
}
// MARK: - Games History Screen
struct GamesHistoryScreen {
let app: XCUIApplication
// MARK: Elements
var navigationBar: XCUIElement {
app.navigationBars["Games Attended"]
}
var emptyStateText: XCUIElement {
app.staticTexts["No games recorded yet"]
}
// MARK: Actions
@discardableResult
func waitForLoad() -> GamesHistoryScreen {
navigationBar.waitForExistenceOrFail(
timeout: BaseUITestCase.defaultTimeout,
"Games History should load"
)
return self
}
}
// 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)
}
/// Plans a trip, saves it, navigates back to My Trips, and opens the saved trip.
/// Returns a TripDetailScreen with `allowCustomItems` enabled.
@MainActor @discardableResult
static func planSaveAndOpenFromMyTrips(
app: XCUIApplication
) -> TripDetailScreen {
let (wizard, detail) = planAndSelectFirstTrip(app: app)
// Save the trip
detail.assertSaveState(isSaved: false)
detail.tapFavorite()
detail.assertSaveState(isSaved: true)
// Navigate back: Detail Options Wizard Cancel
app.navigationBars.buttons.firstMatch.tap()
let optionsBackBtn = app.navigationBars.buttons.firstMatch
optionsBackBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
wizard.tapCancel()
// Switch to My Trips tab and open the saved trip
let home = HomeScreen(app: app)
home.switchToTab(home.myTripsTab)
let myTrips = MyTripsScreen(app: app)
myTrips.assertHasTrips()
myTrips.tapTrip(at: 0)
let savedDetail = TripDetailScreen(app: app)
savedDetail.waitForLoad()
return savedDetail
}
}