Add comprehensive UI test infrastructure with Page Object pattern, accessibility identifiers, UI test mode (--ui-testing, --reset-state, --disable-animations), and 10 passing tests covering app launch, tab navigation, trip wizard, trip saving, settings, schedule, and accessibility at XXXL Dynamic Type. Also adds a 229-case QA test plan Excel workbook for manual QA handoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
411 lines
11 KiB
Swift
411 lines
11 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["Create new trip"]
|
|
}
|
|
|
|
// 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 the "By Dates" planning mode and waits for steps to expand.
|
|
func selectDateRangeMode() {
|
|
let btn = planningModeButton("dateRange")
|
|
btn.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
btn.tap()
|
|
}
|
|
|
|
/// 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: - 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"]
|
|
}
|
|
|
|
// 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 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"]
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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"]
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|