Files
Sportstime/SportsTimeUITests/Framework/Screens.swift
2026-02-18 13:00:15 -06:00

801 lines
24 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() {
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 {
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")
}
/// Navigates the calendar to a target month/year and selects start/end dates.
func selectDateRange(
targetMonth: String,
targetYear: String,
startDay: String,
endDay: String
) {
// 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() {
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.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)
}
}