// // 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() { startPlanningButton.waitUntilHittable().tap() } /// 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 { navigationTitle.waitForExistenceOrFail( timeout: BaseUITestCase.defaultTimeout, "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) btn.scrollIntoView(in: app.scrollViews.firstMatch) btn.tap() } /// Selects the "By Dates" planning mode and waits for steps to expand. func selectDateRangeMode() { selectPlanningMode("dateRange") } /// Navigates the calendar to a target month/year and selects start/end dates. func selectDateRange( targetMonth: String, targetYear: String, startDay: String, endDay: String ) { // Navigate forward to the target month let target = "\(targetMonth) \(targetYear)" var attempts = 0 // First ensure the month label is visible monthLabel.scrollIntoView(in: app.scrollViews.firstMatch) while !monthLabel.label.contains(target) && attempts < 18 { nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() attempts += 1 } XCTAssertTrue(monthLabel.label.contains(target), "Should navigate to \(target)") // Select start date — scroll calendar grid into view first let startBtn = dayButton(startDay) startBtn.scrollIntoView(in: app.scrollViews.firstMatch) startBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() // Select end date let endBtn = dayButton(endDay) endBtn.scrollIntoView(in: app.scrollViews.firstMatch) endBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).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: BaseUITestCase.longTimeout, "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() { XCTAssertTrue(subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout), "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.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) wizard.selectDateRange( targetMonth: month, targetYear: year, startDay: startDay, endDay: endDay ) wizard.selectSport(sport) 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) } }