7044cfd441
El ritual mensual como flujo paso a paso en vez de formulario: - Una fuente por pantalla: valor anterior a la vista, entrada grande (40pt rounded), delta en vivo (chip verde/rojo con importe y %), sugerencia de portapapeles, 'Igual que el mes pasado' y 'Omitir' - Barra de progreso del ritual; cancelable solo al inicio (interactiveDismiss bloqueado a mitad para no perder valores) - Reflexión: mood (5 estados existentes) + nota de una línea - Cierre: total nuevo, delta del mes, streak, compartir y haptic de éxito - Persistencia por los MISMOS caminos que Quick Update (Snapshots) y el journal (MonthlyCheckInStore → JournalEntry): coherente con el resto de entradas - Entrada: CTA del check-in card en Home (fullScreenCover); la fila de completado sigue navegando al journal - Fix: referenceDate congelada en @State al presentar — el card la recalcula al guardar y SwiftUI re-evaluaba el contenido del cover cambiando el mes a mitad de flujo - --pending-checkin en ScreenshotMode (revierte el seed a 'mes pendiente') + UITest testGuidedCheckInFlow end-to-end (valor → skips → mood → cierre) ✓ - Strings ×7 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
430 lines
16 KiB
Swift
430 lines
16 KiB
Swift
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)
|
|
}
|
|
|
|
/// Calm 2.0 Fase 2: walks the guided check-in end to end — enter a value on
|
|
/// the first source (live delta), skip the rest, pick a mood, finish, and
|
|
/// verify the closing summary appears. `--pending-checkin` puts the seeded
|
|
/// data back into a "month not done" state.
|
|
func testGuidedCheckInFlow() throws {
|
|
XCUIDevice.shared.orientation = .portrait
|
|
let capture = XCUIApplication()
|
|
capture.launchArguments = ["--screenshots", "--pending-checkin"]
|
|
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)
|
|
}
|
|
|
|
func snap(_ name: String) {
|
|
let shot = XCTAttachment(screenshot: capture.screenshot())
|
|
shot.name = name
|
|
shot.lifetime = .keepAlways
|
|
add(shot)
|
|
}
|
|
|
|
snap("guided_00_home_pending")
|
|
|
|
let cta = capture.buttons["checkin_start_cta"].firstMatch
|
|
XCTAssertTrue(cta.waitForExistence(timeout: 5), "Pending check-in CTA not found on Home")
|
|
cta.tap()
|
|
Thread.sleep(forTimeInterval: 1.5)
|
|
|
|
// First source: type a value, verify Next enables, advance
|
|
let valueField = capture.textFields["guided_value_field"].firstMatch
|
|
XCTAssertTrue(valueField.waitForExistence(timeout: 5), "Guided flow value field not found")
|
|
valueField.tap()
|
|
valueField.typeText("12345")
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
snap("guided_01_source_value")
|
|
|
|
let next = capture.buttons["guided_next_btn"].firstMatch
|
|
XCTAssertTrue(next.isEnabled, "Next should enable after typing a value")
|
|
next.tap()
|
|
Thread.sleep(forTimeInterval: 0.8)
|
|
|
|
// Skip the remaining sources until the reflection step appears
|
|
let finish = capture.buttons["guided_finish_btn"].firstMatch
|
|
var hops = 0
|
|
while !finish.exists && hops < 20 {
|
|
let skip = capture.buttons["guided_skip_btn"].firstMatch
|
|
guard skip.waitForExistence(timeout: 2) else { break }
|
|
skip.tap()
|
|
Thread.sleep(forTimeInterval: 0.6)
|
|
hops += 1
|
|
}
|
|
XCTAssertTrue(finish.waitForExistence(timeout: 5), "Reflection step not reached")
|
|
snap("guided_02_reflection")
|
|
|
|
// Pick a mood and finish
|
|
let mood = capture.otherElements["guided_mood_balanced"].firstMatch
|
|
if mood.exists {
|
|
mood.tap()
|
|
} else if capture.buttons["guided_mood_balanced"].firstMatch.exists {
|
|
capture.buttons["guided_mood_balanced"].firstMatch.tap()
|
|
}
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
finish.tap()
|
|
Thread.sleep(forTimeInterval: 2.0)
|
|
snap("guided_03_done")
|
|
|
|
XCTAssertTrue(
|
|
capture.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'check-in done'")).firstMatch
|
|
.waitForExistence(timeout: 5),
|
|
"Closing summary did not appear after finishing the check-in"
|
|
)
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
}
|
|
}
|
|
}
|