Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
469f960fec
commit
efc1ee5fa7
@ -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 */ = {
|
||||
|
||||
@ -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])
|
||||
|
||||
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")
|
||||
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||
let configuration = ModelConfiguration(
|
||||
configuration = ModelConfiguration(
|
||||
schema: schema,
|
||||
url: storeURL,
|
||||
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||
)
|
||||
}
|
||||
|
||||
let container: ModelContainer
|
||||
do {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -78,8 +78,10 @@ final class RitualStore: RitualStoreProviding {
|
||||
self.currentTimeOfDay = TimeOfDay.current(for: now())
|
||||
runDataIntegrityMigrationIfNeeded()
|
||||
loadRitualsIfNeeded()
|
||||
if !isRunningTests {
|
||||
observeRemoteChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func now() -> Date {
|
||||
nowProvider()
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
}
|
||||
|
||||
@ -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<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 {
|
||||
func makeSeedRituals(startDate: Date) -> [Ritual] {
|
||||
[]
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
5
PRD.md
5
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 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user