From d53f222489e731a3fb559769e001fab9e54629fe Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 16 Feb 2026 16:23:59 -0600 Subject: [PATCH] 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 --- SportsTime/Core/Theme/DemoMode.swift | 19 +- SportsTime/Features/Home/Views/HomeView.swift | 6 + .../Classic/HomeContent_ClassicAnimated.swift | 1 + .../Settings/Views/SettingsView.swift | 2 + SportsTime/SportsTimeApp.swift | 94 ++- .../Framework/BaseUITestCase.swift | 139 +++++ SportsTimeUITests/Framework/Screens.swift | 410 +++++++++++++ .../Tests/AccessibilityTests.swift | 46 ++ SportsTimeUITests/Tests/AppLaunchTests.swift | 38 ++ SportsTimeUITests/Tests/ScheduleTests.swift | 24 + SportsTimeUITests/Tests/SettingsTests.swift | 48 ++ .../Tests/TabNavigationTests.swift | 47 ++ SportsTimeUITests/Tests/TripSavingTests.swift | 66 +++ .../Tests/TripWizardFlowTests.swift | 71 +++ docs/SportsTime_QA_Test_Plan.xlsx | Bin 0 -> 32196 bytes docs/generate_qa_sheet.py | 542 ++++++++++++++++++ 16 files changed, 1528 insertions(+), 25 deletions(-) create mode 100644 SportsTimeUITests/Framework/BaseUITestCase.swift create mode 100644 SportsTimeUITests/Framework/Screens.swift create mode 100644 SportsTimeUITests/Tests/AccessibilityTests.swift create mode 100644 SportsTimeUITests/Tests/AppLaunchTests.swift create mode 100644 SportsTimeUITests/Tests/ScheduleTests.swift create mode 100644 SportsTimeUITests/Tests/SettingsTests.swift create mode 100644 SportsTimeUITests/Tests/TabNavigationTests.swift create mode 100644 SportsTimeUITests/Tests/TripSavingTests.swift create mode 100644 SportsTimeUITests/Tests/TripWizardFlowTests.swift create mode 100644 docs/SportsTime_QA_Test_Plan.xlsx create mode 100644 docs/generate_qa_sheet.py diff --git a/SportsTime/Core/Theme/DemoMode.swift b/SportsTime/Core/Theme/DemoMode.swift index 4d5f62e..5bbb705 100644 --- a/SportsTime/Core/Theme/DemoMode.swift +++ b/SportsTime/Core/Theme/DemoMode.swift @@ -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") + } } diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 81d34eb..013aab7 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -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 { diff --git a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift index f46e76e..f7c8ca8 100644 --- a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift +++ b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift @@ -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)) diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 87ce65a..e27f3b2 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -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")!) { diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index d7e5665..b464682 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -19,6 +19,15 @@ struct SportsTimeApp: App { private var transactionListener: Task? 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)") diff --git a/SportsTimeUITests/Framework/BaseUITestCase.swift b/SportsTimeUITests/Framework/BaseUITestCase.swift new file mode 100644 index 0000000..781ffb6 --- /dev/null +++ b/SportsTimeUITests/Framework/BaseUITestCase.swift @@ -0,0 +1,139 @@ +// +// BaseUITestCase.swift +// SportsTimeUITests +// +// Base class for all UI tests. Provides launch configuration, +// screenshot-on-failure, and centralized wait helpers. +// + +import XCTest + +// MARK: - Base Test Case + +class BaseUITestCase: XCTestCase { + + /// The application under test. Configured in setUp. + var app: XCUIApplication! + + /// Standard timeout for element existence checks. + static let defaultTimeout: TimeInterval = 15 + + /// Short timeout for elements expected to appear quickly. + static let shortTimeout: TimeInterval = 5 + + /// Extended timeout for bootstrap / planning engine operations. + static let longTimeout: TimeInterval = 30 + + override func setUpWithError() throws { + continueAfterFailure = false + + app = XCUIApplication() + app.launchArguments = [ + "--ui-testing", + "--disable-animations", + "--reset-state" + ] + app.launch() + } + + override func tearDownWithError() throws { + // Capture a screenshot on test failure for post-mortem debugging. + if let failure = testRun, failure.failureCount > 0 { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Failure-\(name)" + screenshot.lifetime = .keepAlways + add(screenshot) + } + app = nil + } + + // MARK: - Screenshot Helpers + + /// Captures a named screenshot attached to the test report. + func captureScreenshot(named name: String) { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = name + screenshot.lifetime = .keepAlways + add(screenshot) + } +} + +// MARK: - Wait Helpers + +extension XCUIElement { + + /// Waits until the element exists, failing with a descriptive message. + @discardableResult + func waitForExistenceOrFail( + timeout: TimeInterval = BaseUITestCase.defaultTimeout, + _ message: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) -> XCUIElement { + let msg = message ?? "Expected \(self) to exist within \(timeout)s" + XCTAssertTrue(waitForExistence(timeout: timeout), msg, file: file, line: line) + return self + } + + /// Waits until the element exists AND is hittable. + @discardableResult + func waitUntilHittable( + timeout: TimeInterval = BaseUITestCase.defaultTimeout, + _ message: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) -> XCUIElement { + waitForExistenceOrFail(timeout: timeout, message, file: file, line: line) + let predicate = NSPredicate(format: "isHittable == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + let msg = message ?? "Expected \(self) to be hittable within \(timeout)s" + XCTAssertEqual(result, .completed, msg, file: file, line: line) + return self + } + + /// Waits until the element no longer exists. + func waitForNonExistence( + timeout: TimeInterval = BaseUITestCase.defaultTimeout, + _ message: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + let msg = message ?? "Expected \(self) to disappear within \(timeout)s" + XCTAssertEqual(result, .completed, msg, file: file, line: line) + } + + /// Scrolls a scroll view until this element is hittable, or times out. + @discardableResult + func scrollIntoView( + in scrollView: XCUIElement, + direction: ScrollDirection = .down, + maxScrolls: Int = 10, + file: StaticString = #filePath, + line: UInt = #line + ) -> XCUIElement { + var scrollsRemaining = maxScrolls + while !exists || !isHittable { + guard scrollsRemaining > 0 else { + XCTFail("Could not scroll \(self) into view after \(maxScrolls) scrolls", + file: file, line: line) + return self + } + switch direction { + case .down: + scrollView.swipeUp(velocity: .slow) + case .up: + scrollView.swipeDown(velocity: .slow) + } + scrollsRemaining -= 1 + } + return self + } +} + +enum ScrollDirection { + case up, down +} diff --git a/SportsTimeUITests/Framework/Screens.swift b/SportsTimeUITests/Framework/Screens.swift new file mode 100644 index 0000000..a0c1f16 --- /dev/null +++ b/SportsTimeUITests/Framework/Screens.swift @@ -0,0 +1,410 @@ +// +// Screens.swift +// SportsTimeUITests +// +// Page Object / Screen Object layer. +// Each struct wraps an XCUIApplication and exposes user-intent methods. +// Tests read like: homeScreen.tapStartPlanning() +// + +import XCTest + +// MARK: - Home Screen + +struct HomeScreen { + let app: XCUIApplication + + // MARK: Elements + + var startPlanningButton: XCUIElement { + app.buttons["home.startPlanningButton"] + } + + var homeTab: XCUIElement { + app.tabBars.buttons["Home"] + } + + var scheduleTab: XCUIElement { + app.tabBars.buttons["Schedule"] + } + + var myTripsTab: XCUIElement { + app.tabBars.buttons["My Trips"] + } + + var progressTab: XCUIElement { + app.tabBars.buttons["Progress"] + } + + var settingsTab: XCUIElement { + app.tabBars.buttons["Settings"] + } + + var adventureAwaitsText: XCUIElement { + app.staticTexts["Adventure Awaits"] + } + + var createTripToolbarButton: XCUIElement { + app.buttons["Create new trip"] + } + + // MARK: Actions + + /// Waits for the home screen to fully load after bootstrap. + @discardableResult + func waitForLoad() -> HomeScreen { + startPlanningButton.waitForExistenceOrFail( + timeout: BaseUITestCase.longTimeout, + "Home screen should load after bootstrap" + ) + return self + } + + /// Taps "Start Planning" to open the Trip Wizard sheet. + func tapStartPlanning() { + startPlanningButton.waitUntilHittable().tap() + } + + /// Switches to a tab by tapping its tab bar button. + func switchToTab(_ tab: XCUIElement) { + tab.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + } + + // MARK: Assertions + + /// Asserts the tab bar is visible with all 5 tabs. + func assertTabBarVisible() { + XCTAssertTrue(homeTab.exists, "Home tab should exist") + XCTAssertTrue(scheduleTab.exists, "Schedule tab should exist") + XCTAssertTrue(myTripsTab.exists, "My Trips tab should exist") + XCTAssertTrue(progressTab.exists, "Progress tab should exist") + XCTAssertTrue(settingsTab.exists, "Settings tab should exist") + } +} + +// MARK: - Trip Wizard Screen + +struct TripWizardScreen { + let app: XCUIApplication + + // MARK: Elements + + var cancelButton: XCUIElement { + app.buttons["Cancel"] + } + + var planTripButton: XCUIElement { + app.buttons["wizard.planTripButton"] + } + + var navigationTitle: XCUIElement { + app.navigationBars["Plan a Trip"] + } + + // Planning modes + func planningModeButton(_ mode: String) -> XCUIElement { + app.buttons["wizard.planningMode.\(mode)"] + } + + // Sports + func sportButton(_ sport: String) -> XCUIElement { + app.buttons["wizard.sports.\(sport)"] + } + + // Regions + func regionButton(_ region: String) -> XCUIElement { + app.buttons["wizard.regions.\(region)"] + } + + // Date picker + var nextMonthButton: XCUIElement { + app.buttons["wizard.dates.nextMonth"] + } + + var previousMonthButton: XCUIElement { + app.buttons["wizard.dates.previousMonth"] + } + + var monthLabel: XCUIElement { + app.staticTexts["wizard.dates.monthLabel"] + } + + func dayButton(_ dateString: String) -> XCUIElement { + app.buttons["wizard.dates.day.\(dateString)"] + } + + // MARK: Actions + + /// Waits for the wizard sheet to appear. + @discardableResult + func waitForLoad() -> TripWizardScreen { + navigationTitle.waitForExistenceOrFail( + timeout: BaseUITestCase.defaultTimeout, + "Trip Wizard should appear" + ) + return self + } + + /// Selects the "By Dates" planning mode and waits for steps to expand. + func selectDateRangeMode() { + let btn = planningModeButton("dateRange") + btn.scrollIntoView(in: app.scrollViews.firstMatch) + btn.tap() + } + + /// Navigates the calendar to a target month/year and selects start/end dates. + func selectDateRange( + targetMonth: String, + targetYear: String, + startDay: String, + endDay: String + ) { + // Navigate forward to the target month + let target = "\(targetMonth) \(targetYear)" + var attempts = 0 + // First ensure the month label is visible + monthLabel.scrollIntoView(in: app.scrollViews.firstMatch) + while !monthLabel.label.contains(target) && attempts < 18 { + nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) + nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + attempts += 1 + } + XCTAssertTrue(monthLabel.label.contains(target), + "Should navigate to \(target)") + + // Select start date — scroll calendar grid into view first + let startBtn = dayButton(startDay) + startBtn.scrollIntoView(in: app.scrollViews.firstMatch) + startBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + + // Select end date + let endBtn = dayButton(endDay) + endBtn.scrollIntoView(in: app.scrollViews.firstMatch) + endBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + } + + /// Selects a sport (e.g., "mlb"). + func selectSport(_ sport: String) { + let btn = sportButton(sport) + btn.scrollIntoView(in: app.scrollViews.firstMatch) + btn.tap() + } + + /// Selects a region (e.g., "central"). + func selectRegion(_ region: String) { + let btn = regionButton(region) + btn.scrollIntoView(in: app.scrollViews.firstMatch) + btn.tap() + } + + /// Scrolls to and taps "Plan My Trip". + func tapPlanTrip() { + let btn = planTripButton + btn.scrollIntoView(in: app.scrollViews.firstMatch) + btn.waitUntilHittable().tap() + } + + /// Dismisses the wizard via the Cancel button. + func tapCancel() { + cancelButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + } +} + +// MARK: - Trip Options Screen + +struct TripOptionsScreen { + let app: XCUIApplication + + // MARK: Elements + + var sortDropdown: XCUIElement { + app.buttons["tripOptions.sortDropdown"] + } + + func tripCard(_ index: Int) -> XCUIElement { + app.buttons["tripOptions.trip.\(index)"] + } + + func sortOption(_ name: String) -> XCUIElement { + app.buttons["tripOptions.sortOption.\(name)"] + } + + // MARK: Actions + + /// Waits for planning results to load. + @discardableResult + func waitForLoad() -> TripOptionsScreen { + sortDropdown.waitForExistenceOrFail( + timeout: BaseUITestCase.longTimeout, + "Trip Options should appear after planning completes" + ) + return self + } + + /// Selects a trip option card by index. + func selectTrip(at index: Int) { + let card = tripCard(index) + card.scrollIntoView(in: app.scrollViews.firstMatch) + card.tap() + } + + /// Opens the sort dropdown and selects an option. + func sort(by option: String) { + sortDropdown.waitUntilHittable().tap() + let optionBtn = sortOption(option) + optionBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + } + + // MARK: Assertions + + /// Asserts at least one trip option is visible. + func assertHasResults() { + XCTAssertTrue(tripCard(0).waitForExistence(timeout: BaseUITestCase.shortTimeout), + "At least one trip option should exist") + } +} + +// MARK: - Trip Detail Screen + +struct TripDetailScreen { + let app: XCUIApplication + + // MARK: Elements + + var favoriteButton: XCUIElement { + app.buttons["tripDetail.favoriteButton"] + } + + var itineraryTitle: XCUIElement { + app.staticTexts["Itinerary"] + } + + // MARK: Actions + + /// Waits for the trip detail view to load. + @discardableResult + func waitForLoad() -> TripDetailScreen { + favoriteButton.waitForExistenceOrFail( + timeout: BaseUITestCase.defaultTimeout, + "Trip Detail should load with favorite button" + ) + return self + } + + /// Taps the favorite/save button. + func tapFavorite() { + favoriteButton.waitUntilHittable().tap() + } + + // MARK: Assertions + + /// Asserts the itinerary section is visible. + func assertItineraryVisible() { + let title = itineraryTitle + title.scrollIntoView(in: app.scrollViews.firstMatch) + XCTAssertTrue(title.exists, "Itinerary section 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" + XCTAssertEqual(favoriteButton.label, expected, + "Favorite button label should reflect saved state") + } +} + +// MARK: - My Trips Screen + +struct MyTripsScreen { + let app: XCUIApplication + + // MARK: Elements + + var emptyState: XCUIElement { + // VStack with accessibilityIdentifier can map to different element types on iOS 26; + // use descendants(matching: .any) for a type-agnostic match. + app.descendants(matching: .any)["myTrips.emptyState"] + } + + var savedTripsTitle: XCUIElement { + app.staticTexts["Saved Trips"] + } + + // MARK: Assertions + + func assertEmpty() { + XCTAssertTrue( + emptyState.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Empty state should be visible when no trips saved" + ) + } + + func assertHasTrips() { + XCTAssertFalse(emptyState.exists, "Empty state should not show when trips exist") + } +} + +// MARK: - Schedule Screen + +struct ScheduleScreen { + let app: XCUIApplication + + // MARK: Elements + + var searchField: XCUIElement { + app.searchFields.firstMatch + } + + var filterButton: XCUIElement { + // The filter menu button uses an accessibilityLabel + app.buttons.matching(NSPredicate( + format: "label CONTAINS 'Filter options'" + )).firstMatch + } + + // MARK: Assertions + + func assertLoaded() { + // Schedule tab should show the filter button with "Filter options" label + XCTAssertTrue(filterButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Schedule filter button should appear") + } +} + +// MARK: - Settings Screen + +struct SettingsScreen { + let app: XCUIApplication + + // MARK: Elements + + var versionLabel: XCUIElement { + app.staticTexts["settings.versionLabel"] + } + + var subscriptionSection: XCUIElement { + app.staticTexts["Subscription"] + } + + var privacySection: XCUIElement { + app.staticTexts["Privacy"] + } + + var aboutSection: XCUIElement { + app.staticTexts["About"] + } + + // MARK: Assertions + + func assertLoaded() { + XCTAssertTrue(subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Settings should show Subscription section") + } + + func assertVersionDisplayed() { + // SwiftUI List renders as UICollectionView on iOS 26, not UITableView + versionLabel.scrollIntoView(in: app.collectionViews.firstMatch, direction: .down) + XCTAssertTrue(versionLabel.exists, "App version should be displayed") + XCTAssertFalse(versionLabel.label.isEmpty, "Version label should not be empty") + } +} diff --git a/SportsTimeUITests/Tests/AccessibilityTests.swift b/SportsTimeUITests/Tests/AccessibilityTests.swift new file mode 100644 index 0000000..a960097 --- /dev/null +++ b/SportsTimeUITests/Tests/AccessibilityTests.swift @@ -0,0 +1,46 @@ +// +// AccessibilityTests.swift +// SportsTimeUITests +// +// Smoke test for Dynamic Type accessibility at XXXL text size. +// + +import XCTest + +final class AccessibilityTests: BaseUITestCase { + + /// Verifies the entry flow is usable at AX XXL text size. + @MainActor + func testLargeDynamicTypeEntryFlow() { + // Re-launch with large Dynamic Type + app.terminate() + app.launchArguments = [ + "--ui-testing", + "--disable-animations", + "--reset-state", + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryAccessibilityXXXL" + ] + app.launch() + + let home = HomeScreen(app: app) + home.waitForLoad() + + // At XXXL text the button may be pushed below the fold; scroll into view + home.startPlanningButton.scrollIntoView(in: app.scrollViews.firstMatch) + XCTAssertTrue(home.startPlanningButton.isHittable, + "Start Planning should remain hittable at large Dynamic Type") + + // Open wizard and verify planning mode options are reachable + home.tapStartPlanning() + let wizard = TripWizardScreen(app: app) + wizard.waitForLoad() + + let dateRangeMode = wizard.planningModeButton("dateRange") + dateRangeMode.scrollIntoView(in: app.scrollViews.firstMatch) + XCTAssertTrue(dateRangeMode.isHittable, + "Planning mode should be hittable at large Dynamic Type") + + captureScreenshot(named: "Accessibility-LargeType") + } +} diff --git a/SportsTimeUITests/Tests/AppLaunchTests.swift b/SportsTimeUITests/Tests/AppLaunchTests.swift new file mode 100644 index 0000000..154a257 --- /dev/null +++ b/SportsTimeUITests/Tests/AppLaunchTests.swift @@ -0,0 +1,38 @@ +// +// AppLaunchTests.swift +// SportsTimeUITests +// +// Verifies app boot, bootstrap, and initial screen rendering. +// + +import XCTest + +final class AppLaunchTests: BaseUITestCase { + + /// Verifies the app boots, shows the home screen, and all 5 tabs are present. + @MainActor + func testAppLaunchShowsHomeWithAllTabs() { + let home = HomeScreen(app: app) + home.waitForLoad() + + // Assert: Hero card text visible + XCTAssertTrue(home.adventureAwaitsText.exists, + "Hero card should display 'Adventure Awaits'") + + // Assert: All tabs present + home.assertTabBarVisible() + + captureScreenshot(named: "HomeScreen-Launch") + } + + /// Verifies the bootstrap loading indicator disappears and content renders. + @MainActor + func testBootstrapCompletesWithContent() { + let home = HomeScreen(app: app) + home.waitForLoad() + + // Assert: Start Planning button is interactable (proves bootstrap loaded data) + XCTAssertTrue(home.startPlanningButton.isHittable, + "Start Planning should be hittable after bootstrap") + } +} diff --git a/SportsTimeUITests/Tests/ScheduleTests.swift b/SportsTimeUITests/Tests/ScheduleTests.swift new file mode 100644 index 0000000..640e131 --- /dev/null +++ b/SportsTimeUITests/Tests/ScheduleTests.swift @@ -0,0 +1,24 @@ +// +// ScheduleTests.swift +// SportsTimeUITests +// +// Verifies the Schedule tab loads and displays content. +// + +import XCTest + +final class ScheduleTests: BaseUITestCase { + + /// Verifies the schedule tab loads and shows content. + @MainActor + func testScheduleTabLoads() { + let home = HomeScreen(app: app) + home.waitForLoad() + home.switchToTab(home.scheduleTab) + + let schedule = ScheduleScreen(app: app) + schedule.assertLoaded() + + captureScreenshot(named: "Schedule-Loaded") + } +} diff --git a/SportsTimeUITests/Tests/SettingsTests.swift b/SportsTimeUITests/Tests/SettingsTests.swift new file mode 100644 index 0000000..08f4803 --- /dev/null +++ b/SportsTimeUITests/Tests/SettingsTests.swift @@ -0,0 +1,48 @@ +// +// SettingsTests.swift +// SportsTimeUITests +// +// Verifies the Settings screen loads, displays version, and shows all sections. +// + +import XCTest + +final class SettingsTests: BaseUITestCase { + + /// Verifies the Settings screen loads and displays the app version. + @MainActor + func testSettingsShowsVersion() { + let home = HomeScreen(app: app) + home.waitForLoad() + home.switchToTab(home.settingsTab) + + let settings = SettingsScreen(app: app) + settings.assertLoaded() + settings.assertVersionDisplayed() + + captureScreenshot(named: "Settings-Version") + } + + /// Verifies key sections are present in Settings. + @MainActor + func testSettingsSectionsPresent() { + let home = HomeScreen(app: app) + home.waitForLoad() + home.switchToTab(home.settingsTab) + + let settings = SettingsScreen(app: app) + settings.assertLoaded() + + // Assert: Key sections exist + XCTAssertTrue(settings.subscriptionSection.waitForExistence( + timeout: BaseUITestCase.shortTimeout), + "Subscription section should exist") + + // SwiftUI List renders as UICollectionView on iOS 26 + settings.privacySection.scrollIntoView(in: app.collectionViews.firstMatch) + XCTAssertTrue(settings.privacySection.exists, "Privacy section should exist") + + settings.aboutSection.scrollIntoView(in: app.collectionViews.firstMatch) + XCTAssertTrue(settings.aboutSection.exists, "About section should exist") + } +} diff --git a/SportsTimeUITests/Tests/TabNavigationTests.swift b/SportsTimeUITests/Tests/TabNavigationTests.swift new file mode 100644 index 0000000..dad013f --- /dev/null +++ b/SportsTimeUITests/Tests/TabNavigationTests.swift @@ -0,0 +1,47 @@ +// +// TabNavigationTests.swift +// SportsTimeUITests +// +// Verifies navigation through all 5 tabs. +// + +import XCTest + +final class TabNavigationTests: BaseUITestCase { + + /// Navigates through all 5 tabs and asserts each one loads. + @MainActor + func testTabNavigationCycle() { + let home = HomeScreen(app: app) + home.waitForLoad() + + // Schedule tab + home.switchToTab(home.scheduleTab) + let schedule = ScheduleScreen(app: app) + schedule.assertLoaded() + + // My Trips tab + home.switchToTab(home.myTripsTab) + let myTrips = MyTripsScreen(app: app) + myTrips.assertEmpty() + + // Progress tab (Pro-gated, but UI test mode forces Pro) + home.switchToTab(home.progressTab) + // Just verify the tab switched without crash + let progressNav = app.navigationBars.firstMatch + XCTAssertTrue(progressNav.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Progress tab should load") + + // Settings tab + home.switchToTab(home.settingsTab) + let settings = SettingsScreen(app: app) + settings.assertLoaded() + + // Return home + home.switchToTab(home.homeTab) + XCTAssertTrue(home.startPlanningButton.waitForExistence(timeout: BaseUITestCase.shortTimeout), + "Should return to Home tab") + + captureScreenshot(named: "TabNavigation-ReturnHome") + } +} diff --git a/SportsTimeUITests/Tests/TripSavingTests.swift b/SportsTimeUITests/Tests/TripSavingTests.swift new file mode 100644 index 0000000..c8b6f56 --- /dev/null +++ b/SportsTimeUITests/Tests/TripSavingTests.swift @@ -0,0 +1,66 @@ +// +// TripSavingTests.swift +// SportsTimeUITests +// +// Tests the end-to-end trip saving flow: plan → select → save → verify in My Trips. +// + +import XCTest + +final class TripSavingTests: BaseUITestCase { + + /// Plans a trip, selects an option, saves it, and verifies it appears in My Trips. + @MainActor + func testSaveTripAppearsInMyTrips() { + let home = HomeScreen(app: app) + home.waitForLoad() + home.tapStartPlanning() + + // Plan a trip using date range mode + let wizard = TripWizardScreen(app: app) + wizard.waitForLoad() + wizard.selectDateRangeMode() + + wizard.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) + wizard.selectDateRange( + targetMonth: "June", + targetYear: "2026", + startDay: "2026-06-11", + endDay: "2026-06-16" + ) + wizard.selectSport("mlb") + wizard.selectRegion("central") + wizard.tapPlanTrip() + + // Select first trip option + let options = TripOptionsScreen(app: app) + options.waitForLoad() + options.selectTrip(at: 0) + + // Save the trip + let detail = TripDetailScreen(app: app) + detail.waitForLoad() + detail.assertSaveState(isSaved: false) + detail.tapFavorite() + + // Allow save to persist + detail.assertSaveState(isSaved: true) + + captureScreenshot(named: "TripSaving-Favorited") + + // Navigate back to My Trips tab + // Dismiss the entire wizard sheet: Detail → Options → Wizard → Cancel + app.navigationBars.buttons.firstMatch.tap() // Back from detail to options + // Back from options to wizard + let wizardBackBtn = app.navigationBars.buttons.firstMatch + wizardBackBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + // Cancel the wizard sheet + wizard.tapCancel() + // Now the tab bar is accessible + home.switchToTab(home.myTripsTab) + + // Assert: Saved trip appears (empty state should NOT be visible) + let myTrips = MyTripsScreen(app: app) + myTrips.assertHasTrips() + } +} diff --git a/SportsTimeUITests/Tests/TripWizardFlowTests.swift b/SportsTimeUITests/Tests/TripWizardFlowTests.swift new file mode 100644 index 0000000..5c65d4f --- /dev/null +++ b/SportsTimeUITests/Tests/TripWizardFlowTests.swift @@ -0,0 +1,71 @@ +// +// TripWizardFlowTests.swift +// SportsTimeUITests +// +// Tests the trip planning wizard: date range mode, calendar navigation, +// sport/region selection, and planning engine results. +// + +import XCTest + +final class TripWizardFlowTests: BaseUITestCase { + + /// Full flow: Start Planning → Date Range → Select dates → MLB → Central → Plan. + /// Asserts the planning engine returns results. + @MainActor + func testDateRangeTripPlanningFlow() { + let home = HomeScreen(app: app) + home.waitForLoad() + home.tapStartPlanning() + + let wizard = TripWizardScreen(app: app) + wizard.waitForLoad() + + // Step 1: Select "By Dates" mode + wizard.selectDateRangeMode() + + // Step 2: Navigate to June 2026 and select June 11-16 + // Scroll to see dates step + wizard.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) + wizard.selectDateRange( + targetMonth: "June", + targetYear: "2026", + startDay: "2026-06-11", + endDay: "2026-06-16" + ) + + // Step 3: Select MLB + wizard.selectSport("mlb") + + // Step 4: Select Central region + wizard.selectRegion("central") + + // Step 5: Tap Plan My Trip + wizard.tapPlanTrip() + + // Assert: Trip Options screen appears with results + let options = TripOptionsScreen(app: app) + options.waitForLoad() + options.assertHasResults() + + captureScreenshot(named: "TripWizard-PlanningResults") + } + + /// Verifies the wizard can be dismissed via Cancel. + @MainActor + func testWizardCanBeDismissed() { + let home = HomeScreen(app: app) + home.waitForLoad() + home.tapStartPlanning() + + let wizard = TripWizardScreen(app: app) + wizard.waitForLoad() + wizard.tapCancel() + + // Assert: Back on home screen + XCTAssertTrue( + home.startPlanningButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Should return to Home after cancelling wizard" + ) + } +} diff --git a/docs/SportsTime_QA_Test_Plan.xlsx b/docs/SportsTime_QA_Test_Plan.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1fba396a9aafc9d09eed039ac89c27ca6d881ce3 GIT binary patch literal 32196 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ȡ-y-sXOGn{66LnR2+N<4re zMjtZTk6e+|wx`&R_$06No=4MZh|NdkPQ0Q=!}Vg#4tV!xzJDL-#%U`qQosNJbD!*s zuz&L_IXSyqn>hV-j<2cP*e%l|yi+N7cLEvb8w2IDBG^o;s%Y|^mJ-6LJL>MU!#tlH z)CeJUs!hRK%=wlqK0KZ`ab?k|7A0`oKYL02biT)&C{1ZesD9mbpYxDnQuHF1Or;{G z5f;r)4UJGs@Quv}DhT(?>ms;h>~?hH9Xp{OH{L4?<4j_5+w{1x(A>>1-o^T%Dam+z z3X#=>xMfX}tSiOX4=>6M)YZHhV4v?8FuqDCUV;t`KL*okeQHci2Li#B0K(SsLFD+{fDB8T0NtNS}DNsbWt_f(Mmei-nr)Fdk z$5=w)m`d=NJ+$I%78=!Vnj?GNO{VS-8zP!+@0t$Wk%VniZE-5_)pWmok%X;jL|12$ zf6`PM@9>Mkw@9naVXTwGo#|~Sp6tM3KN`pxc4lHW9R&$~o)16ETx#KCJc+kf8w}DE zi9UInXpX*>n2bNW`<7MzntW$q<~7?;v71l*w3S&Slc|9GGUtCo!Ewy7B3Omq2S(#q z4x-#R9tm|=%`-$NFl^SGB=dRM;z)(}#f*1@;EQ^@T~Z9Khf1X#blbt|z{Bb4BM%q) zE7L*i{^~>Oh=i3z0oG>D!pI^le$}IayM$(oNNg&7QIKvs~h9uXPWKT=)AY~-R z_MD){S8ZP-NXy2%UFVSVvo+=-Is~Qgz|LI69e#1=c%}-EDc>!|RmQpVZ{ZwO$R|M* z8qsqBVf6dCe?P*Y2|w@&HS_H^q91JtK~2f#L{=U!Uy2t0pvP;GEynZAH#mF|_-?`+ zT81aAzU~X~emYOT!4JFxSlFJ2Pc7duiQ22h=OB|!`8>&)J}>!!q14v{)qs>skx zE^dJj?!bNY%u%HgQLTs8;gf^8GF0TM5lOg%VeLDM?-2Z{H^9FYHJ0S-z61mSI0FL! z_)H7=i~sX4`Zhy5JFCA8OkINAUk0YXBh)PCPDcnx8S9HUYMEBiPoccymEjP&-jWaD zdSD&CH|wIMSl5U`NZo)Iria&W+~{cF1^A2wc$0aX)A^0H^{HYpLsAI6>7`Klr6`in zhC0P=F+Z6REF}S`zQ%_SpvMqMZ|~)v)lDg|$)h=r#5+r5H5%E|`yBHMdzz~Ev)Aw` z;qW?jiqj8)cmkzvGl&){2hmB^kde@At!Pt?E8(yOa7`xeRZeTcyGt=5GbS4I1p`Z5 z^)0ecC$qAqL%f9;t@?4T3fHNqg=Z$O+#eu60H}Lb_hY0=DkfeobDr+q%7mDn*cH*S_-lji>VoIYh8Vi+b2rZco{sf2I`-x6 zR6&u~QAg96%~S8hH#e@9ke#iwiLJAqvb(*Blg?lJV=P`q?$a0q zen`%p%$kNr0c*MeR#XA|5Y(PyvFL@iO}%-px~73(p(>G0N#*w5zqI};px$`EqqhvN zPwqpF@j(H)feP=Pb+5vq(4mig#9-|nX-&jN2zz=J*d0ewmn@zORvR%R-&0k(oxWtD zG^=6%-p}rFjI5=KCUsFaOyZz@YJKGwivvNS=E|W?5yd1M+@~Rk{e){@?71QUQS#lK z2DnYKMzl9V+#m`0{q{Zr9ao#@6Y$o5&!>r zrvH@wlhpZd*-siuz@GnC+US2Mp8pj8llJ%@@y*ZX{{so~p8$W-Uj8q@4?>9lwW9wY z3Fe>9^!_gj;17r2DF3*?e~SKja{nXx;QX(1{7=C@Pr`o$H(ma> z=l}Oy{8RW(OZkuRo7->U|6(`)#QD?Y{DbrQ6#(Er7$A8m(9e_muSWw|0M*aO1a*(U G{{0{BvQPg2 literal 0 HcmV?d00001 diff --git a/docs/generate_qa_sheet.py b/docs/generate_qa_sheet.py new file mode 100644 index 0000000..138fcee --- /dev/null +++ b/docs/generate_qa_sheet.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Generate QA test plan Excel sheet for SportsTime iOS app.""" + +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +wb = openpyxl.Workbook() + +# Styles +header_font = Font(name="Helvetica Neue", bold=True, size=11, color="FFFFFF") +header_fill = PatternFill(start_color="2C3E50", end_color="2C3E50", fill_type="solid") +section_font = Font(name="Helvetica Neue", bold=True, size=11, color="FFFFFF") +section_fill = PatternFill(start_color="FF6B35", end_color="FF6B35", fill_type="solid") +p1_fill = PatternFill(start_color="FADBD8", end_color="FADBD8", fill_type="solid") +p2_fill = PatternFill(start_color="FEF9E7", end_color="FEF9E7", fill_type="solid") +p3_fill = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid") +wrap = Alignment(wrap_text=True, vertical="top") +thin_border = Border( + left=Side(style="thin", color="D5D8DC"), + right=Side(style="thin", color="D5D8DC"), + top=Side(style="thin", color="D5D8DC"), + bottom=Side(style="thin", color="D5D8DC"), +) + +COLUMNS = ["ID", "Feature Area", "Test Case", "Steps", "Expected Result", "Priority", "Type", "Status", "Tester", "Notes"] +COL_WIDTHS = [6, 18, 40, 55, 40, 10, 14, 10, 12, 30] + +def setup_sheet(ws, title): + ws.title = title + ws.sheet_properties.tabColor = "FF6B35" + for i, (col, w) in enumerate(zip(COLUMNS, COL_WIDTHS), 1): + cell = ws.cell(row=1, column=i, value=col) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + cell.border = thin_border + ws.column_dimensions[get_column_letter(i)].width = w + ws.freeze_panes = "A2" + ws.auto_filter.ref = f"A1:J1" + +def add_section(ws, row, title): + for col in range(1, len(COLUMNS) + 1): + cell = ws.cell(row=row, column=col, value=title if col == 3 else "") + cell.font = section_font + cell.fill = section_fill + cell.border = thin_border + return row + 1 + +def add_row(ws, row, test_id, area, case, steps, expected, priority, test_type): + data = [test_id, area, case, steps, expected, priority, test_type, "", "", ""] + for col, val in enumerate(data, 1): + cell = ws.cell(row=row, column=col, value=val) + cell.alignment = wrap + cell.border = thin_border + if col == 6: + if val == "P1": + cell.fill = p1_fill + elif val == "P2": + cell.fill = p2_fill + elif val == "P3": + cell.fill = p3_fill + return row + 1 + + +# ============================================================ +# SHEET 1: Functional Tests +# ============================================================ +ws = wb.active +setup_sheet(ws, "Functional Tests") +r = 2 +tid = 1 + +# --- App Launch & Bootstrap --- +r = add_section(ws, r, "APP LAUNCH & BOOTSTRAP") +tests = [ + ("App Launch", "Cold launch on first install", "1. Delete app\n2. Install fresh\n3. Launch app", "Bootstrap screen appears, then home screen loads with hero card", "P1", "Smoke"), + ("App Launch", "Bootstrap loads bundled data", "1. Launch app (fresh install)\n2. Wait for home screen", "Teams, stadiums, and games load from bundled JSON. 'Start Planning' button is interactable", "P1", "Smoke"), + ("App Launch", "Cold launch with existing data", "1. Launch app after prior use\n2. Observe load time", "Home screen loads quickly from cached SwiftData. No bootstrap needed", "P1", "Regression"), + ("App Launch", "Launch with no network", "1. Enable airplane mode\n2. Launch app", "App launches successfully with cached data. No crash. CloudKit sync silently skipped", "P1", "Negative"), + ("App Launch", "Launch after OS update", "1. Update iOS\n2. Launch app", "App launches without crash. SwiftData migration succeeds if schema changed", "P2", "Regression"), + ("App Launch", "Background to foreground resume", "1. Launch app\n2. Background it\n3. Wait 30s\n4. Foreground", "App resumes without re-bootstrapping. Data intact", "P1", "Regression"), + ("App Launch", "Onboarding paywall shown on first launch (free user)", "1. Fresh install\n2. Launch app\n3. Wait for home screen", "Onboarding paywall sheet appears. Can dismiss with 'Not Now'. Not shown on subsequent launches", "P2", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Tab Navigation --- +r = add_section(ws, r, "TAB NAVIGATION") +tests = [ + ("Navigation", "Switch to all 5 tabs", "1. Launch app\n2. Tap each tab: Home, Schedule, My Trips, Progress, Settings", "Each tab loads its content without crash. Tab bar highlight updates correctly", "P1", "Smoke"), + ("Navigation", "Tab state preserved on switch", "1. Go to Schedule tab, apply filters\n2. Switch to Home tab\n3. Switch back to Schedule", "Schedule filters are still applied. Scroll position preserved", "P2", "Functional"), + ("Navigation", "Double-tap tab scrolls to top", "1. Scroll down on any tab\n2. Tap the active tab again", "View scrolls to top", "P3", "UX"), + ("Navigation", "Tab badge updates", "1. Save a trip\n2. Check My Trips tab", "Tab updates to reflect new content", "P3", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Home Tab --- +r = add_section(ws, r, "HOME TAB") +tests = [ + ("Home", "Hero card displays correctly", "1. Launch app\n2. View hero card area", "'Adventure Awaits' text visible. 'Start Planning' button visible and tappable", "P1", "Smoke"), + ("Home", "Start Planning opens wizard", "1. Tap 'Start Planning' button", "Trip wizard sheet appears with 'Plan a Trip' title and planning mode options", "P1", "Smoke"), + ("Home", "Featured trips carousel loads", "1. Scroll to 'Featured Trips' section\n2. Swipe horizontally", "Trip cards displayed grouped by region. Cards show city count and game count", "P2", "Functional"), + ("Home", "Featured trips refresh button", "1. Tap refresh button on featured trips\n2. Wait for reload", "Loading indicator shows. New suggestions appear. Error state with retry if network fails", "P2", "Functional"), + ("Home", "Tap featured trip opens detail", "1. Tap any featured trip card", "TripDetailView opens showing itinerary, map, and stats", "P2", "Functional"), + ("Home", "Recent trips section (with saved trips)", "1. Save 1+ trips\n2. Go to Home tab", "Recent trips section shows up to 3 most recent saved trips with 'See All' link", "P2", "Functional"), + ("Home", "Recent trips section (no saved trips)", "1. Launch fresh (no saved trips)\n2. View Home tab", "Recent trips section hidden or shows empty prompt", "P3", "Functional"), + ("Home", "Planning tips section", "1. Scroll to bottom of Home tab", "3 planning tips displayed with helpful travel advice", "P3", "Functional"), + ("Home", "Create trip toolbar button", "1. Tap '+' / 'Create new trip' button in toolbar", "Trip wizard opens (same as Start Planning)", "P2", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Trip Wizard --- +r = add_section(ws, r, "TRIP PLANNING WIZARD") +tests = [ + ("Wizard", "Planning mode selection - By Dates", "1. Open wizard\n2. Tap 'By Dates' card", "Card highlights. Date picker, sports, and regions steps appear below", "P1", "Functional"), + ("Wizard", "Planning mode selection - By Games", "1. Open wizard\n2. Tap 'By Games' card", "Game picker step appears with sport selector, team multi-select, and game list", "P1", "Functional"), + ("Wizard", "Planning mode selection - By Route", "1. Open wizard\n2. Tap 'By Route' card", "Start/end location fields appear with autocomplete search", "P1", "Functional"), + ("Wizard", "Planning mode selection - Follow Team", "1. Open wizard\n2. Tap 'Follow Team' card", "Sport selector and single team picker appear", "P1", "Functional"), + ("Wizard", "Planning mode selection - By Teams", "1. Open wizard\n2. Tap 'By Teams' card", "Sport selector and multi-team picker appear (min 2 required)", "P1", "Functional"), + ("Wizard", "Switching planning modes resets fields", "1. Select 'By Dates', pick dates\n2. Switch to 'By Games'\n3. Switch back to 'By Dates'", "Previously selected dates are reset. Form starts fresh", "P2", "Functional"), + ("Wizard", "Calendar navigation - forward", "1. Select 'By Dates'\n2. Tap next month arrow multiple times", "Calendar navigates forward correctly. Month label updates", "P1", "Functional"), + ("Wizard", "Calendar navigation - backward", "1. Navigate to a future month\n2. Tap previous month arrow", "Calendar navigates backward. Cannot go before current month", "P2", "Functional"), + ("Wizard", "Date range selection", "1. Tap a start date\n2. Tap an end date after it", "Date range highlights between start and end. Summary shows selected range", "P1", "Functional"), + ("Wizard", "Date range - end before start", "1. Tap a date\n2. Tap a date before it", "Selection resets. New date becomes start date", "P2", "Edge Case"), + ("Wizard", "Date range - same day", "1. Tap a date\n2. Tap the same date again", "Single-day trip selected. Duration shows '1 day'", "P2", "Edge Case"), + ("Wizard", "Past dates are disabled", "1. View calendar at current month", "Days before today are grayed out and not tappable", "P2", "Validation"), + ("Wizard", "Sport selection - single sport", "1. Tap MLB in sports step", "MLB highlights. Other sports remain deselected", "P1", "Functional"), + ("Wizard", "Sport selection - multiple sports", "1. Tap MLB\n2. Tap NBA", "Both MLB and NBA highlight. Trip will include games from both", "P1", "Functional"), + ("Wizard", "Sport availability indicator", "1. Select dates\n2. View sports step", "Sports with games in date range show availability. Sports without games show dimmed", "P2", "Functional"), + ("Wizard", "Region selection - toggle regions", "1. Tap West region on map\n2. Tap Central\n3. Tap East", "Selected regions highlight on map. At least 1 must remain selected", "P1", "Functional"), + ("Wizard", "Route preference - Direct/Scenic/Balanced", "1. Select a route preference option", "Selection highlights. Affects route calculation", "P2", "Functional"), + ("Wizard", "Allow repeat cities toggle", "1. Toggle 'Allow Repeat Cities' off\n2. Plan trip", "Route avoids visiting same city on multiple days", "P2", "Functional"), + ("Wizard", "Must-stops addition", "1. Tap 'Add Must Stop'\n2. Search for city\n3. Select it", "City added to must-stops list. Will be included in all route options", "P2", "Functional"), + ("Wizard", "Review step shows summary", "1. Complete all steps\n2. Scroll to Review", "Summary shows: planning mode, dates, sports, regions, preferences", "P1", "Functional"), + ("Wizard", "Plan My Trip button - enabled state", "1. Fill all required fields\n2. Check 'Plan My Trip' button", "Button is enabled and tappable", "P1", "Functional"), + ("Wizard", "Plan My Trip button - disabled state", "1. Open wizard without selecting mode", "Button is disabled/grayed out until required fields are filled", "P2", "Validation"), + ("Wizard", "Planning execution - success", "1. Fill valid options (By Dates, June 2026, MLB, Central)\n2. Tap 'Plan My Trip'", "Loading indicator shows. Trip Options screen appears with multiple route options", "P1", "Functional"), + ("Wizard", "Planning execution - no games found", "1. Select very narrow date range with no games\n2. Tap 'Plan My Trip'", "Error message: 'No games found in your date range'. Suggestions to broaden search", "P1", "Negative"), + ("Wizard", "Planning execution - no valid routes", "1. Select conflicting constraints\n2. Tap 'Plan My Trip'", "Error message: 'No valid routes found'. Helpful message to adjust parameters", "P1", "Negative"), + ("Wizard", "Cancel wizard", "1. Tap Cancel button in wizard navigation bar", "Wizard sheet dismisses. Returns to previous screen", "P1", "Functional"), + ("Wizard", "Wizard scroll behavior", "1. Open wizard\n2. Select planning mode\n3. Scroll through all steps", "All steps are reachable. No content clipped. Smooth scrolling", "P2", "UX"), + ("Wizard", "Game picker - filter by sport then team", "1. Select 'By Games'\n2. Choose MLB\n3. Select teams\n4. View games list", "Games filtered by selected sport and teams. Dates shown correctly", "P1", "Functional"), + ("Wizard", "Location search autocomplete", "1. Select 'By Route'\n2. Type city name in start field", "Autocomplete suggestions appear. Selecting one resolves coordinates", "P1", "Functional"), + ("Wizard", "Follow Team mode", "1. Select 'Follow Team'\n2. Pick sport\n3. Pick team\n4. Set dates\n5. Plan", "Route follows team's away schedule within date range", "P1", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Trip Options --- +r = add_section(ws, r, "TRIP OPTIONS (RESULTS)") +tests = [ + ("Trip Options", "Results display after planning", "1. Complete wizard and plan", "Trip options listed with city count, game count, distance, and preview", "P1", "Functional"), + ("Trip Options", "Sort by Recommended", "1. Tap sort dropdown\n2. Select 'Recommended'", "Trips re-ordered by recommendation score", "P2", "Functional"), + ("Trip Options", "Sort by Most Games", "1. Tap sort dropdown\n2. Select 'Most Games'", "Trips re-ordered with most games first", "P2", "Functional"), + ("Trip Options", "Sort by Least Miles", "1. Tap sort dropdown\n2. Select 'Least Miles'", "Trips re-ordered with shortest distance first", "P2", "Functional"), + ("Trip Options", "Sort by Best Efficiency", "1. Tap sort dropdown\n2. Select 'Best Efficiency'", "Trips re-ordered by games-per-mile ratio", "P2", "Functional"), + ("Trip Options", "Pace filter - Packed/Moderate/Relaxed", "1. Tap pace filter\n2. Select 'Packed'", "Only packed-pace trips shown. Count updates", "P2", "Functional"), + ("Trip Options", "Cities filter", "1. Tap cities filter\n2. Select '5 cities max'", "Only trips with 5 or fewer cities shown", "P2", "Functional"), + ("Trip Options", "Select trip opens detail", "1. Tap any trip option card", "TripDetailView opens with full itinerary for that option", "P1", "Functional"), + ("Trip Options", "Back to wizard from options", "1. Tap back button on Trip Options", "Returns to wizard with selections preserved", "P2", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Trip Detail --- +r = add_section(ws, r, "TRIP DETAIL & ITINERARY") +tests = [ + ("Trip Detail", "Trip detail loads with map", "1. Open any trip detail", "Hero map shows all stops with route lines. Zoom level fits all stops", "P1", "Functional"), + ("Trip Detail", "Stats row displays correctly", "1. View trip detail", "City count, game count, total distance, and estimated driving time shown", "P1", "Functional"), + ("Trip Detail", "Itinerary day cards", "1. Scroll through itinerary", "Each day shows: date header, game cards (team logos, times, venues), travel segments", "P1", "Functional"), + ("Trip Detail", "Conflict detection badge", "1. Open trip with same-day games in different cities", "Orange conflict badge appears. Route options card shows alternatives", "P2", "Functional"), + ("Trip Detail", "Save trip (unsaved)", "1. Open unsaved trip\n2. Tap favorite/save button", "Button label changes to 'Remove from favorites'. Trip persisted to SwiftData", "P1", "Functional"), + ("Trip Detail", "Unsave trip (saved)", "1. Open saved trip\n2. Tap favorite/save button", "Button label changes to 'Save to favorites'. Trip removed from saved list", "P1", "Functional"), + ("Trip Detail", "Save trip - free tier limit", "1. As free user with 1 saved trip\n2. Try to save another", "Paywall shown. Cannot save until upgraded or existing trip deleted", "P1", "Functional"), + ("Trip Detail", "Itinerary reordering (saved trips)", "1. Open saved trip\n2. Long-press a game card\n3. Drag to new position", "Card moves to new position. Itinerary recalculates travel times", "P2", "Functional"), + ("Trip Detail", "Add custom item to day", "1. Open saved trip\n2. Tap '+' on a day card\n3. Fill in item details", "Custom item added to itinerary (Attraction/Hotel/Restaurant/etc)", "P2", "Functional"), + ("Trip Detail", "Edit custom item", "1. Tap existing custom item\n2. Modify details\n3. Save", "Item updated with new details", "P2", "Functional"), + ("Trip Detail", "Delete custom item", "1. Swipe left on custom item\n2. Tap delete", "Item removed from itinerary", "P2", "Functional"), + ("Trip Detail", "Travel day override", "1. Tap travel segment\n2. Select 'Move to different day'", "Travel segment reassigned. Itinerary adjusts", "P3", "Functional"), + ("Trip Detail", "Share trip card", "1. Tap share button in toolbar", "Share sheet appears with trip summary card image", "P2", "Functional"), + ("Trip Detail", "Export PDF (Pro)", "1. Tap PDF export button", "Multi-page PDF generated with maps, photos, attractions. Loading overlay during generation", "P1", "Functional"), + ("Trip Detail", "Export PDF (free tier - blocked)", "1. As free user, tap PDF export button", "Paywall appears. Export blocked until Pro", "P2", "Functional"), + ("Trip Detail", "Export PDF with large trip (15+ stops)", "1. Plan a large trip\n2. Export PDF", "PDF generates without crash. Route split into segments (16 stop limit per map)", "P2", "Edge Case"), + ("Trip Detail", "Trip detail with 1 stop", "1. Plan trip with minimal options (1 game)", "Detail view works correctly with single stop", "P3", "Edge Case"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- My Trips --- +r = add_section(ws, r, "MY TRIPS") +tests = [ + ("My Trips", "Empty state (no saved trips)", "1. Fresh install\n2. Go to My Trips tab", "Empty state shows suitcase icon and message to create trips", "P1", "Functional"), + ("My Trips", "Saved trips list", "1. Save 3+ trips\n2. Go to My Trips tab", "All saved trips listed, sorted by city count descending", "P1", "Functional"), + ("My Trips", "Tap saved trip opens detail", "1. Tap a saved trip card", "TripDetailView opens with editable itinerary (allowCustomItems=true)", "P1", "Functional"), + ("My Trips", "Delete saved trip", "1. Swipe left on trip card\n2. Tap delete\n3. Confirm", "Trip removed from list. SwiftData updated", "P1", "Functional"), + ("My Trips", "Polls section visible", "1. Go to My Trips tab", "Group Polls section visible above saved trips", "P2", "Functional"), + ("My Trips", "Create poll button (2+ trips)", "1. Save 2+ trips\n2. Tap '+' in polls section", "Poll creation sheet opens", "P2", "Functional"), + ("My Trips", "Create poll button hidden (<2 trips)", "1. Have 0-1 saved trips\n2. Check polls section", "Create poll button not shown. Message says 'Save at least 2 trips'", "P2", "Functional"), + ("My Trips", "Pull to refresh", "1. Pull down on My Trips list", "Polls refresh from CloudKit. Loading indicator shows", "P3", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Schedule --- +r = add_section(ws, r, "SCHEDULE TAB") +tests = [ + ("Schedule", "Schedule loads with games", "1. Go to Schedule tab", "Games listed grouped by sport, sorted by date. Filter button visible", "P1", "Functional"), + ("Schedule", "Sport filter chips", "1. Tap sport chip (e.g., MLB)\n2. Observe list", "Only MLB games shown. Chip highlights as active filter", "P1", "Functional"), + ("Schedule", "Multiple sport filters", "1. Tap MLB chip\n2. Tap NBA chip", "Both MLB and NBA games shown", "P2", "Functional"), + ("Schedule", "Clear all filters", "1. Apply filters\n2. Tap clear/reset button", "All games shown. Filters reset to default", "P2", "Functional"), + ("Schedule", "Search by team name", "1. Tap search field\n2. Type 'Yankees'", "Only games involving Yankees shown", "P2", "Functional"), + ("Schedule", "Search by venue", "1. Tap search field\n2. Type 'Wrigley'", "Only games at Wrigley Field shown", "P2", "Functional"), + ("Schedule", "Date range filter", "1. Set custom date range\n2. Observe results", "Only games within date range shown", "P2", "Functional"), + ("Schedule", "Empty state (no matching games)", "1. Apply very restrictive filters", "Empty state message shown with suggestion to adjust filters", "P2", "Negative"), + ("Schedule", "Loading state", "1. Go to Schedule tab on slow network", "Loading indicator visible while games load", "P3", "Functional"), + ("Schedule", "Schedule diagnostics", "1. Tap diagnostics button", "Schedule loading diagnostics displayed (game counts, load times)", "P3", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Progress Tab --- +r = add_section(ws, r, "PROGRESS TAB (PRO)") +tests = [ + ("Progress", "Progress tab loads (Pro user)", "1. As Pro user, go to Progress tab", "Stadium map, progress stats, and add visit button visible", "P1", "Functional"), + ("Progress", "Progress tab blocked (free user)", "1. As free user, go to Progress tab", "Paywall or upgrade prompt shown", "P1", "Functional"), + ("Progress", "League/sport selector", "1. Toggle between MLB/NBA/NHL/etc", "Map and stats update to show selected sport's stadiums", "P1", "Functional"), + ("Progress", "Stadium map - visited vs unvisited", "1. Add a stadium visit\n2. View map", "Visited stadiums show different color/icon than unvisited", "P1", "Functional"), + ("Progress", "Progress percentage updates", "1. Add a new stadium visit\n2. View progress stats", "Percentage completion updates for that sport", "P1", "Functional"), + ("Progress", "Add visit - manual entry", "1. Tap 'Add Visit'\n2. Select 'Manual Entry'\n3. Fill all fields\n4. Save", "Visit saved. Stadium marked as visited on map. Progress updates", "P1", "Functional"), + ("Progress", "Add visit - required fields", "1. Try to save visit without sport/stadium", "Save button disabled or validation error shown", "P2", "Validation"), + ("Progress", "Add visit - import from photos", "1. Tap 'Add Visit'\n2. Select 'Import from Photos'\n3. Select photos", "Photos processed for EXIF. Matched stadiums shown for confirmation", "P2", "Functional"), + ("Progress", "Photo import - no GPS data", "1. Import photo without GPS metadata", "Warning: 'Could not determine location'. Photo skipped or manual entry suggested", "P2", "Edge Case"), + ("Progress", "Photo import - no matching stadium", "1. Import photo from non-stadium location", "No match found message. Suggest manual entry", "P2", "Edge Case"), + ("Progress", "Photo import - permission denied", "1. Deny photo library permission\n2. Try import", "Permission denied message with link to Settings", "P2", "Negative"), + ("Progress", "View games history", "1. Tap 'Games History'\n2. Browse list", "All visits listed grouped by year. Sport filter chips work", "P2", "Functional"), + ("Progress", "Edit stadium visit", "1. Tap visit in history\n2. Edit details\n3. Save", "Visit updated with new info", "P2", "Functional"), + ("Progress", "Delete stadium visit", "1. Tap visit\n2. Tap delete\n3. Confirm", "Visit removed. Progress stats update. Map updates", "P1", "Functional"), + ("Progress", "Score auto-fill", "1. Add visit for recent game", "Score fields auto-populated from API if game found", "P3", "Functional"), + ("Progress", "Achievements gallery", "1. Navigate to achievements section", "Badges shown in grid: earned (colored), in-progress (partial), locked (gray)", "P2", "Functional"), + ("Progress", "Achievement detail", "1. Tap any achievement badge", "Detail shows: requirement, current progress, description", "P2", "Functional"), + ("Progress", "Share achievement", "1. Tap earned achievement\n2. Tap share", "Share card generated with achievement image", "P3", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Polls --- +r = add_section(ws, r, "GROUP POLLS") +tests = [ + ("Polls", "Create poll", "1. Have 2+ saved trips\n2. Tap create poll\n3. Enter title\n4. Select 2+ trips\n5. Create", "Poll created with 6-char share code. Shows in polls list", "P1", "Functional"), + ("Polls", "Create poll - validation (< 2 trips selected)", "1. Select only 1 trip\n2. Try to create", "Create button disabled. Message: 'Select at least 2 trips'", "P2", "Validation"), + ("Polls", "View poll detail", "1. Tap poll in list", "Poll detail shows title, share code, trip options with vote counts", "P1", "Functional"), + ("Polls", "Vote on poll", "1. Open poll\n2. Tap vote on a trip option", "Vote registers. Count updates. Current user's vote highlighted", "P1", "Functional"), + ("Polls", "Change vote", "1. Vote on trip A\n2. Vote on trip B instead", "Vote moves from A to B. Counts update correctly", "P2", "Functional"), + ("Polls", "Share poll link", "1. Tap share button on poll", "Share sheet with poll deep link. Code displayed for manual sharing", "P2", "Functional"), + ("Polls", "Open poll via deep link", "1. Tap sportstime://poll/{code} link", "App opens. Poll detail view loads for that share code", "P1", "Functional"), + ("Polls", "Deep link - invalid code", "1. Open sportstime://poll/INVALID", "Error message: 'Poll not found'", "P2", "Negative"), + ("Polls", "Delete poll (creator only)", "1. As poll creator, tap delete\n2. Confirm", "Poll deleted from CloudKit. Removed from list", "P2", "Functional"), + ("Polls", "Poll with network error", "1. Turn off network\n2. Try to create/vote on poll", "Error message shown. Graceful failure", "P2", "Negative"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Settings --- +r = add_section(ws, r, "SETTINGS") +tests = [ + ("Settings", "Settings tab loads", "1. Go to Settings tab", "All sections visible: Subscription, Appearance, Theme, Animations, Sports, Travel, Sync, Privacy, About, Reset", "P1", "Smoke"), + ("Settings", "App version displayed", "1. Scroll to About section", "Version number and build shown. Not empty", "P2", "Functional"), + ("Settings", "Appearance - Light mode", "1. Select Light mode", "App switches to light theme immediately", "P2", "Functional"), + ("Settings", "Appearance - Dark mode", "1. Select Dark mode", "App switches to dark theme immediately", "P2", "Functional"), + ("Settings", "Appearance - System mode", "1. Select System\n2. Toggle device dark mode", "App follows device setting", "P2", "Functional"), + ("Settings", "Toggle animations on/off", "1. Toggle 'Animations' switch", "Home background switches between animated and static", "P3", "Functional"), + ("Settings", "Sports preferences toggle", "1. Toggle off a sport (e.g., NHL)\n2. Go to Schedule tab", "NHL games hidden from schedule. Sport not available in wizard", "P2", "Functional"), + ("Settings", "Max driving hours slider", "1. Adjust slider to 6 hours\n2. Plan a trip", "Trip plans limited to 6 hours driving per day", "P2", "Functional"), + ("Settings", "Privacy - analytics opt-out", "1. Toggle analytics off\n2. Use app normally", "No analytics events sent to PostHog", "P2", "Functional"), + ("Settings", "Privacy - analytics opt-in", "1. Toggle analytics back on", "Analytics resume. Events tracked", "P2", "Functional"), + ("Settings", "Manual sync trigger", "1. Tap 'Sync Now' in Data Sync section", "Sync starts. Status updates. Success/failure message shown", "P2", "Functional"), + ("Settings", "View sync logs", "1. Tap 'View Sync Logs'", "Log viewer sheet opens showing recent sync activity", "P3", "Functional"), + ("Settings", "Subscription section (free user)", "1. View subscription section as free user", "Shows 'Free' plan. 'Upgrade to Pro' button visible", "P1", "Functional"), + ("Settings", "Subscription section (Pro user)", "1. View subscription section as Pro user", "Shows 'Pro' plan. Subscription details visible", "P1", "Functional"), + ("Settings", "Restore purchases", "1. Tap 'Restore Purchases'", "StoreKit restore flow executes. Success/failure message", "P1", "Functional"), + ("Settings", "Reset to defaults", "1. Tap 'Reset to Defaults'\n2. Confirm on alert", "All settings reset. App uses defaults for appearance, sports, etc", "P2", "Functional"), + ("Settings", "Reset to defaults - cancel", "1. Tap 'Reset to Defaults'\n2. Cancel on alert", "Settings unchanged", "P3", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + +# --- Subscription / IAP --- +r = add_section(ws, r, "SUBSCRIPTION & IN-APP PURCHASE") +tests = [ + ("IAP", "Paywall displays products", "1. Trigger paywall (save 2nd trip as free)", "Monthly and annual subscription options shown with pricing", "P1", "Functional"), + ("IAP", "Purchase monthly subscription", "1. Tap monthly option\n2. Authenticate\n3. Confirm", "Purchase succeeds. Pro features unlocked. Analytics event tracked", "P1", "Functional"), + ("IAP", "Purchase annual subscription", "1. Tap annual option\n2. Authenticate\n3. Confirm", "Purchase succeeds. Pro features unlocked", "P1", "Functional"), + ("IAP", "Purchase cancellation", "1. Start purchase\n2. Cancel on App Store dialog", "Purchase cancelled. User remains on free tier. No crash", "P1", "Negative"), + ("IAP", "Purchase failure", "1. Simulate purchase failure (sandbox)", "Error message shown. User remains on free tier", "P1", "Negative"), + ("IAP", "Restore purchases - has subscription", "1. Previously purchased\n2. Tap Restore", "Pro status restored. Features unlocked", "P1", "Functional"), + ("IAP", "Restore purchases - no subscription", "1. Never purchased\n2. Tap Restore", "Message: 'No purchases to restore'", "P2", "Negative"), + ("IAP", "Pro features unlock after purchase", "1. Purchase Pro\n2. Check: unlimited trips, PDF export, progress", "All Pro features accessible. Lock badges removed", "P1", "Functional"), + ("IAP", "Subscription expiry", "1. Let sandbox subscription expire\n2. Use app", "Pro features locked again. Paywall shown when accessing", "P1", "Functional"), + ("IAP", "Free trial (if applicable)", "1. Start free trial\n2. Verify Pro features during trial", "Pro features available during trial. Expiry handled gracefully", "P2", "Functional"), + ("IAP", "Onboarding paywall - first launch", "1. Fresh install as free user", "Onboarding paywall appears with feature carousel. Can dismiss", "P2", "Functional"), + ("IAP", "Onboarding paywall not shown again", "1. Dismiss onboarding paywall\n2. Restart app", "Paywall does not appear again", "P2", "Functional"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws, r, f"F-{tid:03d}", area, case, steps, expected, priority, ttype) + tid += 1 + + +# ============================================================ +# SHEET 2: Edge Cases & Negative Tests +# ============================================================ +ws2 = wb.create_sheet() +setup_sheet(ws2, "Edge Cases & Negative") +r = 2 +eid = 1 + +r = add_section(ws2, r, "DATA & STATE EDGE CASES") +tests = [ + ("Data", "Duplicate game IDs in schedule", "1. Load schedule with known duplicate game IDs", "Duplicates handled gracefully. No crash. Latest version shown", "P2", "Edge Case"), + ("Data", "Stadium rename handling", "1. Search for old stadium name (e.g., 'SBC Park')", "Resolves to current name via StadiumAlias lookup", "P2", "Edge Case"), + ("Data", "Timezone edge case - midnight game", "1. View game at 11:30 PM local time\n2. Check which calendar day it appears on", "Game appears on correct local date, not UTC date", "P2", "Edge Case"), + ("Data", "Empty games for date range", "1. Pick off-season dates (e.g., December for MLB)", "No games found. Helpful message shown", "P1", "Edge Case"), + ("Data", "Very long trip (30+ days)", "1. Plan trip spanning 30+ days", "Engine handles without crash. Results may be limited", "P2", "Edge Case"), + ("Data", "Very short trip (1 day)", "1. Select same start/end date\n2. Plan", "Single-day itinerary with games available that day", "P2", "Edge Case"), + ("Data", "All regions deselected", "1. Try to deselect all 3 regions", "At least 1 region must remain. UI prevents full deselection", "P2", "Validation"), + ("Data", "No sports selected", "1. Deselect all sports\n2. Try to plan", "Plan button disabled. Validation message shown", "P2", "Validation"), + ("Data", "Large number of saved trips (50+)", "1. Save 50+ trips\n2. Browse My Trips", "List performs smoothly. No memory issues", "P3", "Performance"), + ("Data", "Trip with 0 games but valid stops", "1. Create trip where games are cancelled/rescheduled", "Trip shows stops without game cards. Travel still calculated", "P3", "Edge Case"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws2, r, f"E-{eid:03d}", area, case, steps, expected, priority, ttype) + eid += 1 + +r = add_section(ws2, r, "NETWORK & OFFLINE") +tests = [ + ("Network", "Full offline usage", "1. Enable airplane mode\n2. Launch app\n3. Browse home, schedule, trips", "App works with cached data. No crash. Sync features gracefully unavailable", "P1", "Negative"), + ("Network", "Lose network mid-planning", "1. Start planning a trip\n2. Toggle airplane mode\n3. Tap Plan", "Planning uses local data. May show fewer results. No crash", "P1", "Negative"), + ("Network", "Lose network during PDF export", "1. Start PDF export\n2. Toggle airplane mode", "Export fails gracefully. Error message shown. No partial corrupt file", "P2", "Negative"), + ("Network", "Slow network (poor connection)", "1. Use network link conditioner to simulate 3G\n2. Use app normally", "App responsive. Longer loading indicators. No timeouts causing crash", "P2", "Negative"), + ("Network", "Network recovery after offline", "1. Use app offline\n2. Re-enable network\n3. Navigate around", "Background sync resumes. Data refreshes", "P2", "Functional"), + ("Network", "CloudKit quota exceeded", "1. Simulate CloudKit throttling", "Sync fails gracefully. Local data unaffected. Retry on next launch", "P3", "Negative"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws2, r, f"E-{eid:03d}", area, case, steps, expected, priority, ttype) + eid += 1 + +r = add_section(ws2, r, "INTERRUPTIONS & LIFECYCLE") +tests = [ + ("Lifecycle", "Incoming phone call during planning", "1. Start planning trip\n2. Receive phone call\n3. End call\n4. Return to app", "Wizard state preserved. Can continue planning", "P1", "Interruption"), + ("Lifecycle", "Memory warning during PDF export", "1. Generate PDF for large trip\n2. Open other memory-heavy apps", "Export completes or fails gracefully. No crash", "P2", "Interruption"), + ("Lifecycle", "App killed during save", "1. Tap save trip\n2. Immediately force-kill app\n3. Relaunch", "Trip may or may not be saved (depends on timing). No data corruption", "P2", "Interruption"), + ("Lifecycle", "Rotate device during wizard", "1. Open wizard in portrait\n2. Rotate to landscape", "Layout adapts correctly (if supported) or remains portrait-locked", "P3", "Functional"), + ("Lifecycle", "Multitasking - split view (iPad)", "1. Open app in split view\n2. Use all features", "Layout adapts to smaller width. No clipping or overlaps", "P3", "Functional"), + ("Lifecycle", "Background app refresh", "1. Background app for 1+ hours\n2. Foreground app", "Data may have refreshed via background sync. No stale state", "P2", "Functional"), + ("Lifecycle", "Low storage on device", "1. Fill device storage\n2. Try to save trip", "Error handled gracefully. No crash. Message about storage", "P3", "Negative"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws2, r, f"E-{eid:03d}", area, case, steps, expected, priority, ttype) + eid += 1 + +r = add_section(ws2, r, "INPUT VALIDATION") +tests = [ + ("Validation", "Empty title for poll", "1. Try to create poll with empty title", "Create button disabled or validation error", "P2", "Validation"), + ("Validation", "Special characters in search", "1. Type emoji/special chars in schedule search", "Search handles gracefully. No crash. May show no results", "P3", "Validation"), + ("Validation", "Very long text in notes field", "1. Enter 5000+ characters in visit notes", "Text accepted or truncated. No crash. Scrollable", "P3", "Validation"), + ("Validation", "Negative score in visit", "1. Enter -1 in score field", "Input rejected or clamped to 0", "P3", "Validation"), + ("Validation", "Future date for stadium visit", "1. Set visit date to next year", "Date accepted (user may have tickets). Or validation if restricted", "P3", "Edge Case"), + ("Validation", "Same start/end location (By Route)", "1. Enter same city for start and end", "Handled: either error message or round-trip calculated", "P2", "Validation"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws2, r, f"E-{eid:03d}", area, case, steps, expected, priority, ttype) + eid += 1 + + +# ============================================================ +# SHEET 3: Accessibility Tests +# ============================================================ +ws3 = wb.create_sheet() +setup_sheet(ws3, "Accessibility") +r = 2 +aid = 1 + +r = add_section(ws3, r, "VOICEOVER") +tests = [ + ("VoiceOver", "Home tab navigable", "1. Enable VoiceOver\n2. Navigate Home tab", "All elements have meaningful labels. Tab bar, hero card, buttons all announced", "P1", "A11y"), + ("VoiceOver", "Wizard navigable", "1. Open wizard with VoiceOver\n2. Navigate all steps", "Planning modes, dates, sports, regions all have labels. Selection state announced", "P1", "A11y"), + ("VoiceOver", "Trip detail navigable", "1. Open trip detail with VoiceOver", "Map, stats, itinerary items all labeled. Games announce teams and times", "P1", "A11y"), + ("VoiceOver", "Schedule tab navigable", "1. Browse schedule with VoiceOver", "Games, filters, search all accessible. Sport chips announce selected state", "P2", "A11y"), + ("VoiceOver", "Settings navigable", "1. Navigate Settings with VoiceOver", "All sections, toggles, sliders properly labeled", "P2", "A11y"), + ("VoiceOver", "Decorative images hidden", "1. VoiceOver should skip decorative icons", "Icons with accessibilityHidden(true) are skipped. Only meaningful content read", "P2", "A11y"), + ("VoiceOver", "Button labels descriptive", "1. Focus on all buttons via VoiceOver", "Buttons announce action (e.g., 'Save to favorites', not just 'heart')", "P1", "A11y"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws3, r, f"A-{aid:03d}", area, case, steps, expected, priority, ttype) + aid += 1 + +r = add_section(ws3, r, "DYNAMIC TYPE") +tests = [ + ("Dynamic Type", "Default text size", "1. Set text size to default\n2. Browse all screens", "All text readable. Layout correct", "P1", "A11y"), + ("Dynamic Type", "Extra Large text", "1. Settings > Display > Text Size > Extra Large\n2. Browse app", "Text scales up. Layout adapts. No clipping or overlaps", "P1", "A11y"), + ("Dynamic Type", "Accessibility XXL", "1. Settings > Accessibility > Larger Text > max size\n2. Browse app", "Text very large. Buttons still tappable. Can scroll to reach all content", "P1", "A11y"), + ("Dynamic Type", "Extra Small text", "1. Set text size to smallest\n2. Browse app", "Text readable (not too tiny). Layout doesn't break with extra space", "P2", "A11y"), + ("Dynamic Type", "Wizard at large text", "1. Set XXXL text\n2. Open trip wizard\n3. Navigate all steps", "All steps reachable via scrolling. Buttons tappable. Calendar usable", "P1", "A11y"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws3, r, f"A-{aid:03d}", area, case, steps, expected, priority, ttype) + aid += 1 + +r = add_section(ws3, r, "COLOR & CONTRAST") +tests = [ + ("Contrast", "Light mode contrast", "1. Set light mode\n2. Check all text/buttons", "Text meets WCAG AA contrast ratio (4.5:1 for normal text)", "P2", "A11y"), + ("Contrast", "Dark mode contrast", "1. Set dark mode\n2. Check all text/buttons", "Text meets WCAG AA contrast ratio", "P2", "A11y"), + ("Contrast", "Color-blind safe", "1. Enable color filters (Deuteranopia)\n2. Check UI", "No information conveyed by color alone. Icons/text supplement color cues", "P2", "A11y"), + ("Contrast", "Reduce transparency", "1. Settings > Accessibility > Reduce Transparency\n2. Browse app", "Background effects simplified. Text remains readable", "P3", "A11y"), + ("Contrast", "Bold text", "1. Settings > Accessibility > Bold Text\n2. Browse app", "Text renders bold. No layout breaks", "P3", "A11y"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws3, r, f"A-{aid:03d}", area, case, steps, expected, priority, ttype) + aid += 1 + +r = add_section(ws3, r, "MOTION & INTERACTION") +tests = [ + ("Motion", "Reduce Motion enabled", "1. Settings > Accessibility > Reduce Motion\n2. Use app", "Animations disabled/simplified. No parallax or spring effects", "P2", "A11y"), + ("Motion", "Hit targets minimum 44x44pt", "1. Audit all tappable elements", "All buttons, toggles, and links meet 44x44pt minimum", "P2", "A11y"), + ("Motion", "Keyboard navigation (external keyboard)", "1. Connect Bluetooth keyboard\n2. Navigate app", "Tab key moves focus. Enter activates buttons. Logical tab order", "P3", "A11y"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws3, r, f"A-{aid:03d}", area, case, steps, expected, priority, ttype) + aid += 1 + + +# ============================================================ +# SHEET 4: Performance & Stability +# ============================================================ +ws4 = wb.create_sheet() +setup_sheet(ws4, "Performance & Stability") +r = 2 +pid = 1 + +r = add_section(ws4, r, "LAUNCH & LOAD TIMES") +tests = [ + ("Performance", "Cold launch time", "1. Kill app\n2. Launch and time until interactive", "Home screen interactive within 3 seconds", "P1", "Performance"), + ("Performance", "Warm launch time", "1. Background app\n2. Foreground and time", "App interactive within 1 second", "P1", "Performance"), + ("Performance", "Bootstrap time (first launch)", "1. Fresh install\n2. Time bootstrap", "Bootstrap completes within 5 seconds (bundled JSON load)", "P1", "Performance"), + ("Performance", "Planning engine response time", "1. Plan trip with typical parameters\n2. Time from tap to results", "Results appear within 10 seconds for typical trips", "P1", "Performance"), + ("Performance", "PDF export time", "1. Export PDF for 5-city trip\n2. Time generation", "PDF generated within 15 seconds", "P2", "Performance"), + ("Performance", "Schedule tab load time", "1. Switch to Schedule tab\n2. Time until games visible", "Games visible within 2 seconds (cached data)", "P2", "Performance"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws4, r, f"P-{pid:03d}", area, case, steps, expected, priority, ttype) + pid += 1 + +r = add_section(ws4, r, "MEMORY & RESOURCES") +tests = [ + ("Memory", "Memory usage during normal use", "1. Profile with Instruments\n2. Navigate all tabs", "Memory stays under 200MB during normal usage", "P1", "Performance"), + ("Memory", "Memory during PDF export", "1. Export large trip PDF\n2. Monitor memory", "Memory spikes handled. No OOM crash. Memory returns to normal after", "P2", "Performance"), + ("Memory", "No memory leaks on navigation", "1. Navigate back and forth 20x\n2. Check for leaks", "No sustained memory growth. Views deallocated properly", "P2", "Performance"), + ("Memory", "Photo import memory", "1. Import 20 photos\n2. Monitor memory", "Photos processed without OOM. Memory released after processing", "P2", "Performance"), + ("Memory", "Scroll performance in schedule", "1. Load full schedule\n2. Fast-scroll through list", "Smooth 60fps scrolling. No frame drops", "P2", "Performance"), + ("Memory", "Background memory cleanup", "1. Background app\n2. Check memory reduction", "App releases caches and non-essential memory when backgrounded", "P3", "Performance"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws4, r, f"P-{pid:03d}", area, case, steps, expected, priority, ttype) + pid += 1 + +r = add_section(ws4, r, "STABILITY") +tests = [ + ("Stability", "No crashes in 30-min session", "1. Use app for 30 minutes covering all features", "Zero crashes. No hangs > 3 seconds", "P1", "Stability"), + ("Stability", "Rapid tab switching", "1. Rapidly switch between all 5 tabs 50 times", "No crash. UI responds correctly each time", "P2", "Stability"), + ("Stability", "Rapid wizard open/close", "1. Open wizard → Cancel → Open → Cancel 20 times", "No crash. No memory growth", "P2", "Stability"), + ("Stability", "Plan multiple trips sequentially", "1. Plan 5 trips back-to-back", "All plans complete. No degradation. Memory stable", "P2", "Stability"), + ("Stability", "Overnight background", "1. Leave app backgrounded overnight\n2. Open in morning", "App resumes normally. Data fresh from background sync", "P2", "Stability"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws4, r, f"P-{pid:03d}", area, case, steps, expected, priority, ttype) + pid += 1 + + +# ============================================================ +# SHEET 5: Device Compatibility +# ============================================================ +ws5 = wb.create_sheet() +setup_sheet(ws5, "Device Compatibility") +r = 2 +did = 1 + +r = add_section(ws5, r, "DEVICE MODELS") +devices = [ + ("iPhone SE (3rd gen)", "Smallest supported screen. Verify all content fits"), + ("iPhone 16", "Standard size. Primary test device"), + ("iPhone 16 Plus", "Larger screen. Verify layout fills properly"), + ("iPhone 16 Pro Max", "Largest phone screen. Verify no excessive whitespace"), + ("iPad (if supported)", "Tablet layout. Verify split view, larger canvas"), +] +for device, notes in devices: + r = add_row(ws5, r, f"D-{did:03d}", "Device", f"Full smoke test on {device}", f"1. Run full smoke test suite on {device}", f"All features work. Layout correct. {notes}", "P2", "Compatibility") + did += 1 + +r = add_section(ws5, r, "iOS VERSIONS") +tests = [ + ("iOS", "Minimum supported iOS version", "1. Run on minimum supported iOS\n2. Full smoke test", "All features work on minimum OS. No deprecated API crashes", "P1", "Compatibility"), + ("iOS", "Latest iOS version", "1. Run on latest iOS\n2. Full smoke test", "All features work. No new deprecation warnings causing issues", "P1", "Compatibility"), + ("iOS", "iOS beta (if available)", "1. Run on current iOS beta", "App functions. Note any beta-specific issues for future fix", "P3", "Compatibility"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws5, r, f"D-{did:03d}", area, case, steps, expected, priority, ttype) + did += 1 + +r = add_section(ws5, r, "DISPLAY & ORIENTATION") +tests = [ + ("Display", "Portrait orientation", "1. Use app in portrait throughout", "All screens render correctly in portrait", "P1", "Compatibility"), + ("Display", "Landscape orientation", "1. Rotate to landscape\n2. Check all screens", "App locks to portrait or adapts layout", "P3", "Compatibility"), + ("Display", "Dark mode system-wide", "1. Enable system dark mode\n2. App set to 'System'\n3. Browse all screens", "All screens use dark theme. No unreadable text or invisible elements", "P1", "Compatibility"), + ("Display", "Light mode system-wide", "1. Enable system light mode\n2. Browse all screens", "All screens use light theme. Proper contrast", "P1", "Compatibility"), +] +for area, case, steps, expected, priority, ttype in tests: + r = add_row(ws5, r, f"D-{did:03d}", area, case, steps, expected, priority, ttype) + did += 1 + + +# ============================================================ +# Save +# ============================================================ +output = "/Users/treyt/Desktop/code/SportsTime/docs/SportsTime_QA_Test_Plan.xlsx" +wb.save(output) +print(f"Saved to {output}") +print(f"Sheets: {[s.title for s in wb.worksheets]}") +total = sum(1 for ws in wb.worksheets for row in ws.iter_rows(min_row=2) if row[0].value and str(row[0].value).startswith(("F-", "E-", "A-", "P-", "D-"))) +print(f"Total test cases: {total}")