feat: add XCUITest suite with 10 critical flow tests and QA test plan

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>
This commit is contained in:
Trey t
2026-02-16 16:23:59 -06:00
parent 787a0f795e
commit d53f222489
16 changed files with 1528 additions and 25 deletions

View File

@@ -0,0 +1,139 @@
//
// BaseUITestCase.swift
// SportsTimeUITests
//
// Base class for all UI tests. Provides launch configuration,
// screenshot-on-failure, and centralized wait helpers.
//
import XCTest
// MARK: - Base Test Case
class BaseUITestCase: XCTestCase {
/// The application under test. Configured in setUp.
var app: XCUIApplication!
/// Standard timeout for element existence checks.
static let defaultTimeout: TimeInterval = 15
/// Short timeout for elements expected to appear quickly.
static let shortTimeout: TimeInterval = 5
/// Extended timeout for bootstrap / planning engine operations.
static let longTimeout: TimeInterval = 30
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = [
"--ui-testing",
"--disable-animations",
"--reset-state"
]
app.launch()
}
override func tearDownWithError() throws {
// Capture a screenshot on test failure for post-mortem debugging.
if let failure = testRun, failure.failureCount > 0 {
let screenshot = XCTAttachment(screenshot: app.screenshot())
screenshot.name = "Failure-\(name)"
screenshot.lifetime = .keepAlways
add(screenshot)
}
app = nil
}
// MARK: - Screenshot Helpers
/// Captures a named screenshot attached to the test report.
func captureScreenshot(named name: String) {
let screenshot = XCTAttachment(screenshot: app.screenshot())
screenshot.name = name
screenshot.lifetime = .keepAlways
add(screenshot)
}
}
// MARK: - Wait Helpers
extension XCUIElement {
/// Waits until the element exists, failing with a descriptive message.
@discardableResult
func waitForExistenceOrFail(
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
_ message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
let msg = message ?? "Expected \(self) to exist within \(timeout)s"
XCTAssertTrue(waitForExistence(timeout: timeout), msg, file: file, line: line)
return self
}
/// Waits until the element exists AND is hittable.
@discardableResult
func waitUntilHittable(
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
_ message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
waitForExistenceOrFail(timeout: timeout, message, file: file, line: line)
let predicate = NSPredicate(format: "isHittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
let msg = message ?? "Expected \(self) to be hittable within \(timeout)s"
XCTAssertEqual(result, .completed, msg, file: file, line: line)
return self
}
/// Waits until the element no longer exists.
func waitForNonExistence(
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
_ message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
let msg = message ?? "Expected \(self) to disappear within \(timeout)s"
XCTAssertEqual(result, .completed, msg, file: file, line: line)
}
/// Scrolls a scroll view until this element is hittable, or times out.
@discardableResult
func scrollIntoView(
in scrollView: XCUIElement,
direction: ScrollDirection = .down,
maxScrolls: Int = 10,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
var scrollsRemaining = maxScrolls
while !exists || !isHittable {
guard scrollsRemaining > 0 else {
XCTFail("Could not scroll \(self) into view after \(maxScrolls) scrolls",
file: file, line: line)
return self
}
switch direction {
case .down:
scrollView.swipeUp(velocity: .slow)
case .up:
scrollView.swipeDown(velocity: .slow)
}
scrollsRemaining -= 1
}
return self
}
}
enum ScrollDirection {
case up, down
}

View File

@@ -0,0 +1,410 @@
//
// 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")
}
}