Harden test harness and UI suite
This commit is contained in:
@@ -22,6 +22,6 @@ This file is for AI/code agents working in this repo.
|
|||||||
|
|
||||||
- Run the touched test class.
|
- Run the touched test class.
|
||||||
- Run full UI suite:
|
- Run full UI suite:
|
||||||
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests`
|
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO -only-testing:SportsTimeUITests`
|
||||||
- Run full scheme verification if behavior touched shared flows:
|
- Run full scheme verification if behavior touched shared flows:
|
||||||
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO`
|
- `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO`
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -6,19 +6,19 @@ iOS app for planning multi-stop sports road trips. Offline-first architecture wi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the iOS app
|
# Build the iOS app
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' build
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' test
|
||||||
|
|
||||||
# Run specific test suite
|
# Run specific test suite
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:SportsTimeTests/TripPlanningEngineTests test
|
||||||
|
|
||||||
# Run a single test
|
# Run a single test
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests/planningMode_dateRange test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:SportsTimeTests/TripPlanningEngineTests/planningMode_dateRange test
|
||||||
|
|
||||||
# Run UI tests only
|
# Run UI tests only
|
||||||
xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests
|
xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -parallel-testing-enabled NO -only-testing:SportsTimeUITests
|
||||||
|
|
||||||
# Data scraping (Python)
|
# Data scraping (Python)
|
||||||
cd Scripts && pip install -r requirements.txt
|
cd Scripts && pip install -r requirements.txt
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ All schedule data flows through `AppDataProvider.shared` - never access CloudKit
|
|||||||
```bash
|
```bash
|
||||||
xcodebuild -project SportsTime.xcodeproj \
|
xcodebuild -project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
build
|
build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,13 +116,13 @@ xcodebuild -project SportsTime.xcodeproj \
|
|||||||
# All tests
|
# All tests
|
||||||
xcodebuild -project SportsTime.xcodeproj \
|
xcodebuild -project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
test
|
test
|
||||||
|
|
||||||
# Specific test suite
|
# Specific test suite
|
||||||
xcodebuild -project SportsTime.xcodeproj \
|
xcodebuild -project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-only-testing:SportsTimeTests/EdgeCaseTests \
|
-only-testing:SportsTimeTests/EdgeCaseTests \
|
||||||
test
|
test
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ xcodebuild -project SportsTime.xcodeproj \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests
|
-only-testing:SportsTimeUITests
|
||||||
```
|
```
|
||||||
|
|||||||
241
SportsTimeTests/Planning/ScenarioEPlannerRealDataTest.swift
Normal file
241
SportsTimeTests/Planning/ScenarioEPlannerRealDataTest.swift
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
//
|
||||||
|
// ScenarioEPlannerRealDataTest.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Regression test: runs ScenarioEPlanner with real PHI/WSN/BAL data
|
||||||
|
// to verify the past-date filtering and full-season coverage fixes.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
@Suite("ScenarioE Real Data Regression")
|
||||||
|
struct ScenarioEPlannerRealDataTest {
|
||||||
|
|
||||||
|
@Test("PHI + WSN + BAL: returns future regular season results, not spring training")
|
||||||
|
func phiWsnBal_returnsFutureRegularSeasonResults() throws {
|
||||||
|
let fixture = try FixtureLoader.loadCanonicalGames()
|
||||||
|
let rawGames = fixture.games
|
||||||
|
|
||||||
|
// Filter to MLB only
|
||||||
|
let mlbRows = rawGames.filter { $0.sport?.lowercased() == "mlb" }
|
||||||
|
let games = mlbRows.compactMap(\.domainGame)
|
||||||
|
let skippedMLBRows = mlbRows.count - games.count
|
||||||
|
#expect(!games.isEmpty, "Expected MLB canonical fixture to contain valid games")
|
||||||
|
|
||||||
|
// Team IDs
|
||||||
|
let teamIds: Set<String> = ["team_mlb_phi", "team_mlb_wsn", "team_mlb_bal"]
|
||||||
|
|
||||||
|
// Build stadium map with real coordinates
|
||||||
|
let stadiums: [String: Stadium] = [
|
||||||
|
"stadium_mlb_citizens_bank_park": Stadium(
|
||||||
|
id: "stadium_mlb_citizens_bank_park", name: "Citizens Bank Park",
|
||||||
|
city: "Philadelphia", state: "PA",
|
||||||
|
latitude: 39.9061, longitude: -75.1665, capacity: 43035, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_nationals_park": Stadium(
|
||||||
|
id: "stadium_mlb_nationals_park", name: "Nationals Park",
|
||||||
|
city: "Washington", state: "DC",
|
||||||
|
latitude: 38.8730, longitude: -77.0074, capacity: 41339, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_oriole_park_at_camden_yards": Stadium(
|
||||||
|
id: "stadium_mlb_oriole_park_at_camden_yards", name: "Oriole Park at Camden Yards",
|
||||||
|
city: "Baltimore", state: "MD",
|
||||||
|
latitude: 39.2838, longitude: -76.6216, capacity: 45971, sport: .mlb
|
||||||
|
),
|
||||||
|
// Spring training stadiums (should be excluded by date filter)
|
||||||
|
"stadium_mlb_spring_baycare_ballpark": Stadium(
|
||||||
|
id: "stadium_mlb_spring_baycare_ballpark", name: "BayCare Ballpark",
|
||||||
|
city: "Clearwater", state: "FL",
|
||||||
|
latitude: 27.9781, longitude: -82.7337, capacity: 8500, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_spring_cacti_park": Stadium(
|
||||||
|
id: "stadium_mlb_spring_cacti_park", name: "CACTI Park",
|
||||||
|
city: "West Palm Beach", state: "FL",
|
||||||
|
latitude: 26.7367, longitude: -80.1197, capacity: 6671, sport: .mlb
|
||||||
|
),
|
||||||
|
"stadium_mlb_spring_ed_smith_stadium": Stadium(
|
||||||
|
id: "stadium_mlb_spring_ed_smith_stadium", name: "Ed Smith Stadium",
|
||||||
|
city: "Sarasota", state: "FL",
|
||||||
|
latitude: 27.3381, longitude: -82.5226, capacity: 8500, sport: .mlb
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Build team map
|
||||||
|
let teams: [String: Team] = [
|
||||||
|
"team_mlb_phi": Team(id: "team_mlb_phi", name: "Phillies", abbreviation: "PHI",
|
||||||
|
sport: .mlb, city: "Philadelphia",
|
||||||
|
stadiumId: "stadium_mlb_citizens_bank_park",
|
||||||
|
primaryColor: "#E81828", secondaryColor: "#002D72"),
|
||||||
|
"team_mlb_wsn": Team(id: "team_mlb_wsn", name: "Nationals", abbreviation: "WSN",
|
||||||
|
sport: .mlb, city: "Washington",
|
||||||
|
stadiumId: "stadium_mlb_nationals_park",
|
||||||
|
primaryColor: "#AB0003", secondaryColor: "#14225A"),
|
||||||
|
"team_mlb_bal": Team(id: "team_mlb_bal", name: "Orioles", abbreviation: "BAL",
|
||||||
|
sport: .mlb, city: "Baltimore",
|
||||||
|
stadiumId: "stadium_mlb_oriole_park_at_camden_yards",
|
||||||
|
primaryColor: "#DF4601", secondaryColor: "#000000"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let prefs = TripPreferences(
|
||||||
|
planningMode: .teamFirst,
|
||||||
|
sports: [.mlb],
|
||||||
|
selectedTeamIds: teamIds
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: prefs,
|
||||||
|
availableGames: games,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use April 2, 2026 as current date (the date the bug was reported)
|
||||||
|
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 2, hour: 12)
|
||||||
|
let planner = ScenarioEPlanner(currentDate: currentDate)
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
guard case .success(let options) = result else {
|
||||||
|
if case .failure(let failure) = result {
|
||||||
|
Issue.record("Expected success but got failure: \(failure.reason) — \(failure.violations.map(\.description))")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!options.isEmpty, "Should find trip options for PHI/WSN/BAL")
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var output = "\nRESULT> Fixture: \(fixture.url.path)\n"
|
||||||
|
output += "RESULT> MLB rows: \(mlbRows.count), skipped malformed MLB rows: \(skippedMLBRows)\n"
|
||||||
|
output += "RESULT> ========================================\n"
|
||||||
|
output += "RESULT> PHI + WSN + BAL TRIP OPTIONS (\(options.count) results)\n"
|
||||||
|
output += "RESULT> ========================================\n\n"
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
let cities = option.stops.map { "\($0.city)" }.joined(separator: " → ")
|
||||||
|
let dates = option.stops.map { stop in
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d"
|
||||||
|
return formatter.string(from: stop.arrivalDate)
|
||||||
|
}.joined(separator: " → ")
|
||||||
|
|
||||||
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
|
let miles = Int(option.totalDistanceMiles)
|
||||||
|
let hours = String(format: "%.1f", option.totalDrivingHours)
|
||||||
|
|
||||||
|
output += "RESULT> Option #\(option.rank): \(cities)\n"
|
||||||
|
output += "RESULT> Dates: \(dates)\n"
|
||||||
|
output += "RESULT> Driving: \(miles) mi, \(hours) hrs\n"
|
||||||
|
output += "RESULT> Games: \(gameIds.count)\n"
|
||||||
|
output += "RESULT> Rationale: \(option.geographicRationale)\n"
|
||||||
|
|
||||||
|
// Verify all games are in the future
|
||||||
|
for stop in option.stops {
|
||||||
|
#expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate),
|
||||||
|
"Stop in \(stop.city) on \(stop.arrivalDate) should be after April 2, 2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no spring training stadiums
|
||||||
|
for stop in option.stops {
|
||||||
|
let isSpringTraining = stop.city == "Clearwater" || stop.city == "Sarasota" || stop.city == "West Palm Beach"
|
||||||
|
#expect(!isSpringTraining, "Should not include spring training city: \(stop.city)")
|
||||||
|
}
|
||||||
|
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify temporal spread — results should not all be in April
|
||||||
|
let months = Set(options.flatMap { $0.stops.map { calendar.component(.month, from: $0.arrivalDate) } })
|
||||||
|
output += "RESULT> Months covered: \(months.sorted().map { DateFormatter().monthSymbols[$0 - 1] })\n"
|
||||||
|
FileHandle.standardOutput.write(Data(output.utf8))
|
||||||
|
#expect(months.count >= 2, "Results should span multiple months across the season")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Decoding Helpers
|
||||||
|
|
||||||
|
private struct CanonicalGameJSON: Decodable {
|
||||||
|
let canonical_id: String?
|
||||||
|
let sport: String?
|
||||||
|
let season: String?
|
||||||
|
let game_datetime_utc: String?
|
||||||
|
let home_team_canonical_id: String?
|
||||||
|
let away_team_canonical_id: String?
|
||||||
|
let stadium_canonical_id: String?
|
||||||
|
let is_playoff: Bool?
|
||||||
|
|
||||||
|
var parsedDate: Date {
|
||||||
|
guard let game_datetime_utc else { return Date.distantPast }
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = formatter.date(from: game_datetime_utc) { return date }
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: game_datetime_utc) ?? Date.distantPast
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainGame: Game? {
|
||||||
|
guard let canonical_id,
|
||||||
|
let home_team_canonical_id,
|
||||||
|
let away_team_canonical_id,
|
||||||
|
let stadium_canonical_id,
|
||||||
|
let season else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Game(
|
||||||
|
id: canonical_id,
|
||||||
|
homeTeamId: home_team_canonical_id,
|
||||||
|
awayTeamId: away_team_canonical_id,
|
||||||
|
stadiumId: stadium_canonical_id,
|
||||||
|
dateTime: parsedDate,
|
||||||
|
sport: .mlb,
|
||||||
|
season: season,
|
||||||
|
isPlayoff: is_playoff ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FixtureLoader {
|
||||||
|
struct LoadedFixture {
|
||||||
|
let url: URL
|
||||||
|
let games: [CanonicalGameJSON]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadCanonicalGames() throws -> LoadedFixture {
|
||||||
|
let candidateURLs = [
|
||||||
|
repositoryRoot.appendingPathComponent("sportstime_export/games_canonical.json"),
|
||||||
|
Bundle(for: BundleToken.self).url(forResource: "games_canonical", withExtension: "json")
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
for url in candidateURLs where FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let games = try JSONDecoder().decode([CanonicalGameJSON].self, from: data)
|
||||||
|
return LoadedFixture(url: url, games: games)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw FixtureLoadError.notFound(candidateURLs.map(\.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var repositoryRoot: URL {
|
||||||
|
URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FixtureLoadError: LocalizedError {
|
||||||
|
case notFound([String])
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notFound(let paths):
|
||||||
|
let joined = paths.joined(separator: ", ")
|
||||||
|
return "Could not find games_canonical.json. Checked: \(joined)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BundleToken {}
|
||||||
@@ -59,6 +59,33 @@ class BaseUITestCase: XCTestCase {
|
|||||||
screenshot.lifetime = .keepAlways
|
screenshot.lifetime = .keepAlways
|
||||||
add(screenshot)
|
add(screenshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls until the condition becomes true or the timeout expires.
|
||||||
|
@discardableResult
|
||||||
|
func waitUntil(
|
||||||
|
timeout: TimeInterval = BaseUITestCase.defaultTimeout,
|
||||||
|
pollInterval: TimeInterval = 0.2,
|
||||||
|
_ message: String? = nil,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line,
|
||||||
|
condition: @escaping () -> Bool
|
||||||
|
) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
|
while Date() < deadline {
|
||||||
|
if condition() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = deadline.timeIntervalSinceNow
|
||||||
|
let interval = min(pollInterval, max(0.01, remaining))
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(interval))
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = condition()
|
||||||
|
XCTAssertTrue(success, message ?? "Condition was not met within \(timeout)s", file: file, line: line)
|
||||||
|
return success
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wait Helpers
|
// MARK: - Wait Helpers
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ final class AppLaunchTests: BaseUITestCase {
|
|||||||
|
|
||||||
// Background the app
|
// Background the app
|
||||||
XCUIDevice.shared.press(.home)
|
XCUIDevice.shared.press(.home)
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Foreground
|
// Foreground
|
||||||
app.activate()
|
app.activate()
|
||||||
|
waitUntil(timeout: BaseUITestCase.longTimeout, "App should return to the foreground") {
|
||||||
|
self.app.state == .runningForeground
|
||||||
|
}
|
||||||
|
|
||||||
// Assert: Home still loaded, no re-bootstrap
|
// Assert: Home still loaded, no re-bootstrap
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
|
|||||||
@@ -85,12 +85,10 @@ final class HomeTests: BaseUITestCase {
|
|||||||
// Tap refresh and verify no crash
|
// Tap refresh and verify no crash
|
||||||
refreshButton.tap()
|
refreshButton.tap()
|
||||||
|
|
||||||
// Wait briefly for reload
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Featured section should still exist after refresh
|
// Featured section should still exist after refresh
|
||||||
XCTAssertTrue(section.exists,
|
waitUntil(timeout: BaseUITestCase.longTimeout, "Featured trips section should remain after refresh") {
|
||||||
"Featured trips section should remain after refresh")
|
section.exists && refreshButton.exists
|
||||||
|
}
|
||||||
|
|
||||||
captureScreenshot(named: "F015-FeaturedTripsRefresh")
|
captureScreenshot(named: "F015-FeaturedTripsRefresh")
|
||||||
}
|
}
|
||||||
@@ -307,14 +305,17 @@ final class HomeTests: BaseUITestCase {
|
|||||||
home.waitForLoad()
|
home.waitForLoad()
|
||||||
home.switchToTab(home.myTripsTab)
|
home.switchToTab(home.myTripsTab)
|
||||||
|
|
||||||
// Wait briefly for My Trips content to load
|
let myTrips = MyTripsScreen(app: app)
|
||||||
sleep(1)
|
waitUntil(timeout: BaseUITestCase.longTimeout, "My Trips should load before refreshing") {
|
||||||
|
myTrips.emptyState.exists || myTrips.tripCard(0).exists || self.app.staticTexts["Group Polls"].exists
|
||||||
|
}
|
||||||
|
|
||||||
// Pull down to refresh
|
// Pull down to refresh
|
||||||
app.swipeDown(velocity: .slow)
|
app.swipeDown(velocity: .slow)
|
||||||
|
|
||||||
// Wait for any refresh to complete
|
waitUntil(timeout: BaseUITestCase.longTimeout, "My Trips should still be loaded after pull to refresh") {
|
||||||
sleep(2)
|
myTrips.emptyState.exists || myTrips.tripCard(0).exists || self.app.staticTexts["Group Polls"].exists
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the tab is still functional (no crash)
|
// Verify the tab is still functional (no crash)
|
||||||
let groupPolls = app.staticTexts["Group Polls"]
|
let groupPolls = app.staticTexts["Group Polls"]
|
||||||
|
|||||||
@@ -206,22 +206,14 @@ final class ProgressTests: BaseUITestCase {
|
|||||||
visitSheet.tapSave()
|
visitSheet.tapSave()
|
||||||
visitSheet.navigationBar.waitForNonExistence(timeout: BaseUITestCase.defaultTimeout)
|
visitSheet.navigationBar.waitForNonExistence(timeout: BaseUITestCase.defaultTimeout)
|
||||||
|
|
||||||
// Wait for data to reload
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Progress should have updated — verify the progress circle label changed
|
// Progress should have updated — verify the progress circle label changed
|
||||||
let updatedCircle = app.descendants(matching: .any).matching(NSPredicate(
|
let updatedCircle = app.descendants(matching: .any).matching(NSPredicate(
|
||||||
format: "label CONTAINS 'stadiums visited'"
|
format: "label CONTAINS 'stadiums visited'"
|
||||||
)).firstMatch
|
)).firstMatch
|
||||||
|
|
||||||
XCTAssertTrue(updatedCircle.waitForExistence(timeout: BaseUITestCase.longTimeout),
|
waitUntil(timeout: BaseUITestCase.longTimeout, "Progress label should update after adding a visit") {
|
||||||
"Progress circle should exist after adding a visit")
|
guard updatedCircle.exists else { return false }
|
||||||
|
return initialLabel.isEmpty || updatedCircle.label != initialLabel
|
||||||
// If we had an initial label, verify it changed
|
|
||||||
if !initialLabel.isEmpty {
|
|
||||||
// The new label should have a higher visited count
|
|
||||||
XCTAssertNotEqual(updatedCircle.label, initialLabel,
|
|
||||||
"Progress label should update after adding a visit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
captureScreenshot(named: "F099-ProgressPercentageUpdated")
|
captureScreenshot(named: "F099-ProgressPercentageUpdated")
|
||||||
|
|||||||
@@ -127,9 +127,10 @@ final class ScheduleTests: BaseUITestCase {
|
|||||||
"Search field should exist")
|
"Search field should exist")
|
||||||
searchField.tap()
|
searchField.tap()
|
||||||
searchField.typeText("Yankees")
|
searchField.typeText("Yankees")
|
||||||
|
XCTAssertTrue(
|
||||||
// Wait for results to filter
|
((searchField.value as? String) ?? "").contains("Yankees"),
|
||||||
sleep(1)
|
"Search field should contain the typed team name"
|
||||||
|
)
|
||||||
|
|
||||||
captureScreenshot(named: "F089-SearchByTeam")
|
captureScreenshot(named: "F089-SearchByTeam")
|
||||||
}
|
}
|
||||||
@@ -150,9 +151,10 @@ final class ScheduleTests: BaseUITestCase {
|
|||||||
"Search field should exist")
|
"Search field should exist")
|
||||||
searchField.tap()
|
searchField.tap()
|
||||||
searchField.typeText("Wrigley")
|
searchField.typeText("Wrigley")
|
||||||
|
XCTAssertTrue(
|
||||||
// Wait for results to filter
|
((searchField.value as? String) ?? "").contains("Wrigley"),
|
||||||
sleep(1)
|
"Search field should contain the typed venue name"
|
||||||
|
)
|
||||||
|
|
||||||
captureScreenshot(named: "F090-SearchByVenue")
|
captureScreenshot(named: "F090-SearchByVenue")
|
||||||
}
|
}
|
||||||
@@ -174,19 +176,15 @@ final class ScheduleTests: BaseUITestCase {
|
|||||||
searchField.tap()
|
searchField.tap()
|
||||||
searchField.typeText("ZZZZNONEXISTENTTEAMZZZZ")
|
searchField.typeText("ZZZZNONEXISTENTTEAMZZZZ")
|
||||||
|
|
||||||
// Wait for empty state
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Empty state or "no results" text should appear
|
// Empty state or "no results" text should appear
|
||||||
let emptyState = schedule.emptyState
|
let emptyState = schedule.emptyState
|
||||||
let noResults = app.staticTexts.matching(NSPredicate(
|
let noResults = app.staticTexts.matching(NSPredicate(
|
||||||
format: "label CONTAINS[c] 'no' AND label CONTAINS[c] 'game'"
|
format: "label CONTAINS[c] 'no' AND label CONTAINS[c] 'game'"
|
||||||
)).firstMatch
|
)).firstMatch
|
||||||
|
|
||||||
let hasEmptyIndicator = emptyState.waitForExistence(timeout: BaseUITestCase.shortTimeout)
|
waitUntil(timeout: BaseUITestCase.shortTimeout, "Empty state should appear when no games match search") {
|
||||||
|| noResults.waitForExistence(timeout: BaseUITestCase.shortTimeout)
|
emptyState.exists || noResults.exists
|
||||||
XCTAssertTrue(hasEmptyIndicator,
|
}
|
||||||
"Empty state should appear when no games match search")
|
|
||||||
|
|
||||||
captureScreenshot(named: "F092-ScheduleEmptyState")
|
captureScreenshot(named: "F092-ScheduleEmptyState")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,10 +162,10 @@ final class SettingsTests: BaseUITestCase {
|
|||||||
let switchCoord = toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
|
let switchCoord = toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
|
||||||
switchCoord.tap()
|
switchCoord.tap()
|
||||||
|
|
||||||
// Small wait for the toggle animation to complete
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Value should have changed
|
// Value should have changed
|
||||||
|
waitUntil(timeout: BaseUITestCase.shortTimeout, "Toggle value should change after tapping the switch") {
|
||||||
|
(toggle.value as? String) != initialValue
|
||||||
|
}
|
||||||
let newValue = toggle.value as? String
|
let newValue = toggle.value as? String
|
||||||
XCTAssertNotEqual(initialValue, newValue,
|
XCTAssertNotEqual(initialValue, newValue,
|
||||||
"Toggle value should change after tap (was '\(initialValue ?? "nil")', now '\(newValue ?? "nil")')")
|
"Toggle value should change after tap (was '\(initialValue ?? "nil")', now '\(newValue ?? "nil")')")
|
||||||
|
|||||||
@@ -157,8 +157,11 @@ final class TripOptionsTests: BaseUITestCase {
|
|||||||
"'5' cities filter button should exist")
|
"'5' cities filter button should exist")
|
||||||
fiveCitiesButton.tap()
|
fiveCitiesButton.tap()
|
||||||
|
|
||||||
// Results should update; verify no crash
|
let firstTrip = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'tripOptions.trip.'")).firstMatch
|
||||||
sleep(1)
|
XCTAssertTrue(
|
||||||
|
firstTrip.waitForExistence(timeout: BaseUITestCase.shortTimeout),
|
||||||
|
"Trip results should remain visible after applying the cities filter"
|
||||||
|
)
|
||||||
|
|
||||||
captureScreenshot(named: "F057-CitiesFilter-5")
|
captureScreenshot(named: "F057-CitiesFilter-5")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Do not add one-off test-only branching logic unless it removes a real flake.
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests/TripWizardFlowTests/testF026_DateRangeSelection
|
-only-testing:SportsTimeUITests/TripWizardFlowTests/testF026_DateRangeSelection
|
||||||
```
|
```
|
||||||
@@ -74,7 +74,7 @@ xcodebuild test-without-building \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests/TripOptionsTests
|
-only-testing:SportsTimeUITests/TripOptionsTests
|
||||||
```
|
```
|
||||||
@@ -85,7 +85,7 @@ xcodebuild test-without-building \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO \
|
-parallel-testing-enabled NO \
|
||||||
-only-testing:SportsTimeUITests
|
-only-testing:SportsTimeUITests
|
||||||
```
|
```
|
||||||
@@ -96,7 +96,7 @@ xcodebuild test-without-building \
|
|||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-project SportsTime.xcodeproj \
|
-project SportsTime.xcodeproj \
|
||||||
-scheme SportsTime \
|
-scheme SportsTime \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \
|
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
|
||||||
-parallel-testing-enabled NO
|
-parallel-testing-enabled NO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ If a candidate looks flaky/high-risk, skip it and explain why.
|
|||||||
|
|
||||||
Use this destination:
|
Use this destination:
|
||||||
|
|
||||||
`-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2'`
|
`-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'`
|
||||||
|
|
||||||
Run each selected test explicitly:
|
Run each selected test explicitly:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user