From efc1ee5fa760f0c96bc69d514b95e672eafc3be5 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 8 Feb 2026 12:19:33 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida.xcodeproj/project.pbxproj | 6 +- Andromida/AndromidaApp.swift | 44 +++-- Andromida/App/Models/Ritual.swift | 2 +- Andromida/App/Models/RitualArc.swift | 2 +- Andromida/App/State/RitualStore.swift | 4 +- .../Onboarding/FirstCheckInStepView.swift | 3 + .../Onboarding/GoalSelectionStepView.swift | 4 + .../Onboarding/NotificationStepView.swift | 2 + .../Onboarding/RitualPreviewStepView.swift | 2 + .../Onboarding/TimeSelectionStepView.swift | 4 + .../Views/Onboarding/WelcomeStepView.swift | 1 + .../Views/Onboarding/WhatsNextStepView.swift | 1 + Andromida/App/Views/Rituals/RitualsView.swift | 4 + .../Rituals/Sheets/RitualEditSheet.swift | 6 + AndromidaTests/AndromidaTests.swift | 49 ++++++ AndromidaTests/RitualStoreTests.swift | 51 ++---- AndromidaUITests/AndromidaUITests.swift | 164 ++++++++++++++++-- PRD.md | 5 +- README.md | 4 +- 19 files changed, 287 insertions(+), 71 deletions(-) diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj index 00a2610..6cce1e9 100644 --- a/Andromida.xcodeproj/project.pbxproj +++ b/Andromida.xcodeproj/project.pbxproj @@ -53,7 +53,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - EAC04A982F26BAE8007F87EA /* Andromida.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Andromida.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EAC04A982F26BAE8007F87EA /* Rituals.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rituals.app; sourceTree = BUILT_PRODUCTS_DIR; }; EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AndromidaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -166,7 +166,7 @@ EAC04A992F26BAE8007F87EA /* Products */ = { isa = PBXGroup; children = ( - EAC04A982F26BAE8007F87EA /* Andromida.app */, + EAC04A982F26BAE8007F87EA /* Rituals.app */, EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */, EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */, EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */, @@ -208,7 +208,7 @@ EAC04AED2F26BD5B007F87EA /* Bedrock */, ); productName = Andromida; - productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */; + productReference = EAC04A982F26BAE8007F87EA /* Rituals.app */; productType = "com.apple.product-type.application"; }; EAC04AA42F26BAE9007F87EA /* AndromidaTests */ = { diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index fcd3bf0..539a9b5 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -24,19 +24,43 @@ struct AndromidaApp: App { ) Theme.register(border: AppBorder.self) + let environment = ProcessInfo.processInfo.environment + let isRunningTests = environment["XCTestConfigurationFilePath"] != nil + let isUITesting = environment["UITEST_MODE"] == "1" + + if isUITesting { + if environment["UITEST_RESET_USER_DEFAULTS"] == "1", + let bundleIdentifier = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) + } + if let completedValue = environment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] { + let hasCompleted = completedValue == "1" || completedValue.lowercased() == "true" + UserDefaults.standard.set(hasCompleted, forKey: "hasCompletedSetupWizard") + } + } + // Include all models in schema - Ritual, RitualArc, and ArcHabit let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) - // Use App Group for shared container between app and widget. - // Disable CloudKit mirroring under XCTest to keep simulator tests deterministic. - let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? - .appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite") - let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - let configuration = ModelConfiguration( - schema: schema, - url: storeURL, - cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier) - ) + let configuration: ModelConfiguration + if isUITesting { + // UI tests should always run with isolated in-memory persistence. + configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + } else { + // Use App Group for shared container between app and widget. + // Disable CloudKit mirroring under XCTest to keep simulator tests deterministic. + let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? + .appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite") + configuration = ModelConfiguration( + schema: schema, + url: storeURL, + cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier) + ) + } let container: ModelContainer do { diff --git a/Andromida/App/Models/Ritual.swift b/Andromida/App/Models/Ritual.swift index 905461e..244b808 100644 --- a/Andromida/App/Models/Ritual.swift +++ b/Andromida/App/Models/Ritual.swift @@ -106,7 +106,7 @@ final class Ritual { // Arcs - each arc represents a time-bound period with its own habits @Relationship(deleteRule: .cascade) - var arcs: [RitualArc]? = [] + var arcs: [RitualArc]? init( id: UUID = UUID(), diff --git a/Andromida/App/Models/RitualArc.swift b/Andromida/App/Models/RitualArc.swift index c453627..ef61899 100644 --- a/Andromida/App/Models/RitualArc.swift +++ b/Andromida/App/Models/RitualArc.swift @@ -13,7 +13,7 @@ final class RitualArc { var isActive: Bool = true @Relationship(deleteRule: .cascade) - var habits: [ArcHabit]? = [] + var habits: [ArcHabit]? @Relationship(inverse: \Ritual.arcs) var ritual: Ritual? diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 049df9d..e7ac887 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -78,7 +78,9 @@ final class RitualStore: RitualStoreProviding { self.currentTimeOfDay = TimeOfDay.current(for: now()) runDataIntegrityMigrationIfNeeded() loadRitualsIfNeeded() - observeRemoteChanges() + if !isRunningTests { + observeRemoteChanges() + } } private func now() -> Date { diff --git a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift index 2dc95dc..06bc4e3 100644 --- a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift +++ b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift @@ -108,10 +108,12 @@ struct FirstCheckInStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.firstCheckInContinue") Button(action: onComplete) { Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary) } + .accessibilityIdentifier("onboarding.firstCheckInSkip") } .padding(.horizontal, Design.Spacing.xxLarge) @@ -155,6 +157,7 @@ struct FirstCheckInStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.firstCheckInContinueToRituals") .padding(.horizontal, Design.Spacing.xxLarge) .transition(.move(edge: .bottom).combined(with: .opacity)) } diff --git a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift index 737fc46..a6bca56 100644 --- a/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift +++ b/Andromida/App/Views/Onboarding/GoalSelectionStepView.swift @@ -62,6 +62,7 @@ struct GoalSelectionStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.goalContinue") .padding(.horizontal, Design.Spacing.xxLarge) .padding(.bottom, Design.Spacing.xxLarge) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -126,8 +127,11 @@ private struct GoalCardView: View { ) } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isButton) .accessibilityLabel(goal.displayName) .accessibilityHint(goal.subtitle) + .accessibilityIdentifier("onboarding.goal.\(goal.rawValue)") } } diff --git a/Andromida/App/Views/Onboarding/NotificationStepView.swift b/Andromida/App/Views/Onboarding/NotificationStepView.swift index f6b13a8..07863cf 100644 --- a/Andromida/App/Views/Onboarding/NotificationStepView.swift +++ b/Andromida/App/Views/Onboarding/NotificationStepView.swift @@ -85,6 +85,7 @@ struct NotificationStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.enableNotifications") .disabled(isRequestingPermission) // Secondary skip option @@ -93,6 +94,7 @@ struct NotificationStepView: View { .typography(.body) .foregroundStyle(AppTextColors.secondary) } + .accessibilityIdentifier("onboarding.notificationsMaybeLater") .disabled(isRequestingPermission) } .padding(.horizontal, Design.Spacing.xxLarge) diff --git a/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift b/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift index 31f755c..d3fff56 100644 --- a/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift +++ b/Andromida/App/Views/Onboarding/RitualPreviewStepView.swift @@ -90,11 +90,13 @@ struct RitualPreviewStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.startRitual") // Skip option Button(action: onSkip) { Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary) } + .accessibilityIdentifier("onboarding.skipRitual") } .padding(.horizontal, Design.Spacing.xxLarge) .opacity(animateContent ? 1 : 0) diff --git a/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift b/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift index 0b40846..7082de6 100644 --- a/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift +++ b/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift @@ -62,6 +62,7 @@ struct TimeSelectionStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.timeContinue") .padding(.horizontal, Design.Spacing.xxLarge) .padding(.bottom, Design.Spacing.xxLarge) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -131,8 +132,11 @@ private struct TimeCardView: View { ) } .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isButton) .accessibilityLabel(time.displayName) .accessibilityHint(time.subtitle) + .accessibilityIdentifier("onboarding.time.\(time.rawValue)") } } diff --git a/Andromida/App/Views/Onboarding/WelcomeStepView.swift b/Andromida/App/Views/Onboarding/WelcomeStepView.swift index cc832aa..ae9d230 100644 --- a/Andromida/App/Views/Onboarding/WelcomeStepView.swift +++ b/Andromida/App/Views/Onboarding/WelcomeStepView.swift @@ -46,6 +46,7 @@ struct WelcomeStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.getStarted") .padding(.horizontal, Design.Spacing.xxLarge) .opacity(animateButton ? 1 : 0) .offset(y: animateButton ? 0 : 20) diff --git a/Andromida/App/Views/Onboarding/WhatsNextStepView.swift b/Andromida/App/Views/Onboarding/WhatsNextStepView.swift index eb8260a..af0b5d0 100644 --- a/Andromida/App/Views/Onboarding/WhatsNextStepView.swift +++ b/Andromida/App/Views/Onboarding/WhatsNextStepView.swift @@ -74,6 +74,7 @@ struct WhatsNextStepView: View { .background(AppAccent.primary) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } + .accessibilityIdentifier("onboarding.letsGo") .padding(.horizontal, Design.Spacing.xxLarge) .opacity(animateContent ? 1 : 0) .animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent) diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index 4894dd7..c29e9d7 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -72,16 +72,20 @@ struct RitualsView: View { } label: { Label(String(localized: "Create New"), systemImage: "plus.circle") } + .accessibilityIdentifier("rituals.createNew") Button { showingPresetLibrary = true } label: { Label(String(localized: "Browse Presets"), systemImage: "sparkles.rectangle.stack") } + .accessibilityIdentifier("rituals.browsePresets") } label: { Image(systemName: "plus") .foregroundStyle(AppAccent.primary) + .accessibilityIdentifier("rituals.addMenu") } + .accessibilityIdentifier("rituals.addMenu") } } .sheet(isPresented: $showingPresetLibrary) { diff --git a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift index cc3b53b..6db9c03 100644 --- a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift @@ -62,6 +62,7 @@ struct RitualEditSheet: View { Button(String(localized: "Cancel")) { dismiss() } + .accessibilityIdentifier("ritualEditor.cancelButton") .foregroundStyle(AppTextColors.secondary) } @@ -70,6 +71,7 @@ struct RitualEditSheet: View { saveRitual() dismiss() } + .accessibilityIdentifier("ritualEditor.saveButton") .foregroundStyle(AppAccent.primary) .disabled(!canSave) } @@ -112,10 +114,12 @@ struct RitualEditSheet: View { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { TextField(String(localized: "Ritual name"), text: $title) .typography(.heading) + .accessibilityIdentifier("ritualEditor.titleField") TextField(String(localized: "Theme or tagline"), text: $theme) .typography(.subheading) .foregroundStyle(AppTextColors.secondary) + .accessibilityIdentifier("ritualEditor.themeField") } } .listRowBackground(AppSurface.card) @@ -296,6 +300,7 @@ struct RitualEditSheet: View { .buttonStyle(.plain) TextField(String(localized: "Add a habit..."), text: $newHabitTitle) + .accessibilityIdentifier("ritualEditor.newHabitField") .onSubmit { addNewHabit() } @@ -306,6 +311,7 @@ struct RitualEditSheet: View { Image(systemName: "plus.circle.fill") .foregroundStyle(AppAccent.primary) } + .accessibilityIdentifier("ritualEditor.addHabitButton") .buttonStyle(.plain) .disabled(newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } diff --git a/AndromidaTests/AndromidaTests.swift b/AndromidaTests/AndromidaTests.swift index 382e203..870f5b7 100644 --- a/AndromidaTests/AndromidaTests.swift +++ b/AndromidaTests/AndromidaTests.swift @@ -6,6 +6,8 @@ // import Testing +import SwiftData +import Foundation @testable import Rituals struct AndromidaTests { @@ -14,4 +16,51 @@ struct AndromidaTests { // Write your test here and use APIs like `#expect(...)` to check expected conditions. } + @MainActor + @Test func modelContextCanInsertBareRitual() throws { + let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) + let config = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + let container = try ModelContainer(for: schema, configurations: [config]) + let context = container.mainContext + + let ritual = Ritual(title: "Bare", theme: "Test", arcs: []) + context.insert(ritual) + try context.save() + + let rituals = try context.fetch(FetchDescriptor()) + #expect(rituals.count == 1) + } + + @MainActor + @Test func storeCanCreateQuickRitual() throws { + let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) + let config = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + let container = try ModelContainer(for: schema, configurations: [config]) + let store = RitualStore( + modelContext: container.mainContext, + seedService: EmptySeedService(), + settingsStore: TestFeedbackSettings() + ) + + store.createQuickRitual() + #expect(store.rituals.count == 1) + } + +} + +private struct EmptySeedService: RitualSeedProviding { + func makeSeedRituals(startDate: Date) -> [Ritual] { [] } +} + +private struct TestFeedbackSettings: RitualFeedbackSettingsProviding { + var hapticsEnabled: Bool = false + var soundEnabled: Bool = false } diff --git a/AndromidaTests/RitualStoreTests.swift b/AndromidaTests/RitualStoreTests.swift index acbbcd1..ca5f64c 100644 --- a/AndromidaTests/RitualStoreTests.swift +++ b/AndromidaTests/RitualStoreTests.swift @@ -3,6 +3,7 @@ import SwiftData import Testing @testable import Rituals +@Suite(.serialized) struct RitualStoreTests { @MainActor @Test func quickRitualStartsIncomplete() throws { @@ -411,8 +412,18 @@ struct RitualStoreTests { @MainActor private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore { - let container = SharedTestContainer.container - clearSharedTestContainer(container.mainContext) + let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + let container: ModelContainer + do { + container = try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Test container failed: \(error)") + } return RitualStore( modelContext: container.mainContext, @@ -422,42 +433,6 @@ private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore { ) } -@MainActor -private enum SharedTestContainer { - static let container: ModelContainer = { - let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) - let configuration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: true, - cloudKitDatabase: .none - ) - - do { - return try ModelContainer(for: schema, configurations: [configuration]) - } catch { - fatalError("Test container failed: \(error)") - } - }() -} - -@MainActor -private func clearSharedTestContainer(_ context: ModelContext) { - do { - for habit in try context.fetch(FetchDescriptor()) { - context.delete(habit) - } - for arc in try context.fetch(FetchDescriptor()) { - context.delete(arc) - } - for ritual in try context.fetch(FetchDescriptor()) { - context.delete(ritual) - } - try context.save() - } catch { - fatalError("Failed to reset shared test container: \(error)") - } -} - private struct EmptySeedService: RitualSeedProviding { func makeSeedRituals(startDate: Date) -> [Ritual] { [] diff --git a/AndromidaUITests/AndromidaUITests.swift b/AndromidaUITests/AndromidaUITests.swift index fd354c0..ed111ad 100644 --- a/AndromidaUITests/AndromidaUITests.swift +++ b/AndromidaUITests/AndromidaUITests.swift @@ -10,32 +10,166 @@ import XCTest final class AndromidaUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() + 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() - // Use XCTAssert and related functions to verify your tests produce the correct results. + XCTAssertTrue(app.buttons["onboarding.getStarted"].waitForExistence(timeout: 8)) + app.buttons["onboarding.getStarted"].tap() + + tapFirstAvailableElement( + app: app, + identifiers: ["onboarding.goal.health"], + fallbackLabels: ["Health"] + ) + XCTAssertTrue(app.buttons["onboarding.goalContinue"].waitForExistence(timeout: 8)) + app.buttons["onboarding.goalContinue"].tap() + + tapFirstAvailableElement( + app: app, + identifiers: ["onboarding.time.morning"], + fallbackLabels: ["Morning"] + ) + XCTAssertTrue(app.buttons["onboarding.timeContinue"].waitForExistence(timeout: 8)) + app.buttons["onboarding.timeContinue"].tap() + + 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 { - // This measures how long it takes to launch your application. + let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true) measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() + app.launch() + app.terminate() } } + + 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 tapFirstAvailableElement( + app: XCUIApplication, + identifiers: [String], + fallbackLabels: [String], + timeout: TimeInterval = 8 + ) { + for identifier in identifiers { + let candidates = [ + app.buttons[identifier], + app.otherElements[identifier], + app.staticTexts[identifier], + app.descendants(matching: .any)[identifier] + ] + for element in candidates where element.waitForExistence(timeout: 1) { + element.tap() + return + } + } + + for label in fallbackLabels { + let candidates = [ + app.buttons[label], + app.otherElements[label], + app.staticTexts[label] + ] + for element in candidates where element.waitForExistence(timeout: timeout) { + element.tap() + return + } + } + + XCTFail("Unable to locate tap target. identifiers=\(identifiers), labels=\(fallbackLabels)") + } } diff --git a/PRD.md b/PRD.md index 12f1ebe..12a754e 100644 --- a/PRD.md +++ b/PRD.md @@ -592,8 +592,10 @@ Andromida/ | Unit tests in `AndromidaTests/` covering store logic and analytics | | UI tests in `AndromidaUITests/` for critical user flows | | Unit-test harness should use deterministic in-memory SwiftData setup to prevent host-app test instability | +| UI-test harness should run app launches with deterministic flags (`UITEST_MODE`, optional reset/onboarding overrides) and in-memory SwiftData | | UI launch coverage should prioritize stable smoke validation over exhaustive simulator configuration matrices | -| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` | +| UI coverage should include onboarding state transition validation and ritual creation flow from the Rituals tab | +| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2'` | --- @@ -603,3 +605,4 @@ Andromida/ |---------|------|-------------| | 1.0 | February 2026 | Initial PRD based on implemented features | | 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation | +| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage | diff --git a/README.md b/README.md index 916f88c..a962662 100644 --- a/README.md +++ b/README.md @@ -182,10 +182,12 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA): - Unit tests in `AndromidaTests/` - Run via Xcode Test navigator or: - - `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` + - `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2'` - XCTest runs disable SwiftData CloudKit mirroring in the host app to keep simulator tests deterministic. - `RitualStoreTests` use a shared in-memory SwiftData container with per-test cleanup to avoid host-process container churn. - `AndromidaUITestsLaunchTests` runs a single launch configuration to reduce flaky simulator timeouts. +- `AndromidaUITests` launch with `UITEST_MODE=1`, forcing in-memory SwiftData and optional launch-state overrides (`UITEST_RESET_USER_DEFAULTS`, `UITEST_HAS_COMPLETED_SETUP_WIZARD`) for deterministic UI runs. +- UI coverage includes stable onboarding-state transition validation and ritual creation from the Rituals tab. ## Notes