Files
Sportstime/SportsTimeUITests/Framework/Screens.swift
Trey t dc142bd14b feat: expand XCUITest coverage to 54 QA scenarios with accessibility IDs and fix test failures
Add 22 new UI tests across 8 test files covering Home, Schedule, Progress,
Settings, TabNavigation, TripSaving, and TripOptions. Add accessibility
identifiers to 11 view files for test element discovery. Fix sport chip
assertion logic (all sports start selected, tap deselects), scroll container
issues on iOS 26 nested ScrollViews, toggle interaction, and delete trip flow.
Update QA coverage map from 32 to 54 automated test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:44:22 -06:00

750 lines
21 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 {
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)
}
}