// // 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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 @MainActor 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() // Primary path: command-A replacement is generally the most reliable on simulator. field.typeKey("a", modifierFlags: .command) field.typeText(text) // Fallback path: if replacement didn't stick, try explicit select-all and replace. let currentValue = (field.value as? String) ?? "" if !currentValue.localizedCaseInsensitiveContains(text) { field.tap(withNumberOfTaps: 3, numberOfTouches: 1) if app.menuItems["Select All"].waitForExistence(timeout: BaseUITestCase.shortTimeout) { app.menuItems["Select All"].tap() } else { field.typeKey("a", modifierFlags: .command) } field.typeText(text) } } func tapSave() { saveButton.waitUntilHittable().tap() } func tapCancel() { cancelButton.waitUntilHittable().tap() } } // MARK: - Games History Screen @MainActor 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 @MainActor 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 @MainActor 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. @MainActor 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 } }