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:
Trey t
2026-02-16 19:44:22 -06:00
parent d53f222489
commit dc142bd14b
25 changed files with 1637 additions and 102 deletions

View File

@@ -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)
}
}