Andromida/AndromidaUITests/AndromidaUITests.swift

466 lines
17 KiB
Swift

//
// AndromidaUITests.swift
// AndromidaUITests
//
// Created by Matt Bruce on 1/25/26.
//
import XCTest
final class AndromidaUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testOnboardingFlowCompletes() throws {
let onboardingApp = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
onboardingApp.launch()
let getStarted = onboardingApp.buttons["onboarding.getStarted"]
XCTAssertTrue(getStarted.waitForExistence(timeout: 8))
onboardingApp.terminate()
let completedApp = makeApp(resetDefaults: false, hasCompletedSetupWizard: true)
completedApp.launch()
let ritualsTab = completedApp.tabBars.buttons["Rituals"]
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
}
@MainActor
func testOnboardingHappyPath() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
app.launch()
XCTAssertTrue(app.buttons["onboarding.getStarted"].waitForExistence(timeout: 8))
app.buttons["onboarding.getStarted"].tap()
_ = app.otherElements["onboarding.goalSelection"].waitForExistence(timeout: 2)
let selectedGoal = tapFirstAvailableElementIfPresent(
app: app,
identifiers: [
"onboarding.goal.health",
"onboarding.goal.productivity",
"onboarding.goal.mindfulness",
"onboarding.goal.selfCare"
],
fallbackLabels: ["Health", "Focus", "Mindfulness", "Self-Care"],
timeout: 2
)
let goalContinue = app.buttons["onboarding.goalContinue"]
if goalContinue.waitForExistence(timeout: 8) {
goalContinue.tap()
} else if !selectedGoal {
throw XCTSkip("Onboarding goal controls were not discoverable in UI test runtime.")
}
_ = app.otherElements["onboarding.timeSelection"].waitForExistence(timeout: 2)
let selectedTime = tapFirstAvailableElementIfPresent(
app: app,
identifiers: [
"onboarding.time.morning",
"onboarding.time.midday",
"onboarding.time.afternoon",
"onboarding.time.evening",
"onboarding.time.night"
],
fallbackLabels: ["Morning", "Midday", "Afternoon", "Evening", "Night"],
timeout: 2
)
let timeContinue = app.buttons["onboarding.timeContinue"]
if timeContinue.waitForExistence(timeout: 8) {
timeContinue.tap()
} else if !selectedTime {
throw XCTSkip("Onboarding time controls were not discoverable in UI test runtime.")
}
if app.buttons["onboarding.startRitual"].waitForExistence(timeout: 8) {
app.buttons["onboarding.startRitual"].tap()
} else {
XCTAssertTrue(app.buttons["onboarding.skipRitual"].waitForExistence(timeout: 8))
app.buttons["onboarding.skipRitual"].tap()
}
if app.buttons["onboarding.firstCheckInSkip"].waitForExistence(timeout: 8) {
app.buttons["onboarding.firstCheckInSkip"].tap()
} else {
XCTAssertTrue(app.buttons["onboarding.firstCheckInContinueToRituals"].waitForExistence(timeout: 8))
app.buttons["onboarding.firstCheckInContinueToRituals"].tap()
}
XCTAssertTrue(app.buttons["onboarding.notificationsMaybeLater"].waitForExistence(timeout: 8))
app.buttons["onboarding.notificationsMaybeLater"].tap()
XCTAssertTrue(app.buttons["onboarding.letsGo"].waitForExistence(timeout: 8))
app.buttons["onboarding.letsGo"].tap()
XCTAssertTrue(app.tabBars.buttons["Rituals"].waitForExistence(timeout: 8))
}
@MainActor
func testCreateRitualFromRitualsTab() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
app.launch()
let ritualsTab = app.tabBars.buttons["Rituals"]
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
ritualsTab.tap()
let addMenu = app.buttons["rituals.addMenu"]
XCTAssertTrue(addMenu.waitForExistence(timeout: 8))
addMenu.tap()
let createNew = app.buttons["rituals.createNew"]
XCTAssertTrue(createNew.waitForExistence(timeout: 8))
createNew.tap()
let titleField = app.textFields["ritualEditor.titleField"]
XCTAssertTrue(titleField.waitForExistence(timeout: 8))
titleField.tap()
titleField.typeText("UI Test Ritual")
let themeField = app.textFields["ritualEditor.themeField"]
XCTAssertTrue(themeField.waitForExistence(timeout: 8))
themeField.tap()
themeField.typeText("Daily focus")
let newHabitField = app.textFields["ritualEditor.newHabitField"]
XCTAssertTrue(newHabitField.waitForExistence(timeout: 8))
newHabitField.tap()
newHabitField.typeText("Stretch")
let addHabitButton = app.buttons["ritualEditor.addHabitButton"]
XCTAssertTrue(addHabitButton.waitForExistence(timeout: 8))
addHabitButton.tap()
let saveButton = app.buttons["ritualEditor.saveButton"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 8))
saveButton.tap()
XCTAssertTrue(app.staticTexts["UI Test Ritual"].waitForExistence(timeout: 8))
}
@MainActor
func testLaunchPerformance() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
measure(metrics: [XCTApplicationLaunchMetric()]) {
app.launch()
app.terminate()
}
}
@MainActor
func testAppStorePortraitScreenshots() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
app.launch()
XCUIDevice.shared.orientation = .portrait
// Add three preset rituals across the day, then seed representative history from Debug settings.
addThreePresetRitualsAcrossDay(app: app)
preloadSixMonthsDemoDataFromDebug(app: app)
ensureDataRichStateForScreenshots(app: app)
openTab(app: app, label: "Today", index: 0)
XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Today should show active rituals for App Store screenshots.")
XCTAssertFalse(app.staticTexts["All caught up"].exists, "Today should show ritual content, not the no-rituals-for-time state.")
saveScreenshot(app: app, named: "01-today-focus-portrait")
openTab(app: app, label: "Rituals", index: 1)
XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Rituals should show active ritual cards for App Store screenshots.")
saveScreenshot(app: app, named: "02-ritual-arcs-portrait")
openTab(app: app, label: "Insights", index: 2)
saveScreenshot(app: app, named: "03-insights-trends-portrait")
openTab(app: app, label: "History", index: 3)
saveScreenshot(app: app, named: "04-history-calendar-portrait")
openHistoryDayDetailWithData(app: app)
saveScreenshot(app: app, named: "05-history-day-detail-portrait")
XCUIDevice.shared.orientation = .portrait
}
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["UITEST_MODE"] = "1"
if resetDefaults {
app.launchEnvironment["UITEST_RESET_USER_DEFAULTS"] = "1"
}
app.launchEnvironment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] = hasCompletedSetupWizard ? "1" : "0"
return app
}
private func addThreePresetRitualsAcrossDay(app: XCUIApplication) {
openTab(app: app, label: "Rituals", index: 1)
let addMenu = app.buttons["rituals.addMenu"]
guard addMenu.waitForExistence(timeout: 8) else { return }
addMenu.tap()
let browsePresets = app.buttons["rituals.browsePresets"]
guard browsePresets.waitForExistence(timeout: 8) else { return }
browsePresets.tap()
let presetTitles = ["Morning Hydration", "Midday Movement", "Evening Nutrition"]
for title in presetTitles {
if let preset = findElementWithVerticalSearch(
app: app,
label: title,
timeoutPerAttempt: 1.0,
swipeCount: 6
) {
if preset.isHittable {
preset.tap()
} else {
preset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let addButton = app.buttons["Add to My Rituals"]
if addButton.waitForExistence(timeout: 8) {
addButton.tap()
}
tapDoneInNavigationBar(app: app, title: title)
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
tapDoneInNavigationBar(app: app, title: "Preset Library")
}
private func preloadSixMonthsDemoDataFromDebug(app: XCUIApplication) {
openTab(app: app, label: "Settings", index: 4)
// Move into the lower Debug section before tapping preload.
_ = findElementWithVerticalSearch(app: app, label: "Debug", timeoutPerAttempt: 0.5, swipeCount: 8)
if let preload = findElementWithVerticalSearch(
app: app,
label: "Preload 6 Months Demo Data",
timeoutPerAttempt: 0.5,
swipeCount: 10
) {
if preload.isHittable {
preload.tap()
} else {
preload.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
waitForHistoryDataSeed(app: app)
}
}
private func waitForHistoryDataSeed(app: XCUIApplication) {
for _ in 0..<6 {
openTab(app: app, label: "History", index: 3)
let nonZeroDay = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] %@ AND NOT (label CONTAINS[c] %@)", "percent complete", "0 percent complete")
).firstMatch
if nonZeroDay.waitForExistence(timeout: 1.5) {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
}
private func ensureDataRichStateForScreenshots(app: XCUIApplication) {
// Try multiple time buckets until Today has content.
let candidateTimes = ["Anytime", "Morning", "Midday", "Afternoon", "Evening", "Night", "Real"]
for time in candidateTimes {
openTab(app: app, label: "Settings", index: 4)
setSimulatedTimeIfAvailable(app: app, label: time)
openTab(app: app, label: "Today", index: 0)
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
let hasActiveEmptyState = app.staticTexts["No Active Rituals"].exists
let hasTimeEmptyState = app.staticTexts["All caught up"].exists
if !hasActiveEmptyState && !hasTimeEmptyState {
return
}
}
}
private func setSimulatedTimeIfAvailable(app: XCUIApplication, label: String) {
if let button = firstMatchIfExists(app: app, label: label, timeout: 2) {
if button.isHittable {
button.tap()
} else {
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
return
}
for _ in 0..<4 {
app.swipeUp()
if let button = firstMatchIfExists(app: app, label: label, timeout: 1) {
if button.isHittable {
button.tap()
} else {
button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
return
}
}
}
private func firstMatchIfExists(app: XCUIApplication, label: String, timeout: TimeInterval) -> XCUIElement? {
let candidates: [XCUIElement] = [
app.buttons[label].firstMatch,
app.staticTexts[label].firstMatch,
app.otherElements[label].firstMatch
]
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let element = candidates.first(where: { $0.exists }) {
return element
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return nil
}
private func findElementWithVerticalSearch(
app: XCUIApplication,
label: String,
timeoutPerAttempt: TimeInterval,
swipeCount: Int
) -> XCUIElement? {
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
for _ in 0..<swipeCount {
app.swipeUp()
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
for _ in 0..<swipeCount {
app.swipeDown()
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
return nil
}
private func tapDoneInNavigationBar(app: XCUIApplication, title: String) {
let navDone = app.navigationBars[title].buttons["Done"].firstMatch
if navDone.waitForExistence(timeout: 6) {
navDone.tap()
return
}
let anyDone = app.buttons["Done"].firstMatch
if anyDone.waitForExistence(timeout: 4) {
anyDone.tap()
}
}
private func openTab(app: XCUIApplication, label: String, index: Int) {
let primary = app.tabBars.buttons[label].firstMatch
if primary.waitForExistence(timeout: 8) {
primary.tap()
return
}
let tabButtons = app.tabBars.buttons
if tabButtons.count > index {
tabButtons.element(boundBy: index).tap()
return
}
let fallback = app.buttons[label].firstMatch
if fallback.exists {
fallback.tap()
}
}
private func saveScreenshot(app: XCUIApplication, named name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func openHistoryDayDetailWithData(app: XCUIApplication) {
openTab(app: app, label: "History", index: 3)
let nonZeroDay = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] %@ AND NOT (label CONTAINS[c] %@)", "percent complete", "0 percent complete")
).firstMatch
if nonZeroDay.waitForExistence(timeout: 8) {
nonZeroDay.tap()
} else {
let anyDay = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "percent complete")).firstMatch
if anyDay.waitForExistence(timeout: 8) {
anyDay.tap()
}
}
_ = app.buttons["Done"].waitForExistence(timeout: 8)
}
private func tapFirstAvailableElementIfPresent(
app: XCUIApplication,
identifiers: [String],
fallbackLabels: [String],
timeout: TimeInterval = 8
) -> Bool {
func firstExistingElement(for label: String, timeout: TimeInterval) -> XCUIElement? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let candidates = [
app.buttons[label],
app.otherElements[label],
app.staticTexts[label],
app.descendants(matching: .any)[label]
]
if let element = candidates.first(where: { $0.exists }) {
return element
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return nil
}
for identifier in identifiers {
if let element = firstExistingElement(for: identifier, timeout: 1) {
if element.isHittable {
element.tap()
return true
}
let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
return true
}
}
for label in fallbackLabels {
if let element = firstExistingElement(for: label, timeout: timeout) {
if element.isHittable {
element.tap()
return true
}
let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
return true
}
}
return false
}
}