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:
Trey t
2026-02-16 16:23:59 -06:00
parent 787a0f795e
commit d53f222489
16 changed files with 1528 additions and 25 deletions

View File

@@ -61,11 +61,28 @@ enum DemoConfig {
static let demoTripIndex: Int = 3
}
// MARK: - Demo Mode Launch Argument
// MARK: - Launch Arguments
extension ProcessInfo {
/// Check if app was launched in demo mode
static var isDemoMode: Bool {
ProcessInfo.processInfo.arguments.contains("-DemoMode")
}
/// Check if app was launched for UI testing.
/// When true, the app suppresses non-essential popups, disables analytics
/// and CloudKit sync, forces Pro mode, and disables animations.
static var isUITesting: Bool {
ProcessInfo.processInfo.arguments.contains("--ui-testing")
}
/// Check if state should be reset before the test run.
static var shouldResetState: Bool {
ProcessInfo.processInfo.arguments.contains("--reset-state")
}
/// Check if animations should be disabled.
static var shouldDisableAnimations: Bool {
ProcessInfo.processInfo.arguments.contains("--disable-animations")
}
}

View File

@@ -52,6 +52,7 @@ struct HomeView: View {
Label("Home", systemImage: "house.fill")
}
.tag(0)
.accessibilityIdentifier("tab.home")
// Schedule Tab
NavigationStack {
@@ -61,6 +62,7 @@ struct HomeView: View {
Label("Schedule", systemImage: "calendar")
}
.tag(1)
.accessibilityIdentifier("tab.schedule")
// My Trips Tab
NavigationStack {
@@ -70,6 +72,7 @@ struct HomeView: View {
Label("My Trips", systemImage: "suitcase.fill")
}
.tag(2)
.accessibilityIdentifier("tab.myTrips")
// Progress Tab
NavigationStack {
@@ -85,6 +88,7 @@ struct HomeView: View {
Label("Progress", systemImage: "chart.bar.fill")
}
.tag(3)
.accessibilityIdentifier("tab.progress")
// Settings Tab
NavigationStack {
@@ -94,6 +98,7 @@ struct HomeView: View {
Label("Settings", systemImage: "gear")
}
.tag(4)
.accessibilityIdentifier("tab.settings")
}
.tint(Theme.warmOrange)
.onChange(of: selectedTab) { oldTab, newTab in
@@ -633,6 +638,7 @@ struct SavedTripsListView: View {
.padding(Theme.Spacing.xl)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.accessibilityIdentifier("myTrips.emptyState")
} else {
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {

View File

@@ -82,6 +82,7 @@ struct HomeContent_ClassicAnimated: View {
}
.pressableStyle()
.glowEffect(color: Theme.warmOrange, radius: 12)
.accessibilityIdentifier("home.startPlanningButton")
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))

View File

@@ -317,6 +317,7 @@ struct SettingsView: View {
.accessibilityHidden(true)
}
}
.accessibilityIdentifier("settings.analyticsToggle")
} header: {
Text("Privacy")
} footer: {
@@ -334,6 +335,7 @@ struct SettingsView: View {
Spacer()
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
.foregroundStyle(.secondary)
.accessibilityIdentifier("settings.versionLabel")
}
Link(destination: URL(string: "https://sportstime.88oakapps.com/privacy.html")!) {

View File

@@ -19,6 +19,15 @@ struct SportsTimeApp: App {
private var transactionListener: Task<Void, Never>?
init() {
// UI Test Mode: disable animations and force classic style for deterministic tests
if ProcessInfo.isUITesting || ProcessInfo.shouldDisableAnimations {
UIView.setAnimationsEnabled(false)
}
if ProcessInfo.isUITesting {
// Force classic (non-animated) home variant for consistent identifiers
DesignStyleManager.shared.setStyle(.classic)
}
// Configure sync manager immediately so push/background triggers can sync.
BackgroundSyncManager.shared.configure(with: sharedModelContainer)
@@ -27,7 +36,9 @@ struct SportsTimeApp: App {
BackgroundSyncManager.shared.registerTasks()
// Start listening for transactions immediately
transactionListener = StoreManager.shared.listenForTransactions()
if !ProcessInfo.isUITesting {
transactionListener = StoreManager.shared.listenForTransactions()
}
}
var sharedModelContainer: ModelContainer = {
@@ -93,7 +104,8 @@ struct BootstrappedContentView: View {
@State private var appearanceManager = AppearanceManager.shared
private var shouldShowOnboardingPaywall: Bool {
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
guard !ProcessInfo.isUITesting else { return false }
return !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
}
var body: some View {
@@ -136,6 +148,7 @@ struct BootstrappedContentView: View {
deepLinkHandler.handleURL(url)
}
.onChange(of: scenePhase) { _, newPhase in
guard !ProcessInfo.isUITesting else { return }
switch newPhase {
case .active:
// Refresh super properties (subscription status may have changed)
@@ -170,6 +183,18 @@ struct BootstrappedContentView: View {
let bootstrapService = BootstrapService()
do {
// 0. UI Test Mode: reset user data if requested
if ProcessInfo.shouldResetState {
print("🚀 [BOOT] Step 0: Resetting user data for UI tests...")
try context.delete(model: SavedTrip.self)
try context.delete(model: StadiumVisit.self)
try context.delete(model: Achievement.self)
try context.delete(model: LocalTripPoll.self)
try context.delete(model: LocalPollVote.self)
try context.delete(model: TripVote.self)
try context.save()
}
// 1. Bootstrap from bundled JSON if first launch (no data exists)
print("🚀 [BOOT] Step 1: Checking if bootstrap needed...")
try await bootstrapService.bootstrapIfNeeded(context: context)
@@ -192,40 +217,61 @@ struct BootstrappedContentView: View {
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
// 5. Load store products and entitlements
print("🚀 [BOOT] Step 5: Loading store products...")
await StoreManager.shared.loadProducts()
await StoreManager.shared.updateEntitlements()
if ProcessInfo.isUITesting {
print("🚀 [BOOT] Step 5: UI Test Mode — forcing Pro, skipping StoreKit")
#if DEBUG
StoreManager.shared.debugProOverride = true
#endif
} else {
print("🚀 [BOOT] Step 5: Loading store products...")
await StoreManager.shared.loadProducts()
await StoreManager.shared.updateEntitlements()
}
// 6. Start network monitoring and wire up sync callback
print("🚀 [BOOT] Step 6: Starting network monitoring...")
NetworkMonitor.shared.onSyncNeeded = {
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
if !ProcessInfo.isUITesting {
print("🚀 [BOOT] Step 6: Starting network monitoring...")
NetworkMonitor.shared.onSyncNeeded = {
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
}
NetworkMonitor.shared.startMonitoring()
} else {
print("🚀 [BOOT] Step 6: UI Test Mode — skipping network monitoring")
}
NetworkMonitor.shared.startMonitoring()
// 7. Configure analytics
print("🚀 [BOOT] Step 7: Configuring analytics...")
AnalyticsManager.shared.configure()
if !ProcessInfo.isUITesting {
print("🚀 [BOOT] Step 7: Configuring analytics...")
AnalyticsManager.shared.configure()
} else {
print("🚀 [BOOT] Step 7: UI Test Mode — skipping analytics")
}
// 8. App is now usable
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
isBootstrapping = false
// 9. Schedule background tasks for future syncs
BackgroundSyncManager.shared.scheduleAllTasks()
// 9-10: Background sync (skip in UI test mode)
if !ProcessInfo.isUITesting {
// 9. Schedule background tasks for future syncs
BackgroundSyncManager.shared.scheduleAllTasks()
// 9b. Ensure CloudKit subscriptions exist for push-driven sync.
Task(priority: .utility) {
await BackgroundSyncManager.shared.ensureCanonicalSubscriptions()
}
// 10. Background: Try to refresh from CloudKit (non-blocking)
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
Task(priority: .background) {
await self.performBackgroundSync(context: self.modelContainer.mainContext)
await MainActor.run {
self.hasCompletedInitialSync = true
// 9b. Ensure CloudKit subscriptions exist for push-driven sync.
Task(priority: .utility) {
await BackgroundSyncManager.shared.ensureCanonicalSubscriptions()
}
// 10. Background: Try to refresh from CloudKit (non-blocking)
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
Task(priority: .background) {
await self.performBackgroundSync(context: self.modelContainer.mainContext)
await MainActor.run {
self.hasCompletedInitialSync = true
}
}
} else {
print("🚀 [BOOT] Steps 9-10: UI Test Mode — skipping CloudKit sync")
hasCompletedInitialSync = true
}
} catch {
print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)")