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

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