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:
139
SportsTimeUITests/Framework/BaseUITestCase.swift
Normal file
139
SportsTimeUITests/Framework/BaseUITestCase.swift
Normal 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
|
||||
}
|
||||
410
SportsTimeUITests/Framework/Screens.swift
Normal file
410
SportsTimeUITests/Framework/Screens.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
46
SportsTimeUITests/Tests/AccessibilityTests.swift
Normal file
46
SportsTimeUITests/Tests/AccessibilityTests.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// AccessibilityTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Smoke test for Dynamic Type accessibility at XXXL text size.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class AccessibilityTests: BaseUITestCase {
|
||||
|
||||
/// Verifies the entry flow is usable at AX XXL text size.
|
||||
@MainActor
|
||||
func testLargeDynamicTypeEntryFlow() {
|
||||
// Re-launch with large Dynamic Type
|
||||
app.terminate()
|
||||
app.launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations",
|
||||
"--reset-state",
|
||||
"-UIPreferredContentSizeCategoryName",
|
||||
"UICTContentSizeCategoryAccessibilityXXXL"
|
||||
]
|
||||
app.launch()
|
||||
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
|
||||
// At XXXL text the button may be pushed below the fold; scroll into view
|
||||
home.startPlanningButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
XCTAssertTrue(home.startPlanningButton.isHittable,
|
||||
"Start Planning should remain hittable at large Dynamic Type")
|
||||
|
||||
// Open wizard and verify planning mode options are reachable
|
||||
home.tapStartPlanning()
|
||||
let wizard = TripWizardScreen(app: app)
|
||||
wizard.waitForLoad()
|
||||
|
||||
let dateRangeMode = wizard.planningModeButton("dateRange")
|
||||
dateRangeMode.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
XCTAssertTrue(dateRangeMode.isHittable,
|
||||
"Planning mode should be hittable at large Dynamic Type")
|
||||
|
||||
captureScreenshot(named: "Accessibility-LargeType")
|
||||
}
|
||||
}
|
||||
38
SportsTimeUITests/Tests/AppLaunchTests.swift
Normal file
38
SportsTimeUITests/Tests/AppLaunchTests.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// AppLaunchTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Verifies app boot, bootstrap, and initial screen rendering.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class AppLaunchTests: BaseUITestCase {
|
||||
|
||||
/// Verifies the app boots, shows the home screen, and all 5 tabs are present.
|
||||
@MainActor
|
||||
func testAppLaunchShowsHomeWithAllTabs() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
|
||||
// Assert: Hero card text visible
|
||||
XCTAssertTrue(home.adventureAwaitsText.exists,
|
||||
"Hero card should display 'Adventure Awaits'")
|
||||
|
||||
// Assert: All tabs present
|
||||
home.assertTabBarVisible()
|
||||
|
||||
captureScreenshot(named: "HomeScreen-Launch")
|
||||
}
|
||||
|
||||
/// Verifies the bootstrap loading indicator disappears and content renders.
|
||||
@MainActor
|
||||
func testBootstrapCompletesWithContent() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
|
||||
// Assert: Start Planning button is interactable (proves bootstrap loaded data)
|
||||
XCTAssertTrue(home.startPlanningButton.isHittable,
|
||||
"Start Planning should be hittable after bootstrap")
|
||||
}
|
||||
}
|
||||
24
SportsTimeUITests/Tests/ScheduleTests.swift
Normal file
24
SportsTimeUITests/Tests/ScheduleTests.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// ScheduleTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Verifies the Schedule tab loads and displays content.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class ScheduleTests: BaseUITestCase {
|
||||
|
||||
/// Verifies the schedule tab loads and shows content.
|
||||
@MainActor
|
||||
func testScheduleTabLoads() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
home.switchToTab(home.scheduleTab)
|
||||
|
||||
let schedule = ScheduleScreen(app: app)
|
||||
schedule.assertLoaded()
|
||||
|
||||
captureScreenshot(named: "Schedule-Loaded")
|
||||
}
|
||||
}
|
||||
48
SportsTimeUITests/Tests/SettingsTests.swift
Normal file
48
SportsTimeUITests/Tests/SettingsTests.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// SettingsTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Verifies the Settings screen loads, displays version, and shows all sections.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class SettingsTests: BaseUITestCase {
|
||||
|
||||
/// Verifies the Settings screen loads and displays the app version.
|
||||
@MainActor
|
||||
func testSettingsShowsVersion() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
home.switchToTab(home.settingsTab)
|
||||
|
||||
let settings = SettingsScreen(app: app)
|
||||
settings.assertLoaded()
|
||||
settings.assertVersionDisplayed()
|
||||
|
||||
captureScreenshot(named: "Settings-Version")
|
||||
}
|
||||
|
||||
/// Verifies key sections are present in Settings.
|
||||
@MainActor
|
||||
func testSettingsSectionsPresent() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
home.switchToTab(home.settingsTab)
|
||||
|
||||
let settings = SettingsScreen(app: app)
|
||||
settings.assertLoaded()
|
||||
|
||||
// Assert: Key sections exist
|
||||
XCTAssertTrue(settings.subscriptionSection.waitForExistence(
|
||||
timeout: BaseUITestCase.shortTimeout),
|
||||
"Subscription section should exist")
|
||||
|
||||
// SwiftUI List renders as UICollectionView on iOS 26
|
||||
settings.privacySection.scrollIntoView(in: app.collectionViews.firstMatch)
|
||||
XCTAssertTrue(settings.privacySection.exists, "Privacy section should exist")
|
||||
|
||||
settings.aboutSection.scrollIntoView(in: app.collectionViews.firstMatch)
|
||||
XCTAssertTrue(settings.aboutSection.exists, "About section should exist")
|
||||
}
|
||||
}
|
||||
47
SportsTimeUITests/Tests/TabNavigationTests.swift
Normal file
47
SportsTimeUITests/Tests/TabNavigationTests.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// TabNavigationTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Verifies navigation through all 5 tabs.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class TabNavigationTests: BaseUITestCase {
|
||||
|
||||
/// Navigates through all 5 tabs and asserts each one loads.
|
||||
@MainActor
|
||||
func testTabNavigationCycle() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
|
||||
// Schedule tab
|
||||
home.switchToTab(home.scheduleTab)
|
||||
let schedule = ScheduleScreen(app: app)
|
||||
schedule.assertLoaded()
|
||||
|
||||
// My Trips tab
|
||||
home.switchToTab(home.myTripsTab)
|
||||
let myTrips = MyTripsScreen(app: app)
|
||||
myTrips.assertEmpty()
|
||||
|
||||
// Progress tab (Pro-gated, but UI test mode forces Pro)
|
||||
home.switchToTab(home.progressTab)
|
||||
// Just verify the tab switched without crash
|
||||
let progressNav = app.navigationBars.firstMatch
|
||||
XCTAssertTrue(progressNav.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
|
||||
"Progress tab should load")
|
||||
|
||||
// Settings tab
|
||||
home.switchToTab(home.settingsTab)
|
||||
let settings = SettingsScreen(app: app)
|
||||
settings.assertLoaded()
|
||||
|
||||
// Return home
|
||||
home.switchToTab(home.homeTab)
|
||||
XCTAssertTrue(home.startPlanningButton.waitForExistence(timeout: BaseUITestCase.shortTimeout),
|
||||
"Should return to Home tab")
|
||||
|
||||
captureScreenshot(named: "TabNavigation-ReturnHome")
|
||||
}
|
||||
}
|
||||
66
SportsTimeUITests/Tests/TripSavingTests.swift
Normal file
66
SportsTimeUITests/Tests/TripSavingTests.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// TripSavingTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Tests the end-to-end trip saving flow: plan → select → save → verify in My Trips.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class TripSavingTests: BaseUITestCase {
|
||||
|
||||
/// Plans a trip, selects an option, saves it, and verifies it appears in My Trips.
|
||||
@MainActor
|
||||
func testSaveTripAppearsInMyTrips() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
home.tapStartPlanning()
|
||||
|
||||
// Plan a trip using date range mode
|
||||
let wizard = TripWizardScreen(app: app)
|
||||
wizard.waitForLoad()
|
||||
wizard.selectDateRangeMode()
|
||||
|
||||
wizard.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
wizard.selectDateRange(
|
||||
targetMonth: "June",
|
||||
targetYear: "2026",
|
||||
startDay: "2026-06-11",
|
||||
endDay: "2026-06-16"
|
||||
)
|
||||
wizard.selectSport("mlb")
|
||||
wizard.selectRegion("central")
|
||||
wizard.tapPlanTrip()
|
||||
|
||||
// Select first trip option
|
||||
let options = TripOptionsScreen(app: app)
|
||||
options.waitForLoad()
|
||||
options.selectTrip(at: 0)
|
||||
|
||||
// Save the trip
|
||||
let detail = TripDetailScreen(app: app)
|
||||
detail.waitForLoad()
|
||||
detail.assertSaveState(isSaved: false)
|
||||
detail.tapFavorite()
|
||||
|
||||
// Allow save to persist
|
||||
detail.assertSaveState(isSaved: true)
|
||||
|
||||
captureScreenshot(named: "TripSaving-Favorited")
|
||||
|
||||
// Navigate back to My Trips tab
|
||||
// Dismiss the entire wizard sheet: Detail → Options → Wizard → Cancel
|
||||
app.navigationBars.buttons.firstMatch.tap() // Back from detail to options
|
||||
// Back from options to wizard
|
||||
let wizardBackBtn = app.navigationBars.buttons.firstMatch
|
||||
wizardBackBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap()
|
||||
// Cancel the wizard sheet
|
||||
wizard.tapCancel()
|
||||
// Now the tab bar is accessible
|
||||
home.switchToTab(home.myTripsTab)
|
||||
|
||||
// Assert: Saved trip appears (empty state should NOT be visible)
|
||||
let myTrips = MyTripsScreen(app: app)
|
||||
myTrips.assertHasTrips()
|
||||
}
|
||||
}
|
||||
71
SportsTimeUITests/Tests/TripWizardFlowTests.swift
Normal file
71
SportsTimeUITests/Tests/TripWizardFlowTests.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// TripWizardFlowTests.swift
|
||||
// SportsTimeUITests
|
||||
//
|
||||
// Tests the trip planning wizard: date range mode, calendar navigation,
|
||||
// sport/region selection, and planning engine results.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class TripWizardFlowTests: BaseUITestCase {
|
||||
|
||||
/// Full flow: Start Planning → Date Range → Select dates → MLB → Central → Plan.
|
||||
/// Asserts the planning engine returns results.
|
||||
@MainActor
|
||||
func testDateRangeTripPlanningFlow() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
home.tapStartPlanning()
|
||||
|
||||
let wizard = TripWizardScreen(app: app)
|
||||
wizard.waitForLoad()
|
||||
|
||||
// Step 1: Select "By Dates" mode
|
||||
wizard.selectDateRangeMode()
|
||||
|
||||
// Step 2: Navigate to June 2026 and select June 11-16
|
||||
// Scroll to see dates step
|
||||
wizard.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
wizard.selectDateRange(
|
||||
targetMonth: "June",
|
||||
targetYear: "2026",
|
||||
startDay: "2026-06-11",
|
||||
endDay: "2026-06-16"
|
||||
)
|
||||
|
||||
// Step 3: Select MLB
|
||||
wizard.selectSport("mlb")
|
||||
|
||||
// Step 4: Select Central region
|
||||
wizard.selectRegion("central")
|
||||
|
||||
// Step 5: Tap Plan My Trip
|
||||
wizard.tapPlanTrip()
|
||||
|
||||
// Assert: Trip Options screen appears with results
|
||||
let options = TripOptionsScreen(app: app)
|
||||
options.waitForLoad()
|
||||
options.assertHasResults()
|
||||
|
||||
captureScreenshot(named: "TripWizard-PlanningResults")
|
||||
}
|
||||
|
||||
/// Verifies the wizard can be dismissed via Cancel.
|
||||
@MainActor
|
||||
func testWizardCanBeDismissed() {
|
||||
let home = HomeScreen(app: app)
|
||||
home.waitForLoad()
|
||||
home.tapStartPlanning()
|
||||
|
||||
let wizard = TripWizardScreen(app: app)
|
||||
wizard.waitForLoad()
|
||||
wizard.tapCancel()
|
||||
|
||||
// Assert: Back on home screen
|
||||
XCTAssertTrue(
|
||||
home.startPlanningButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout),
|
||||
"Should return to Home after cancelling wizard"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user