diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..05c8835 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# SportsTime Agent Notes + +This file is for AI/code agents working in this repo. + +## UI Test Docs + +- Start with `XCUITest-Authoring.md`. +- Use `XCUITestSuiteTemplate.swift` when creating new suites. +- Use `uiTestPrompt.md` when asking an agent to add/fix UI tests. + +## UI Test Ground Rules + +- Keep new UI tests in `SportsTimeUITests/Tests/`. +- Reuse page objects in `SportsTimeUITests/Framework/Screens.swift`. +- Reuse shared setup and helpers from `SportsTimeUITests/Framework/BaseUITestCase.swift`. +- Reuse high-level flows from `TestFlows` before adding duplicate test steps. +- Prefer robust waits (`waitForExistenceOrFail`, `waitUntilHittable`) over sleeps. +- Use existing accessibility identifiers (`wizard.*`, `tripOptions.*`, `tripDetail.*`, etc.). +- Capture screenshots for key checkpoints in longer end-to-end tests. + +## Before Merging UI Test Changes + +- Run the touched test class. +- Run full UI suite: + - `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests` +- Run full scheme verification if behavior touched shared flows: + - `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO` diff --git a/CLAUDE.md b/CLAUDE.md index 30ba2d5..028a5bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,9 @@ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platfo # Run a single test xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests/planningMode_dateRange test +# Run UI tests only +xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests + # Data scraping (Python) cd Scripts && pip install -r requirements.txt python scrape_schedules.py --sport all --season 2026 @@ -298,7 +301,10 @@ View → modelContext.delete(model) → modelContext.save() → reload data → - **Test directory**: `SportsTimeTests/` — mirrors source structure - **File naming**: `{ClassName}Tests.swift` or `{Feature}Tests.swift` - **Helper files**: `SportsTimeTests/Helpers/MockServices.swift`, `SportsTimeTests/Helpers/TestFixtures.swift` -- **UI tests**: `SportsTimeUITests/` (template only, not actively used) +- **UI tests**: `SportsTimeUITests/` is active and uses XCTest + page-object patterns +- **UI authoring guide**: `XCUITest-Authoring.md` +- **UI suite template**: `XCUITestSuiteTemplate.swift` +- **UI request template**: `uiTestPrompt.md` ### Existing Test Suites diff --git a/README.md b/README.md index d16b9ba..041c325 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ SportsTime/ │ ├── Export/ # PDF generation │ └── Resources/ # Bundled JSON data ├── SportsTimeTests/ # Unit tests +├── SportsTimeUITests/ # UI test suites + screen objects ├── Scripts/ # Python data pipeline │ └── sportstime_parser/ # Schedule scraping & CloudKit upload ├── data/ # Local data files @@ -124,6 +125,14 @@ xcodebuild -project SportsTime.xcodeproj \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/EdgeCaseTests \ test + +# UI test target only +xcodebuild test-without-building \ + -project SportsTime.xcodeproj \ + -scheme SportsTime \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -parallel-testing-enabled NO \ + -only-testing:SportsTimeUITests ``` ### Data Pipeline @@ -151,7 +160,11 @@ python -m sportstime_parser upload --sport all ## Documentation - `CLAUDE.md` - Development guidelines and architecture details +- `AGENTS.md` - Agent-specific execution notes for this repo - `ARCHITECTURE.md` - Detailed system architecture +- `XCUITest-Authoring.md` - How existing UI tests are structured and how to add new ones +- `XCUITestSuiteTemplate.swift` - Starter suite template for new UI test files +- `uiTestPrompt.md` - Reusable prompt template for requesting UI test work - `docs/TEST_PLAN.md` - Test suite documentation - `docs/MARKET_RESEARCH.md` - Competitive analysis diff --git a/SportsTime.xcodeproj/xcshareddata/xcschemes/SportsTime.xcscheme b/SportsTime.xcodeproj/xcshareddata/xcschemes/SportsTime.xcscheme index fe5e1eb..f670ef8 100644 --- a/SportsTime.xcodeproj/xcshareddata/xcschemes/SportsTime.xcscheme +++ b/SportsTime.xcodeproj/xcshareddata/xcschemes/SportsTime.xcscheme @@ -43,7 +43,7 @@ + parallelizable = "NO"> 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 + if exists && isHittable { return self } + + func attemptScroll(_ scrollDirection: ScrollDirection, attempts: Int) -> Bool { + var remaining = attempts + while (!exists || !isHittable) && remaining > 0 { + switch scrollDirection { + case .down: + scrollView.swipeUp(velocity: .slow) + case .up: + scrollView.swipeDown(velocity: .slow) + } + remaining -= 1 } - switch direction { - case .down: - scrollView.swipeUp(velocity: .slow) - case .up: - scrollView.swipeDown(velocity: .slow) - } - scrollsRemaining -= 1 + return exists && isHittable } + + if attemptScroll(direction, attempts: maxScrolls) { + return self + } + + let reverseDirection: ScrollDirection = direction == .down ? .up : .down + if attemptScroll(reverseDirection, attempts: maxScrolls) { + return self + } + + XCTFail( + "Could not scroll \(self) into view after \(maxScrolls) scrolls in either direction", + file: file, + line: line + ) return self } } -enum ScrollDirection { +enum ScrollDirection: Equatable { case up, down } diff --git a/SportsTimeUITests/Framework/Screens.swift b/SportsTimeUITests/Framework/Screens.swift index 0a55409..0830e5c 100644 --- a/SportsTimeUITests/Framework/Screens.swift +++ b/SportsTimeUITests/Framework/Screens.swift @@ -74,7 +74,35 @@ struct HomeScreen { /// Taps "Start Planning" to open the Trip Wizard sheet. func tapStartPlanning() { - startPlanningButton.waitUntilHittable().tap() + let navTitle = app.navigationBars["Plan a Trip"] + let dateRangeMode = app.buttons["wizard.planningMode.dateRange"] + + if navTitle.exists || dateRangeMode.exists { + return + } + + func tapIfVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + guard element.waitForExistence(timeout: timeout), element.isHittable else { return false } + element.tap() + return true + } + + _ = tapIfVisible(startPlanningButton, timeout: BaseUITestCase.defaultTimeout) || + tapIfVisible(createTripToolbarButton, timeout: BaseUITestCase.shortTimeout) + + if navTitle.waitForExistence(timeout: BaseUITestCase.shortTimeout) || + dateRangeMode.waitForExistence(timeout: BaseUITestCase.shortTimeout) { + return + } + + _ = tapIfVisible(createTripToolbarButton, timeout: BaseUITestCase.shortTimeout) || + tapIfVisible(startPlanningButton, timeout: BaseUITestCase.shortTimeout) + + XCTAssertTrue( + navTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout) || + dateRangeMode.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Trip Wizard should appear after tapping start planning" + ) } /// Switches to a tab by tapping its tab bar button. @@ -150,10 +178,21 @@ struct TripWizardScreen { /// Waits for the wizard sheet to appear. @discardableResult func waitForLoad() -> TripWizardScreen { - if navigationTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout) || - planningModeButton("dateRange").waitForExistence(timeout: BaseUITestCase.defaultTimeout) { + if navigationTitle.waitForExistence(timeout: BaseUITestCase.longTimeout) || + planningModeButton("dateRange").waitForExistence(timeout: BaseUITestCase.longTimeout) { return self } + + // Fallback: if we're still on Home, trigger planning again. + let home = HomeScreen(app: app) + if home.startPlanningButton.exists || home.createTripToolbarButton.exists { + home.tapStartPlanning() + if navigationTitle.waitForExistence(timeout: BaseUITestCase.defaultTimeout) || + planningModeButton("dateRange").waitForExistence(timeout: BaseUITestCase.defaultTimeout) { + return self + } + } + XCTFail("Trip Wizard should appear") return self } @@ -169,6 +208,19 @@ struct TripWizardScreen { /// Selects the "By Dates" planning mode and waits for steps to expand. func selectDateRangeMode() { selectPlanningMode("dateRange") + + if monthLabel.waitForExistence(timeout: BaseUITestCase.shortTimeout) || + nextMonthButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) { + return + } + + // Retry once for occasional dropped taps under simulator load. + selectPlanningMode("dateRange") + XCTAssertTrue( + monthLabel.waitForExistence(timeout: BaseUITestCase.defaultTimeout) || + nextMonthButton.waitForExistence(timeout: BaseUITestCase.defaultTimeout), + "Date range controls should appear after selecting planning mode" + ) } /// Navigates the calendar to a target month/year and selects start/end dates. @@ -178,6 +230,11 @@ struct TripWizardScreen { startDay: String, endDay: String ) { + // Ensure date controls are rendered before attempting calendar navigation. + if !monthLabel.waitForExistence(timeout: BaseUITestCase.shortTimeout) { + selectDateRangeMode() + } + // First, navigate by month label so tests that assert month visibility stay stable. monthLabel.scrollIntoView(in: app.scrollViews.firstMatch) let targetMonthYear = "\(targetMonth) \(targetYear)" @@ -196,20 +253,20 @@ struct TripWizardScreen { let targetIdx = monthOrder.firstIndex(of: targetMonth) { if currentIdx > targetIdx { previousMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - previousMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + previousMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } else if currentIdx < targetIdx { nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } else { break } } else { nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } } else { nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } monthAttempts += 1 } @@ -223,27 +280,27 @@ struct TripWizardScreen { let startFallback = dayCells.element(boundBy: 0) let endFallback = dayCells.element(boundBy: min(4, max(1, dayCells.count - 1))) startFallback.scrollIntoView(in: app.scrollViews.firstMatch) - startFallback.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + startFallback.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() endFallback.scrollIntoView(in: app.scrollViews.firstMatch) - endFallback.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + endFallback.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() return } // Select start date — scroll calendar grid into view first startBtn.scrollIntoView(in: app.scrollViews.firstMatch) - startBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + startBtn.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() // Select end date let endBtn = dayButton(endDay) if endBtn.exists { endBtn.scrollIntoView(in: app.scrollViews.firstMatch) - endBtn.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + endBtn.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } else { let dayCells = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'wizard.dates.day.'")) guard dayCells.count > 1 else { return } let fallback = dayCells.element(boundBy: min(4, max(1, dayCells.count - 1))) fallback.scrollIntoView(in: app.scrollViews.firstMatch) - fallback.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + fallback.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } } @@ -560,8 +617,31 @@ struct SettingsScreen { // MARK: Assertions func assertLoaded() { - XCTAssertTrue(subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout), - "Settings should show Subscription section") + if subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout) { + return + } + + let proLabel = app.staticTexts["SportsTime Pro"] + let manageSubscriptionButton = app.buttons["Manage Subscription"] + if proLabel.exists || manageSubscriptionButton.exists || + upgradeProButton.exists || restorePurchasesButton.exists { + return + } + + // Retry tab switch once when the first tap doesn't switch tabs under load. + let settingsTab = app.tabBars.buttons["Settings"] + if settingsTab.waitForExistence(timeout: BaseUITestCase.shortTimeout), settingsTab.isHittable { + settingsTab.tap() + } + + XCTAssertTrue( + subscriptionSection.waitForExistence(timeout: BaseUITestCase.defaultTimeout) || + proLabel.waitForExistence(timeout: BaseUITestCase.shortTimeout) || + manageSubscriptionButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) || + upgradeProButton.waitForExistence(timeout: BaseUITestCase.shortTimeout) || + restorePurchasesButton.waitForExistence(timeout: BaseUITestCase.shortTimeout), + "Settings should show Subscription section" + ) } func assertVersionDisplayed() { diff --git a/SportsTimeUITests/Tests/TripWizardFlowTests.swift b/SportsTimeUITests/Tests/TripWizardFlowTests.swift index c4132eb..9ff83b2 100644 --- a/SportsTimeUITests/Tests/TripWizardFlowTests.swift +++ b/SportsTimeUITests/Tests/TripWizardFlowTests.swift @@ -92,7 +92,7 @@ final class TripWizardFlowTests: BaseUITestCase { // Navigate forward 3 times for _ in 0..<3 { wizard.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - wizard.nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + wizard.nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } // Month label should have changed @@ -113,14 +113,14 @@ final class TripWizardFlowTests: BaseUITestCase { // Go forward 3 months for _ in 0..<3 { wizard.nextMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - wizard.nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + wizard.nextMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() } let afterForward = wizard.monthLabel.label // Go back 1 month wizard.previousMonthButton.scrollIntoView(in: app.scrollViews.firstMatch) - wizard.previousMonthButton.waitUntilHittable(timeout: BaseUITestCase.shortTimeout).tap() + wizard.previousMonthButton.waitUntilHittable(timeout: BaseUITestCase.defaultTimeout).tap() XCTAssertNotEqual(wizard.monthLabel.label, afterForward, "Month should change after navigating backward") diff --git a/XCUITest-Authoring.md b/XCUITest-Authoring.md new file mode 100644 index 0000000..4dc0f7e --- /dev/null +++ b/XCUITest-Authoring.md @@ -0,0 +1,108 @@ +# XCUITest Authoring Guide + +This guide documents the current SportsTime UI test foundation and how to add new tests safely. + +## Current Foundation + +## Layout + +- Test target: `SportsTimeUITests` +- Base setup/helpers: `SportsTimeUITests/Framework/BaseUITestCase.swift` +- Page objects + shared flows: `SportsTimeUITests/Framework/Screens.swift` +- Suite files: `SportsTimeUITests/Tests/*.swift` +- Legacy mixed tests: `SportsTimeUITests/SportsTimeUITests.swift` and `SportsTimeUITests/SportsTimeUITestsLaunchTests.swift` + +## Existing Suite Coverage + +- `AppLaunchTests` +- `HomeTests` +- `TabNavigationTests` +- `ScheduleTests` +- `TripWizardFlowTests` +- `TripOptionsTests` +- `TripSavingTests` +- `ProgressTests` +- `SettingsTests` +- `AccessibilityTests` +- `StabilityTests` + +## Key Conventions + +- Inherit from `BaseUITestCase`. +- Use `@MainActor` test methods. +- Prefer page-object actions over direct element taps. +- Prefer existing high-level flows (`TestFlows.planDateRangeTrip`, `TestFlows.planAndSelectFirstTrip`) for shared setup. +- Use deterministic selectors with accessibility identifiers. +- Use `waitForExistenceOrFail` and `waitUntilHittable` instead of arbitrary sleeps. + +## Adding a New UI Test + +1. Pick the closest existing suite in `SportsTimeUITests/Tests/`. +2. If none fits, create a new suite using `XCUITestSuiteTemplate.swift`. +3. Add/extend page-object methods in `Screens.swift` before writing raw element code. +4. Reuse `TestFlows` when setup overlaps existing end-to-end flows. +5. Keep assertion messages explicit and behavior-focused. +6. Capture screenshot(s) for key milestone state in longer flows. +7. Run targeted tests, then full UI tests. + +## When to Edit `Screens.swift` + +Edit page objects when: + +- A new screen element needs a stable selector. +- The interaction is reused across multiple tests. +- The flow can be standardized (especially wizard planning flow). + +Do not add one-off test-only branching logic unless it removes a real flake. + +## Running Tests + +## Fast Loop (single test) + +```bash +xcodebuild test-without-building \ + -project SportsTime.xcodeproj \ + -scheme SportsTime \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -parallel-testing-enabled NO \ + -only-testing:SportsTimeUITests/TripWizardFlowTests/testF026_DateRangeSelection +``` + +## Per Class + +```bash +xcodebuild test-without-building \ + -project SportsTime.xcodeproj \ + -scheme SportsTime \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -parallel-testing-enabled NO \ + -only-testing:SportsTimeUITests/TripOptionsTests +``` + +## Full UI Suite + +```bash +xcodebuild test-without-building \ + -project SportsTime.xcodeproj \ + -scheme SportsTime \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -parallel-testing-enabled NO \ + -only-testing:SportsTimeUITests +``` + +## Full Scheme Validation (unit + UI) + +```bash +xcodebuild test-without-building \ + -project SportsTime.xcodeproj \ + -scheme SportsTime \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -parallel-testing-enabled NO +``` + +## Flake Prevention Notes + +- Keep simulator orientation consistent (portrait baseline from `BaseUITestCase`). +- For wizard date selection, navigate by month/year first and use day-cell fallback when specific IDs are unavailable. +- For cross-season planning tests, prefer deterministic fallback sports if selected sport has no viable schedule for current test data. +- Increase waits only where planning computation is the actual bottleneck. diff --git a/XCUITestSuiteTemplate.swift b/XCUITestSuiteTemplate.swift new file mode 100644 index 0000000..18ab132 --- /dev/null +++ b/XCUITestSuiteTemplate.swift @@ -0,0 +1,48 @@ +// +// __FeatureName__Tests.swift +// SportsTimeUITests +// +// Copy this file into SportsTimeUITests/Tests and rename placeholders. +// + +import XCTest + +final class __FeatureName__Tests: BaseUITestCase { + + // MARK: - Helpers + + @MainActor + private func openHome() -> HomeScreen { + let home = HomeScreen(app: app) + home.waitForLoad() + return home + } + + @MainActor + private func openWizard() -> TripWizardScreen { + let home = openHome() + home.tapStartPlanning() + + let wizard = TripWizardScreen(app: app) + wizard.waitForLoad() + return wizard + } + + // MARK: - Tests + + /// Replace with test case ID and behavior, e.g. F-200. + @MainActor + func testF___BehaviorName() { + let wizard = openWizard() + wizard.selectDateRangeMode() + + // Add scenario actions + // wizard.selectSport("mlb") + // wizard.selectRegion("central") + + // Add assertions + XCTAssertTrue(wizard.planTripButton.exists, "Plan button should be visible") + + captureScreenshot(named: "F___-BehaviorName") + } +} diff --git a/uiTestPrompt.md b/uiTestPrompt.md new file mode 100644 index 0000000..d658254 --- /dev/null +++ b/uiTestPrompt.md @@ -0,0 +1,49 @@ +# UI Test Prompt Template + +Use this prompt when asking an agent to add or modify SportsTime UI tests. + +--- + +You are updating SportsTime UI tests. + +## Goal + +- [Describe the behavior to test or fix.] + +## Scope + +- Update/add tests under `SportsTimeUITests/Tests/`. +- Reuse page objects in `SportsTimeUITests/Framework/Screens.swift`. +- Reuse shared setup in `SportsTimeUITests/Framework/BaseUITestCase.swift`. +- Reuse existing `TestFlows` where possible. + +## Required Changes + +- [List test suites to modify.] +- [List new test names.] +- [List selectors/page-object methods to add if needed.] + +## Constraints + +- Do not add raw sleeps unless strictly necessary. +- Prefer `waitForExistenceOrFail` and `waitUntilHittable`. +- Keep tests deterministic with current local test data. +- Keep existing naming style (`testF###_...`, `testP###_...`, etc.). + +## Validation + +Run these before finishing: + +1. Targeted class or test: + - `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests/` +2. Full UI suite: + - `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests` +3. If requested, full scheme: + - `xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO` + +## Output Format + +- Summarize files changed. +- Summarize root causes fixed. +- Include exact commands run and pass/fail outcomes. +- Call out any remaining flaky behavior or follow-up work.