import XCTest /// UI Tests for Portfolio Journal app /// These tests verify the app's user interface and navigation flows final class PortfolioJournalUITests: XCTestCase { var app: XCUIApplication! override func setUpWithError() throws { continueAfterFailure = false app = XCUIApplication() app.launchArguments = ["--uitesting"] app.launch() } override func tearDownWithError() throws { app = nil } // MARK: - App Launch Tests func testAppLaunches() throws { // Verify the app launches successfully XCTAssertTrue(app.exists) } func testTabBarExists() throws { // Wait for the tab bar to appear let tabBar = app.tabBars.firstMatch let exists = tabBar.waitForExistence(timeout: 5) XCTAssertTrue(exists, "Tab bar should exist after launch") } // MARK: - Navigation Tests func testDashboardTabIsSelected() throws { // Dashboard should be the default selected tab let tabBar = app.tabBars.firstMatch _ = tabBar.waitForExistence(timeout: 5) // Look for Dashboard tab button let dashboardTab = tabBar.buttons["Dashboard"] if dashboardTab.exists { XCTAssertTrue(dashboardTab.isSelected || dashboardTab.isHittable) } } func testNavigateToSourcesTab() throws { let tabBar = app.tabBars.firstMatch _ = tabBar.waitForExistence(timeout: 5) let sourcesTab = tabBar.buttons["Sources"] if sourcesTab.exists { sourcesTab.tap() // Verify we're on the Sources screen XCTAssertTrue(sourcesTab.isSelected || sourcesTab.isHittable) } } func testNavigateToGoalsTab() throws { let tabBar = app.tabBars.firstMatch _ = tabBar.waitForExistence(timeout: 5) let goalsTab = tabBar.buttons["Goals"] if goalsTab.exists { goalsTab.tap() XCTAssertTrue(goalsTab.isSelected || goalsTab.isHittable) } } func testNavigateToJournalTab() throws { let tabBar = app.tabBars.firstMatch _ = tabBar.waitForExistence(timeout: 5) let journalTab = tabBar.buttons["Journal"] if journalTab.exists { journalTab.tap() XCTAssertTrue(journalTab.isSelected || journalTab.isHittable) } } func testNavigateToSettingsTab() throws { let tabBar = app.tabBars.firstMatch _ = tabBar.waitForExistence(timeout: 5) let settingsTab = tabBar.buttons["Settings"] if settingsTab.exists { settingsTab.tap() XCTAssertTrue(settingsTab.isSelected || settingsTab.isHittable) } } // MARK: - All Tabs Navigation Test func testNavigateThroughAllTabs() throws { let tabBar = app.tabBars.firstMatch guard tabBar.waitForExistence(timeout: 5) else { XCTFail("Tab bar not found") return } let tabs = ["Dashboard", "Sources", "Goals", "Journal", "Settings"] for tabName in tabs { let tab = tabBar.buttons[tabName] if tab.exists && tab.isHittable { tab.tap() // Give time for navigation Thread.sleep(forTimeInterval: 0.3) } } // Return to Dashboard let dashboardTab = tabBar.buttons["Dashboard"] if dashboardTab.exists { dashboardTab.tap() } } // MARK: - App Store Screenshot Capture /// Captures the redesigned iPad Charts screen (sidebar tiles + KPI header). /// Works on both iPad (sidebar buttons) and iPhone (tab bar). func testCaptureChartsScreen() throws { let capture = XCUIApplication() capture.launchArguments = ["--screenshots"] capture.launch() // Wait for main UI (tab bar on iPhone, sidebar on iPad) Thread.sleep(forTimeInterval: 4.0) // Dismiss the notification-permission system alert if it appears let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let denyButton = springboard.alerts.buttons["Don't Allow"] if denyButton.waitForExistence(timeout: 3) { denyButton.tap() Thread.sleep(forTimeInterval: 1.0) } // Try tab bar first (iPhone), then any button labeled Charts (iPad sidebar) let tabBar = capture.tabBars.firstMatch if tabBar.exists && tabBar.buttons["Charts"].exists { tabBar.buttons["Charts"].tap() } else { let chartsButton = capture.buttons["Charts"].firstMatch if chartsButton.waitForExistence(timeout: 5) { chartsButton.tap() } else { capture.staticTexts["Charts"].firstMatch.tap() } } Thread.sleep(forTimeInterval: 3.0) func snap(_ name: String) { let shot = XCTAttachment(screenshot: capture.screenshot()) shot.name = name shot.lifetime = .keepAlways add(shot) } snap("01_Evolution_default") // Switch period to All (exercises the shared snapshot cache across ranges) let allSegment = capture.buttons["All"].firstMatch if allSegment.waitForExistence(timeout: 3) { allSegment.tap() Thread.sleep(forTimeInterval: 2.0) snap("02_Evolution_All") } // Switch chart type to a free multi-control chart and back (cache reuse path) let compareTile = capture.buttons["Compare"].firstMatch if compareTile.waitForExistence(timeout: 3) { compareTile.tap() Thread.sleep(forTimeInterval: 2.0) snap("03_Compare") } let periodTile = capture.buttons["Period vs Period"].firstMatch if periodTile.waitForExistence(timeout: 3) { periodTile.tap() Thread.sleep(forTimeInterval: 2.0) snap("04_PeriodVsPeriod") } let evolutionTile = capture.buttons["Evolution"].firstMatch if evolutionTile.waitForExistence(timeout: 3) { evolutionTile.tap() Thread.sleep(forTimeInterval: 2.0) snap("05_Evolution_back") } } /// Regression (1.4.2): selecting Allocation / Contributions / Rolling 12M in the /// Charts sidebar must actually switch the chart — taps on the old custom tiles /// were unreliable on iPad. Also verifies the Rolling 12M period filter reacts. func testChartSidebarSelection() throws { // Landscape reproduces the decorative-background overlay geometry that // killed sidebar taps (AppBackground circle overflowing its panel). XCUIDevice.shared.orientation = .landscapeLeft let capture = XCUIApplication() capture.launchArguments = ["--screenshots"] capture.launch() Thread.sleep(forTimeInterval: 4.0) let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let denyButton = springboard.alerts.buttons["Don't Allow"] if denyButton.waitForExistence(timeout: 3) { denyButton.tap() Thread.sleep(forTimeInterval: 1.0) } let tabBar = capture.tabBars.firstMatch if tabBar.exists && tabBar.buttons["Charts"].exists { tabBar.buttons["Charts"].tap() } else { // iPad main sidebar rows are cells with a static text label capture.staticTexts["Charts"].firstMatch.tap() } Thread.sleep(forTimeInterval: 2.0) func snap(_ name: String) { let shot = XCTAttachment(screenshot: capture.screenshot()) shot.name = name shot.lifetime = .keepAlways add(shot) } // Allocation let allocationRow = capture.staticTexts["Allocation"].firstMatch XCTAssertTrue(allocationRow.waitForExistence(timeout: 5), "Allocation row not found in sidebar") allocationRow.tap() Thread.sleep(forTimeInterval: 2.0) snap("charts_allocation") XCTAssertTrue( capture.staticTexts["Asset Allocation"].firstMatch.waitForExistence(timeout: 5), "Allocation chart did not load after tapping its sidebar row" ) // Contributions let contributionsRow = capture.staticTexts["Contributions"].firstMatch XCTAssertTrue(contributionsRow.waitForExistence(timeout: 5), "Contributions row not found") contributionsRow.tap() Thread.sleep(forTimeInterval: 2.0) snap("charts_contributions") XCTAssertTrue( capture.staticTexts["Monthly avg"].firstMatch.waitForExistence(timeout: 5), "Contributions chart did not load after tapping its sidebar row" ) // Rolling 12M + period filter let rollingRow = capture.staticTexts["Rolling 12M"].firstMatch XCTAssertTrue(rollingRow.waitForExistence(timeout: 5), "Rolling 12M row not found") rollingRow.tap() Thread.sleep(forTimeInterval: 2.0) snap("charts_rolling_all") let threeMonths = capture.buttons["3M"].firstMatch if threeMonths.waitForExistence(timeout: 3) { threeMonths.tap() Thread.sleep(forTimeInterval: 1.5) snap("charts_rolling_3m") } } /// Regression (1.4.2): WITHOUT premium, tapping a premium chart must still /// select it and show the unlock teaser — blocking selection behind a paywall /// sheet made premium tiles look completely dead on non-premium devices. func testChartSelectionWithoutPremium() throws { let capture = XCUIApplication() capture.launchArguments = ["--screenshots", "--no-premium"] capture.launch() Thread.sleep(forTimeInterval: 4.0) let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let denyButton = springboard.alerts.buttons["Don't Allow"] if denyButton.waitForExistence(timeout: 3) { denyButton.tap() Thread.sleep(forTimeInterval: 1.0) } let tabBar = capture.tabBars.firstMatch if tabBar.exists && tabBar.buttons["Charts"].exists { tabBar.buttons["Charts"].tap() } else { capture.staticTexts["Charts"].firstMatch.tap() } Thread.sleep(forTimeInterval: 2.0) // Premium chart: must select and show the unlock teaser (not a dead tap) let allocationRow = capture.staticTexts["Allocation"].firstMatch XCTAssertTrue(allocationRow.waitForExistence(timeout: 5), "Allocation row not found in sidebar") allocationRow.tap() Thread.sleep(forTimeInterval: 2.0) let shot = XCTAttachment(screenshot: capture.screenshot()) shot.name = "charts_allocation_locked" shot.lifetime = .keepAlways add(shot) XCTAssertTrue( capture.buttons["Unlock Premium"].firstMatch.waitForExistence(timeout: 5), "Premium chart tap did not select the chart / show the unlock teaser" ) // The unlock button must present the paywall capture.buttons["Unlock Premium"].firstMatch.tap() Thread.sleep(forTimeInterval: 2.0) let paywallShot = XCTAttachment(screenshot: capture.screenshot()) paywallShot.name = "charts_paywall" paywallShot.lifetime = .keepAlways add(paywallShot) } /// Captures full-screen screenshots of the main tabs with demo data for App Store /// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped, /// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var /// (e.g. "es", "de"). Screenshots are attached to the test result and extracted from /// the .xcresult afterwards by the framing pipeline. func testCaptureScreenshots() throws { let capture = XCUIApplication() capture.launchArguments = ["--screenshots"] if let lang = ProcessInfo.processInfo.environment["SCREENSHOT_LANG"], !lang.isEmpty { capture.launchArguments += ["-AppleLanguages", "(\(lang))", "-AppleLocale", lang] } capture.launch() let tabBar = capture.tabBars.firstMatch guard tabBar.waitForExistence(timeout: 20) else { XCTFail("Tab bar not found in screenshot mode") return } // Let demo data seed and charts render. Thread.sleep(forTimeInterval: 3.0) func snap(_ name: String) { let shot = XCTAttachment(screenshot: capture.screenshot()) shot.name = name shot.lifetime = .keepAlways add(shot) } // Dashboard is already selected on launch. snap("01_Dashboard") for (index, tabName) in ["Sources", "Goals", "Journal", "Settings"].enumerated() { let tab = tabBar.buttons[tabName] if tab.waitForExistence(timeout: 5), tab.isHittable { tab.tap() Thread.sleep(forTimeInterval: 1.5) snap(String(format: "%02d_%@", index + 2, tabName)) } } } }