Files
Sportstime/SportsTimeUITests/SportsTimeUITests.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

273 lines
11 KiB
Swift

//
// SportsTimeUITests.swift
// SportsTimeUITests
//
// Created by Trey Tartt on 1/6/26.
//
import XCTest
@MainActor
final class SportsTimeUITests: XCTestCase {
override func setUpWithError() throws {
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
}
override func tearDownWithError() throws {
// Put teardown code here.
}
// MARK: - Accessibility Smoke Tests
/// Verifies primary entry flow remains usable at a large accessibility text size.
@MainActor
func testAccessibilitySmoke_LargeDynamicTypeEntryFlow() throws {
let app = XCUIApplication()
app.launchArguments = [
"--ui-testing",
"--disable-animations",
"--reset-state",
"-UIPreferredContentSizeCategoryName",
"UICTContentSizeCategoryAccessibilityXXXL"
]
app.launch()
let startPlanningButton = app.buttons["home.startPlanningButton"]
XCTAssertTrue(startPlanningButton.waitForExistence(timeout: 20), "Start Planning should exist at large Dynamic Type")
// At XXXL the button may be pushed below the fold scroll into view
startPlanningButton.scrollIntoView(in: app.scrollViews.firstMatch)
XCTAssertTrue(startPlanningButton.isHittable, "Start Planning should remain hittable at large Dynamic Type")
startPlanningButton.tap()
let dateRangeMode = app.buttons["wizard.planningMode.dateRange"]
XCTAssertTrue(dateRangeMode.waitForExistence(timeout: 10), "Planning mode options should load")
dateRangeMode.scrollIntoView(in: app.scrollViews.firstMatch)
XCTAssertTrue(dateRangeMode.isHittable, "Planning mode option should remain hittable at large Dynamic Type")
}
// MARK: - Shared Wizard Helpers
/// Launches the app with standard UI testing arguments and taps Start Planning.
private func launchAndStartPlanning() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = [
"--ui-testing",
"--disable-animations",
"--reset-state"
]
app.launch()
let startPlanningButton = app.buttons["home.startPlanningButton"]
XCTAssertTrue(startPlanningButton.waitForExistence(timeout: 10), "Start Planning button should exist")
startPlanningButton.tap()
return app
}
/// Fills the wizard: selects By Dates mode, navigates to June 2026,
/// picks June 11-16, MLB, and Central region.
private func fillWizardSteps(app: XCUIApplication) {
// Choose "By Dates" mode
let dateRangeMode = app.buttons["wizard.planningMode.dateRange"]
XCTAssertTrue(dateRangeMode.waitForExistence(timeout: 10), "Date Range mode should exist")
dateRangeMode.tap()
// Navigate to June 2026
let nextMonthButton = app.buttons["wizard.dates.nextMonth"]
nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch)
let monthLabel = app.staticTexts["wizard.dates.monthLabel"]
var attempts = 0
while !monthLabel.label.contains("June 2026") && attempts < 12 {
nextMonthButton.tap()
// Wait for the month label to update after tap
let updatedLabel = app.staticTexts["wizard.dates.monthLabel"]
_ = updatedLabel.waitForExistence(timeout: 2)
attempts += 1
}
// Select June 11
let june11 = app.buttons["wizard.dates.day.2026-06-11"]
june11.scrollIntoView(in: app.scrollViews.firstMatch)
june11.tap()
// Wait for June 16 to be available after selecting the start date
let june16 = app.buttons["wizard.dates.day.2026-06-16"]
XCTAssertTrue(june16.waitForExistence(timeout: 5), "June 16 button should exist after selecting start date")
june16.scrollIntoView(in: app.scrollViews.firstMatch)
june16.tap()
// Select MLB
let mlbButton = app.buttons["wizard.sports.mlb"]
mlbButton.scrollIntoView(in: app.scrollViews.firstMatch)
mlbButton.tap()
// Select Central region
let centralRegion = app.buttons["wizard.regions.central"]
centralRegion.scrollIntoView(in: app.scrollViews.firstMatch)
centralRegion.tap()
}
/// Taps "Plan My Trip" after waiting for it to become enabled.
private func tapPlanTrip(app: XCUIApplication) {
let planTripButton = app.buttons["wizard.planTripButton"]
planTripButton.scrollIntoView(in: app.scrollViews.firstMatch)
let enabledPred = NSPredicate(format: "isEnabled == true")
let enabledExp = XCTNSPredicateExpectation(predicate: enabledPred, object: planTripButton)
let waitResult = XCTWaiter.wait(for: [enabledExp], timeout: 10)
XCTAssertEqual(waitResult, .completed, "Plan My Trip should become enabled")
planTripButton.tap()
}
/// Selects "Most Games" sort option from the sort dropdown.
private func selectMostGamesSort(app: XCUIApplication) {
let sortDropdown = app.buttons["tripOptions.sortDropdown"]
XCTAssertTrue(sortDropdown.waitForExistence(timeout: 30), "Sort dropdown should exist")
sortDropdown.tap()
// Wait for the sort option to appear
let mostGamesOption = app.buttons["tripOptions.sortOption.mostgames"]
do {
let found = mostGamesOption.waitForExistence(timeout: 3)
if found {
mostGamesOption.tap()
} else {
XCTFail("Expected element with accessibility ID 'tripOptions.sortOption.mostgames' but it did not appear")
}
}
// Wait for sort to be applied and trip list to update
let anyTrip = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'tripOptions.trip.'")).firstMatch
XCTAssertTrue(anyTrip.waitForExistence(timeout: 10), "Trip options should appear after sorting")
}
/// Selects the first available trip option and waits for the detail view to load.
private func selectFirstTrip(app: XCUIApplication) {
let anyTrip = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'tripOptions.trip.'")).firstMatch
XCTAssertTrue(anyTrip.waitForExistence(timeout: 10), "At least one trip option should exist")
anyTrip.tap()
// Wait for the trip detail view to load
let favoriteButton = app.buttons["tripDetail.favoriteButton"]
XCTAssertTrue(favoriteButton.waitForExistence(timeout: 10), "Trip detail view should load with favorite button")
}
// MARK: - Demo Flow Test (Continuous Scroll Mode)
/// Complete trip planning demo with continuous smooth scrolling.
///
/// In demo mode, the app auto-selects each step as it appears on screen:
/// - Planning Mode: "By Dates"
/// - Dates: June 11-16, 2026
/// - Sport: MLB
/// - Region: Central US
/// - Sort: Most Games
/// - Trip: 4th option
/// - Action: Auto-favorite
///
/// The test just needs to:
/// 1. Launch with -DemoMode argument
/// 2. Tap "Start Planning"
/// 3. Continuously scroll - items auto-select as they appear
/// 4. Wait for transitions to complete
@MainActor
func testTripPlanningDemoFlow() throws {
let app = launchAndStartPlanning()
fillWizardSteps(app: app)
tapPlanTrip(app: app)
selectMostGamesSort(app: app)
selectFirstTrip(app: app)
// Scroll through itinerary
for _ in 1...5 {
slowSwipeUp(app: app)
// Wait for scroll content to settle
let scrollView = app.scrollViews.firstMatch
_ = scrollView.waitForExistence(timeout: 3)
}
// Favorite the trip
let favoriteButton = app.buttons["tripDetail.favoriteButton"]
favoriteButton.scrollIntoView(in: app.scrollViews.firstMatch, direction: .up)
XCTAssertTrue(favoriteButton.exists, "Favorite button should exist")
favoriteButton.tap()
// Wait for favorite state to update
let favoritedPredicate = NSPredicate(format: "isSelected == true OR label CONTAINS 'Favorited' OR label CONTAINS 'Unfavorite'")
let favoritedExpectation = XCTNSPredicateExpectation(predicate: favoritedPredicate, object: favoriteButton)
let result = XCTWaiter.wait(for: [favoritedExpectation], timeout: 5)
// If the button doesn't change state visually, at least verify it still exists
if result != .completed {
XCTAssertTrue(favoriteButton.exists, "Favorite button should still exist after tapping")
}
}
// MARK: - Manual Demo Flow Test (Original)
/// Original manual test flow for comparison or when demo mode is not desired.
/// This test verifies the same wizard flow as the demo test, plus additional
/// scrolling through the itinerary detail view.
@MainActor
func testTripPlanningManualFlow() throws {
let app = launchAndStartPlanning()
fillWizardSteps(app: app)
tapPlanTrip(app: app)
selectMostGamesSort(app: app)
selectFirstTrip(app: app)
// Scroll through itinerary
for _ in 1...5 {
slowSwipeUp(app: app)
// Wait for scroll content to settle
let scrollView = app.scrollViews.firstMatch
_ = scrollView.waitForExistence(timeout: 3)
}
// Favorite the trip
let favoriteButton = app.buttons["tripDetail.favoriteButton"]
favoriteButton.scrollIntoView(in: app.scrollViews.firstMatch, direction: .up)
XCTAssertTrue(favoriteButton.exists, "Favorite button should exist")
favoriteButton.tap()
// Wait for favorite state to update
let favoritedPredicate = NSPredicate(format: "isSelected == true OR label CONTAINS 'Favorited' OR label CONTAINS 'Unfavorite'")
let favoritedExpectation = XCTNSPredicateExpectation(predicate: favoritedPredicate, object: favoriteButton)
let result = XCTWaiter.wait(for: [favoritedExpectation], timeout: 5)
// If the button doesn't change state visually, at least verify it still exists
if result != .completed {
XCTAssertTrue(favoriteButton.exists, "Favorite button should still exist after tapping")
}
}
// MARK: - Helper Methods
/// Performs a slow swipe up gesture for smooth scrolling
private func slowSwipeUp(app: XCUIApplication) {
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1)
}
/// Performs a slow swipe down gesture for smooth scrolling
private func slowSwipeDown(app: XCUIApplication) {
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1)
}
// MARK: - Basic Tests
@MainActor
func testExample() throws {
let app = XCUIApplication()
app.launch()
}
@MainActor
func testLaunchPerformance() throws {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}