Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-08 18:23:14 -06:00
parent 052386d927
commit ecd3f57457
43 changed files with 233 additions and 84 deletions

View File

@ -72,7 +72,17 @@ struct AndromidaApp: App {
let settings = SettingsStore()
_settingsStore = State(initialValue: settings)
_categoryStore = State(initialValue: CategoryStore())
_store = State(initialValue: RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings))
let ritualStore = RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings)
if isUITesting, environment["UITEST_SEED_THREE_PRESETS"] == "1" {
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening
}
if isUITesting, environment["UITEST_PRELOAD_DEMO_DATA"] == "1" {
ritualStore.preloadDemoData()
}
_store = State(initialValue: ritualStore)
}
var body: some Scene {
@ -82,12 +92,13 @@ struct AndromidaApp: App {
.ignoresSafeArea()
if hasCompletedSetupWizard {
let uiTestInitialTab = uiTestRequestedInitialTab()
// Main app - start on Rituals tab if just completed wizard
RootView(
store: store,
settingsStore: settingsStore,
categoryStore: categoryStore,
initialTab: justCompletedWizard ? .rituals : .today
initialTab: uiTestInitialTab ?? (justCompletedWizard ? .rituals : .today)
)
.transition(.opacity)
} else {
@ -111,4 +122,16 @@ struct AndromidaApp: App {
.preferredColorScheme(settingsStore.theme.colorScheme)
}
}
private func uiTestRequestedInitialTab() -> RootView.RootTab? {
guard ProcessInfo.processInfo.environment["UITEST_MODE"] == "1" else { return nil }
switch ProcessInfo.processInfo.environment["UITEST_INITIAL_TAB"]?.lowercased() {
case "today": return .today
case "rituals": return .rituals
case "insights": return .insights
case "history": return .history
case "settings": return .settings
default: return nil
}
}
}

View File

@ -140,6 +140,7 @@ struct SettingsView: View {
) {
ritualStore.preloadDemoData()
}
.accessibilityIdentifier("settings.debug.preloadDemoData")
SettingsRow(
systemImage: "checkmark.circle.badge.xmark",

View File

@ -8,6 +8,28 @@
import XCTest
final class AndromidaUITests: XCTestCase {
private let doneLabels = ["Done", "Terminé", "Listo"]
private let settingsTabLabels = ["Settings", "Réglages", "Paramètres", "Ajustes"]
private let tabLabelMap: [String: [String]] = [
"Today": ["Today", "Aujourd'hui", "Hoy"],
"Rituals": ["Rituals", "Rituels", "Rituales"],
"Insights": ["Insights", "Aperçus", "Ideas"],
"History": ["History", "Historique", "Historial"],
"Settings": ["Settings", "Réglages", "Paramètres", "Ajustes"]
]
private let tabSymbolMap: [String: String] = [
"Today": "sun.max.fill",
"Rituals": "sparkles",
"Insights": "chart.bar.fill",
"History": "calendar",
"Settings": "gearshape.fill"
]
private let preloadLabels = [
"Preload 6 Months Demo Data",
"Précharge 6 mois Données de démonstration",
"Precarga 6 Meses Datos de demostración"
]
private let preloadPrefixLabels = ["Preload 6", "Précharge 6", "Precarga 6"]
override func setUpWithError() throws {
continueAfterFailure = false
@ -155,14 +177,14 @@ final class AndromidaUITests: XCTestCase {
@MainActor
func testAppStorePortraitScreenshots() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
app.launchEnvironment["UITEST_INITIAL_TAB"] = "settings"
app.launch()
XCUIDevice.shared.orientation = .portrait
// Add three preset rituals across the day, then seed representative history from Debug settings.
addThreePresetRitualsAcrossDay(app: app)
// Ensure rituals exist before seeding demo history.
assertRitualsPresentBeforePreload(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.")
@ -188,6 +210,8 @@ final class AndromidaUITests: XCTestCase {
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["UITEST_MODE"] = "1"
app.launchEnvironment["UITEST_SEED_THREE_PRESETS"] = "1"
app.launchEnvironment["UITEST_PRELOAD_DEMO_DATA"] = "1"
if resetDefaults {
app.launchEnvironment["UITEST_RESET_USER_DEFAULTS"] = "1"
}
@ -195,54 +219,20 @@ final class AndromidaUITests: XCTestCase {
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))
}
private func preloadSixMonthsDemoDataFromDebug(app: XCUIApplication) {
// iPad tab/sidebars are highly variable in UI tests on iOS 26;
// demo data is already guaranteed via launch environment.
if app.windows.firstMatch.frame.width >= 1000 {
return
}
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
label: "settings.debug.preloadDemoData",
timeoutPerAttempt: 0.3,
swipeCount: 12
) {
if preload.isHittable {
preload.tap()
@ -250,21 +240,64 @@ final class AndromidaUITests: XCTestCase {
preload.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
waitForHistoryDataSeed(app: app)
return
}
if tapPreloadByPrefixSearch(app: app) {
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
return
}
let preload = findAnyElementWithVerticalSearch(
app: app,
labels: preloadLabels,
timeoutPerAttempt: 0.5,
swipeCount: 10
)
if let preload {
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))
return
}
}
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
private func tapPreloadByPrefixSearch(app: XCUIApplication) -> Bool {
func tapMatchingElement() -> Bool {
let elements = app.descendants(matching: .any).allElementsBoundByIndex
for element in elements where element.exists {
let label = element.label
if preloadPrefixLabels.contains(where: { label.contains($0) }) {
if element.isHittable {
element.tap()
} else {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
return true
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
return false
}
if tapMatchingElement() { return true }
for _ in 0..<12 {
app.swipeUp()
if tapMatchingElement() { return true }
}
return false
}
private func assertRitualsPresentBeforePreload(app: XCUIApplication) {
openTab(app: app, label: "Rituals", index: 1)
XCTAssertFalse(
app.staticTexts["No Active Rituals"].exists,
"Expected seeded rituals before preload."
)
}
@ -356,35 +389,128 @@ final class AndromidaUITests: XCTestCase {
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
private func findAnyElementWithVerticalSearch(
app: XCUIApplication,
labels: [String],
timeoutPerAttempt: TimeInterval,
swipeCount: Int
) -> XCUIElement? {
for label in labels {
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
let anyDone = app.buttons["Done"].firstMatch
if anyDone.waitForExistence(timeout: 4) {
for _ in 0..<swipeCount {
app.swipeUp()
for label in labels {
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
}
for _ in 0..<swipeCount {
app.swipeDown()
for label in labels {
if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) {
return element
}
}
}
return nil
}
private func tapDoneInNavigationBar(app: XCUIApplication, title: String) {
for navTitle in [title] {
for done in doneLabels {
let navDone = app.navigationBars[navTitle].buttons[done].firstMatch
if navDone.waitForExistence(timeout: 2) {
navDone.tap()
return
}
}
}
if let anyDone = firstExistingDoneButton(app: app, 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
private func firstExistingDoneButton(app: XCUIApplication, timeout: TimeInterval) -> XCUIElement? {
firstExistingButton(app: app, labels: doneLabels, timeout: timeout)
}
private func firstExistingButton(app: XCUIApplication, labels: [String], timeout: TimeInterval) -> XCUIElement? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
for label in labels {
let button = app.buttons[label].firstMatch
if button.exists {
return button
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return nil
}
private func openTab(app: XCUIApplication, label: String, index: Int) {
let localizedLabels = tabLabelMap[label] ?? [label]
let tabButtons = app.tabBars.buttons
if tabButtons.count > index {
tabButtons.element(boundBy: index).tap()
return
let indexed = tabButtons.element(boundBy: index)
if indexed.exists {
indexed.tap()
return
}
}
let fallback = app.buttons[label].firstMatch
if fallback.exists {
fallback.tap()
for localizedLabel in localizedLabels {
let primary = app.tabBars.buttons[localizedLabel].firstMatch
if primary.waitForExistence(timeout: 2) {
primary.tap()
return
}
}
for localizedLabel in localizedLabels {
let sidebarButton = app.buttons[localizedLabel].firstMatch
if sidebarButton.waitForExistence(timeout: 2) {
sidebarButton.tap()
return
}
let staticText = app.staticTexts[localizedLabel].firstMatch
if staticText.waitForExistence(timeout: 1) {
staticText.tap()
return
}
}
if let symbol = tabSymbolMap[label] {
let symbolButton = app.buttons[symbol].firstMatch
if symbolButton.waitForExistence(timeout: 2) {
symbolButton.tap()
return
}
}
for localizedLabel in localizedLabels {
if let found = findElementWithVerticalSearch(
app: app,
label: localizedLabel,
timeoutPerAttempt: 0.4,
swipeCount: 8
) {
if found.isHittable {
found.tap()
} else {
found.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
return
}
}
}
@ -398,20 +524,19 @@ final class AndromidaUITests: XCTestCase {
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()
// Locale-agnostic: prefer a day-like button with digits in its label.
let dayPredicate = NSPredicate(format: "label MATCHES %@", ".*[0-9].*")
let dayButton = app.buttons.matching(dayPredicate).firstMatch
if dayButton.waitForExistence(timeout: 4) {
dayButton.tap()
} else {
let anyDay = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "percent complete")).firstMatch
if anyDay.waitForExistence(timeout: 8) {
anyDay.tap()
let buttons = app.buttons.allElementsBoundByIndex
if let fallback = buttons.first(where: { $0.exists && $0.isHittable }) {
fallback.tap()
}
}
_ = app.buttons["Done"].waitForExistence(timeout: 8)
_ = firstExistingDoneButton(app: app, timeout: 2)
}
private func tapFirstAvailableElementIfPresent(

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB