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

This commit is contained in:
Matt Bruce 2026-02-08 12:19:33 -06:00
parent 469f960fec
commit efc1ee5fa7
19 changed files with 287 additions and 71 deletions

View File

@ -53,7 +53,7 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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; }; 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; }; 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; }; EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AndromidaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@ -166,7 +166,7 @@
EAC04A992F26BAE8007F87EA /* Products */ = { EAC04A992F26BAE8007F87EA /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EAC04A982F26BAE8007F87EA /* Andromida.app */, EAC04A982F26BAE8007F87EA /* Rituals.app */,
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */, EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */,
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */, EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */,
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */, EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */,
@ -208,7 +208,7 @@
EAC04AED2F26BD5B007F87EA /* Bedrock */, EAC04AED2F26BD5B007F87EA /* Bedrock */,
); );
productName = Andromida; productName = Andromida;
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */; productReference = EAC04A982F26BAE8007F87EA /* Rituals.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
EAC04AA42F26BAE9007F87EA /* AndromidaTests */ = { EAC04AA42F26BAE9007F87EA /* AndromidaTests */ = {

View File

@ -24,19 +24,43 @@ struct AndromidaApp: App {
) )
Theme.register(border: AppBorder.self) 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 // Include all models in schema - Ritual, RitualArc, and ArcHabit
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
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. // Use App Group for shared container between app and widget.
// Disable CloudKit mirroring under XCTest to keep simulator tests deterministic. // Disable CloudKit mirroring under XCTest to keep simulator tests deterministic.
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite") .appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil configuration = ModelConfiguration(
let configuration = ModelConfiguration(
schema: schema, schema: schema,
url: storeURL, url: storeURL,
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier) cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
) )
}
let container: ModelContainer let container: ModelContainer
do { do {

View File

@ -106,7 +106,7 @@ final class Ritual {
// Arcs - each arc represents a time-bound period with its own habits // Arcs - each arc represents a time-bound period with its own habits
@Relationship(deleteRule: .cascade) @Relationship(deleteRule: .cascade)
var arcs: [RitualArc]? = [] var arcs: [RitualArc]?
init( init(
id: UUID = UUID(), id: UUID = UUID(),

View File

@ -13,7 +13,7 @@ final class RitualArc {
var isActive: Bool = true var isActive: Bool = true
@Relationship(deleteRule: .cascade) @Relationship(deleteRule: .cascade)
var habits: [ArcHabit]? = [] var habits: [ArcHabit]?
@Relationship(inverse: \Ritual.arcs) @Relationship(inverse: \Ritual.arcs)
var ritual: Ritual? var ritual: Ritual?

View File

@ -78,8 +78,10 @@ final class RitualStore: RitualStoreProviding {
self.currentTimeOfDay = TimeOfDay.current(for: now()) self.currentTimeOfDay = TimeOfDay.current(for: now())
runDataIntegrityMigrationIfNeeded() runDataIntegrityMigrationIfNeeded()
loadRitualsIfNeeded() loadRitualsIfNeeded()
if !isRunningTests {
observeRemoteChanges() observeRemoteChanges()
} }
}
private func now() -> Date { private func now() -> Date {
nowProvider() nowProvider()

View File

@ -108,10 +108,12 @@ struct FirstCheckInStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.firstCheckInContinue")
Button(action: onComplete) { Button(action: onComplete) {
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary) Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
} }
.accessibilityIdentifier("onboarding.firstCheckInSkip")
} }
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
@ -155,6 +157,7 @@ struct FirstCheckInStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
} }

View File

@ -62,6 +62,7 @@ struct GoalSelectionStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.goalContinue")
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.padding(.bottom, Design.Spacing.xxLarge) .padding(.bottom, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
@ -126,8 +127,11 @@ private struct GoalCardView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(goal.displayName) .accessibilityLabel(goal.displayName)
.accessibilityHint(goal.subtitle) .accessibilityHint(goal.subtitle)
.accessibilityIdentifier("onboarding.goal.\(goal.rawValue)")
} }
} }

View File

@ -85,6 +85,7 @@ struct NotificationStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.enableNotifications")
.disabled(isRequestingPermission) .disabled(isRequestingPermission)
// Secondary skip option // Secondary skip option
@ -93,6 +94,7 @@ struct NotificationStepView: View {
.typography(.body) .typography(.body)
.foregroundStyle(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
} }
.accessibilityIdentifier("onboarding.notificationsMaybeLater")
.disabled(isRequestingPermission) .disabled(isRequestingPermission)
} }
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)

View File

@ -90,11 +90,13 @@ struct RitualPreviewStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.startRitual")
// Skip option // Skip option
Button(action: onSkip) { Button(action: onSkip) {
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary) Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
} }
.accessibilityIdentifier("onboarding.skipRitual")
} }
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateContent ? 1 : 0) .opacity(animateContent ? 1 : 0)

View File

@ -62,6 +62,7 @@ struct TimeSelectionStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.timeContinue")
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.padding(.bottom, Design.Spacing.xxLarge) .padding(.bottom, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
@ -131,8 +132,11 @@ private struct TimeCardView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(time.displayName) .accessibilityLabel(time.displayName)
.accessibilityHint(time.subtitle) .accessibilityHint(time.subtitle)
.accessibilityIdentifier("onboarding.time.\(time.rawValue)")
} }
} }

View File

@ -46,6 +46,7 @@ struct WelcomeStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.getStarted")
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateButton ? 1 : 0) .opacity(animateButton ? 1 : 0)
.offset(y: animateButton ? 0 : 20) .offset(y: animateButton ? 0 : 20)

View File

@ -74,6 +74,7 @@ struct WhatsNextStepView: View {
.background(AppAccent.primary) .background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.accessibilityIdentifier("onboarding.letsGo")
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateContent ? 1 : 0) .opacity(animateContent ? 1 : 0)
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent) .animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent)

View File

@ -72,16 +72,20 @@ struct RitualsView: View {
} label: { } label: {
Label(String(localized: "Create New"), systemImage: "plus.circle") Label(String(localized: "Create New"), systemImage: "plus.circle")
} }
.accessibilityIdentifier("rituals.createNew")
Button { Button {
showingPresetLibrary = true showingPresetLibrary = true
} label: { } label: {
Label(String(localized: "Browse Presets"), systemImage: "sparkles.rectangle.stack") Label(String(localized: "Browse Presets"), systemImage: "sparkles.rectangle.stack")
} }
.accessibilityIdentifier("rituals.browsePresets")
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("rituals.addMenu")
} }
.accessibilityIdentifier("rituals.addMenu")
} }
} }
.sheet(isPresented: $showingPresetLibrary) { .sheet(isPresented: $showingPresetLibrary) {

View File

@ -62,6 +62,7 @@ struct RitualEditSheet: View {
Button(String(localized: "Cancel")) { Button(String(localized: "Cancel")) {
dismiss() dismiss()
} }
.accessibilityIdentifier("ritualEditor.cancelButton")
.foregroundStyle(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
} }
@ -70,6 +71,7 @@ struct RitualEditSheet: View {
saveRitual() saveRitual()
dismiss() dismiss()
} }
.accessibilityIdentifier("ritualEditor.saveButton")
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.disabled(!canSave) .disabled(!canSave)
} }
@ -112,10 +114,12 @@ struct RitualEditSheet: View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
TextField(String(localized: "Ritual name"), text: $title) TextField(String(localized: "Ritual name"), text: $title)
.typography(.heading) .typography(.heading)
.accessibilityIdentifier("ritualEditor.titleField")
TextField(String(localized: "Theme or tagline"), text: $theme) TextField(String(localized: "Theme or tagline"), text: $theme)
.typography(.subheading) .typography(.subheading)
.foregroundStyle(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
.accessibilityIdentifier("ritualEditor.themeField")
} }
} }
.listRowBackground(AppSurface.card) .listRowBackground(AppSurface.card)
@ -296,6 +300,7 @@ struct RitualEditSheet: View {
.buttonStyle(.plain) .buttonStyle(.plain)
TextField(String(localized: "Add a habit..."), text: $newHabitTitle) TextField(String(localized: "Add a habit..."), text: $newHabitTitle)
.accessibilityIdentifier("ritualEditor.newHabitField")
.onSubmit { .onSubmit {
addNewHabit() addNewHabit()
} }
@ -306,6 +311,7 @@ struct RitualEditSheet: View {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
} }
.accessibilityIdentifier("ritualEditor.addHabitButton")
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }

View File

@ -6,6 +6,8 @@
// //
import Testing import Testing
import SwiftData
import Foundation
@testable import Rituals @testable import Rituals
struct AndromidaTests { struct AndromidaTests {
@ -14,4 +16,51 @@ struct AndromidaTests {
// Write your test here and use APIs like `#expect(...)` to check expected conditions. // 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<Ritual>())
#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
} }

View File

@ -3,6 +3,7 @@ import SwiftData
import Testing import Testing
@testable import Rituals @testable import Rituals
@Suite(.serialized)
struct RitualStoreTests { struct RitualStoreTests {
@MainActor @MainActor
@Test func quickRitualStartsIncomplete() throws { @Test func quickRitualStartsIncomplete() throws {
@ -411,8 +412,18 @@ struct RitualStoreTests {
@MainActor @MainActor
private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore { private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
let container = SharedTestContainer.container let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
clearSharedTestContainer(container.mainContext) 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( return RitualStore(
modelContext: container.mainContext, 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<ArcHabit>()) {
context.delete(habit)
}
for arc in try context.fetch(FetchDescriptor<RitualArc>()) {
context.delete(arc)
}
for ritual in try context.fetch(FetchDescriptor<Ritual>()) {
context.delete(ritual)
}
try context.save()
} catch {
fatalError("Failed to reset shared test container: \(error)")
}
}
private struct EmptySeedService: RitualSeedProviding { private struct EmptySeedService: RitualSeedProviding {
func makeSeedRituals(startDate: Date) -> [Ritual] { func makeSeedRituals(startDate: Date) -> [Ritual] {
[] []

View File

@ -10,32 +10,166 @@ import XCTest
final class AndromidaUITests: XCTestCase { final class AndromidaUITests: XCTestCase {
override func setUpWithError() throws { 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 continueAfterFailure = false
// In UI tests its 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 @MainActor
func testExample() throws { func testOnboardingFlowCompletes() throws {
// UI tests must launch the application that they test. let onboardingApp = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
let app = XCUIApplication() 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() 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 @MainActor
func testLaunchPerformance() throws { func testLaunchPerformance() throws {
// This measures how long it takes to launch your application. let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
measure(metrics: [XCTApplicationLaunchMetric()]) { 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)")
}
} }

5
PRD.md
View File

@ -592,8 +592,10 @@ Andromida/
| Unit tests in `AndromidaTests/` covering store logic and analytics | | Unit tests in `AndromidaTests/` covering store logic and analytics |
| UI tests in `AndromidaUITests/` for critical user flows | | UI tests in `AndromidaUITests/` for critical user flows |
| Unit-test harness should use deterministic in-memory SwiftData setup to prevent host-app test instability | | 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 | | 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.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.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 |

View File

@ -182,10 +182,12 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
- Unit tests in `AndromidaTests/` - Unit tests in `AndromidaTests/`
- Run via Xcode Test navigator or: - 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. - 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. - `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. - `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 ## Notes