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>
This commit is contained in:
@@ -45,7 +45,19 @@ struct HomeScreen {
|
||||
}
|
||||
|
||||
var createTripToolbarButton: XCUIElement {
|
||||
app.buttons["Create new trip"]
|
||||
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
|
||||
@@ -145,13 +157,18 @@ struct TripWizardScreen {
|
||||
return self
|
||||
}
|
||||
|
||||
/// Selects the "By Dates" planning mode and waits for steps to expand.
|
||||
func selectDateRangeMode() {
|
||||
let btn = planningModeButton("dateRange")
|
||||
/// 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,
|
||||
@@ -208,6 +225,16 @@ struct TripWizardScreen {
|
||||
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
|
||||
@@ -279,6 +306,14 @@ struct TripDetailScreen {
|
||||
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.
|
||||
@@ -305,6 +340,12 @@ struct TripDetailScreen {
|
||||
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"
|
||||
@@ -330,6 +371,39 @@ struct MyTripsScreen {
|
||||
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() {
|
||||
@@ -362,6 +436,19 @@ struct ScheduleScreen {
|
||||
)).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() {
|
||||
@@ -394,6 +481,35 @@ struct SettingsScreen {
|
||||
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() {
|
||||
@@ -408,3 +524,226 @@ struct SettingsScreen {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user