881 lines
27 KiB
Swift
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.shortTimeout).tap()
|
|
} else if currentIdx < targetIdx {
|
|
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
|
} else {
|
|
break
|
|
}
|
|
} else {
|
|
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
|
}
|
|
} else {
|
|
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).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.shortTimeout).tap()
|
|
endFallback.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
endFallback.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
|
return
|
|
}
|
|
|
|
// Select start date — scroll calendar grid into view first
|
|
startBtn.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
startBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
|
|
|
// Select end date
|
|
let endBtn = dayButton(endDay)
|
|
if endBtn.exists {
|
|
endBtn.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
endBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).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.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: 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)
|
|
}
|
|
}
|