fixes for testing and debug

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-09 09:14:08 -06:00
parent c1625e4c54
commit ce27a4473e
7 changed files with 143 additions and 68 deletions

View File

@ -5,6 +5,7 @@ import Bedrock
@main @main
struct AndromidaApp: App { struct AndromidaApp: App {
private let modelContainer: ModelContainer private let modelContainer: ModelContainer
private let launchContext: AppLaunchContext
@State private var store: RitualStore @State private var store: RitualStore
@State private var settingsStore: SettingsStore @State private var settingsStore: SettingsStore
@State private var categoryStore: CategoryStore @State private var categoryStore: CategoryStore
@ -15,6 +16,10 @@ struct AndromidaApp: App {
@State private var isTransitioningToRoot = false @State private var isTransitioningToRoot = false
init() { init() {
#if !DEBUG
Design.showDebugLogs = false
#endif
// Register app's color theme for Bedrock components // Register app's color theme for Bedrock components
Theme.register( Theme.register(
text: AppTextColors.self, text: AppTextColors.self,
@ -24,43 +29,14 @@ struct AndromidaApp: App {
) )
Theme.register(border: AppBorder.self) Theme.register(border: AppBorder.self)
let environment = ProcessInfo.processInfo.environment let launchContext = AppLaunchContext()
let isRunningTests = environment["XCTestConfigurationFilePath"] != nil self.launchContext = launchContext
let isUITesting = environment["UITEST_MODE"] == "1" launchContext.applyUserDefaultsOverrides(bundleIdentifier: Bundle.main.bundleIdentifier)
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 let configuration = launchContext.modelConfiguration(for: schema)
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 let container: ModelContainer
do { do {
@ -73,15 +49,13 @@ struct AndromidaApp: App {
_settingsStore = State(initialValue: settings) _settingsStore = State(initialValue: settings)
_categoryStore = State(initialValue: CategoryStore()) _categoryStore = State(initialValue: CategoryStore())
let ritualStore = RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings) let ritualStore = RitualStore(
if isUITesting, environment["UITEST_SEED_THREE_PRESETS"] == "1" { modelContext: container.mainContext,
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning seedService: RitualSeedService(),
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday settingsStore: settings,
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening isRunningTests: launchContext.isRunningTests
} )
if isUITesting, environment["UITEST_PRELOAD_DEMO_DATA"] == "1" { launchContext.applyUITestSeeding(to: ritualStore)
ritualStore.preloadDemoData()
}
_store = State(initialValue: ritualStore) _store = State(initialValue: ritualStore)
} }
@ -92,13 +66,12 @@ struct AndromidaApp: App {
.ignoresSafeArea() .ignoresSafeArea()
if hasCompletedSetupWizard { if hasCompletedSetupWizard {
let uiTestInitialTab = uiTestRequestedInitialTab()
// Main app - start on Rituals tab if just completed wizard // Main app - start on Rituals tab if just completed wizard
RootView( RootView(
store: store, store: store,
settingsStore: settingsStore, settingsStore: settingsStore,
categoryStore: categoryStore, categoryStore: categoryStore,
initialTab: uiTestInitialTab ?? (justCompletedWizard ? .rituals : .today) initialTab: launchContext.initialTabOverride ?? (justCompletedWizard ? .rituals : .today)
) )
.transition(.opacity) .transition(.opacity)
} else { } else {
@ -126,16 +99,4 @@ struct AndromidaApp: App {
.preferredColorScheme(settingsStore.theme.colorScheme) .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

@ -1,6 +1,7 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import Observation import Observation
import Bedrock
/// Reminder time slots based on ritual TimeOfDay values. /// Reminder time slots based on ritual TimeOfDay values.
/// Groups similar times to avoid excessive notifications. /// Groups similar times to avoid excessive notifications.
@ -85,7 +86,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
willPresent notification: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) { ) {
print("🔔 Notification will present in foreground: \(notification.request.identifier)") Design.debugLog("🔔 Notification will present in foreground: \(notification.request.identifier)")
// Show the notification even when the app is in the foreground // Show the notification even when the app is in the foreground
completionHandler([.banner, .list, .sound, .badge]) completionHandler([.banner, .list, .sound, .badge])
} }
@ -95,7 +96,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void
) { ) {
print("🔔 Notification received/tapped: \(response.notification.request.identifier)") Design.debugLog("🔔 Notification received/tapped: \(response.notification.request.identifier)")
// Clear badge when user interacts with notification // Clear badge when user interacts with notification
clearBadge() clearBadge()
shouldNavigateToToday = true shouldNavigateToToday = true
@ -127,7 +128,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
await refreshAuthorizationStatus() await refreshAuthorizationStatus()
return granted return granted
} catch { } catch {
print("Notification authorization error: \(error)") Design.debugLog("Notification authorization error: \(error)")
return false return false
} }
} }
@ -147,7 +148,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
/// Schedules a test notification to appear in 5 seconds. /// Schedules a test notification to appear in 5 seconds.
func scheduleTestNotification() { func scheduleTestNotification() {
print("🔔 Attempting to schedule test notification...") Design.debugLog("🔔 Attempting to schedule test notification...")
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = String(localized: "Test Notification") content.title = String(localized: "Test Notification")
@ -164,9 +165,9 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
UNUserNotificationCenter.current().add(request) { error in UNUserNotificationCenter.current().add(request) { error in
if let error = error { if let error = error {
print("❌ Failed to schedule test notification: \(error)") Design.debugLog("❌ Failed to schedule test notification: \(error)")
} else { } else {
print("✅ Test notification scheduled successfully! It should appear in 5 seconds.") Design.debugLog("✅ Test notification scheduled successfully! It should appear in 5 seconds.")
} }
} }
} }
@ -257,7 +258,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
do { do {
try await UNUserNotificationCenter.current().add(request) try await UNUserNotificationCenter.current().add(request)
} catch { } catch {
print("Failed to schedule \(slot.rawValue) reminder: \(error)") Design.debugLog("Failed to schedule \(slot.rawValue) reminder: \(error)")
} }
} }
} }

View File

@ -11,6 +11,11 @@ extension RitualStore {
} catch { } catch {
fatalError("Preview container failed: \(error)") fatalError("Preview container failed: \(error)")
} }
return RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: SettingsStore()) return RitualStore(
modelContext: container.mainContext,
seedService: RitualSeedService(),
settingsStore: SettingsStore(),
isRunningTests: false
)
} }
} }

View File

@ -59,6 +59,7 @@ final class RitualStore: RitualStoreProviding {
modelContext: ModelContext, modelContext: ModelContext,
seedService: RitualSeedProviding, seedService: RitualSeedProviding,
settingsStore: any RitualFeedbackSettingsProviding, settingsStore: any RitualFeedbackSettingsProviding,
isRunningTests: Bool,
calendar: Calendar = .current, calendar: Calendar = .current,
now: @escaping () -> Date = Date.init now: @escaping () -> Date = Date.init
) { ) {
@ -67,7 +68,7 @@ final class RitualStore: RitualStoreProviding {
self.settingsStore = settingsStore self.settingsStore = settingsStore
self.calendar = calendar self.calendar = calendar
self.nowProvider = now self.nowProvider = now
self.isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil self.isRunningTests = isRunningTests
self.dayFormatter = DateFormatter() self.dayFormatter = DateFormatter()
self.displayFormatter = DateFormatter() self.displayFormatter = DateFormatter()
dayFormatter.calendar = calendar dayFormatter.calendar = calendar
@ -1556,7 +1557,7 @@ final class RitualStore: RitualStoreProviding {
// Find the first ritual with an active arc // Find the first ritual with an active arc
guard let ritual = currentRituals.first, guard let ritual = currentRituals.first,
let arc = ritual.activeArc(on: now()) else { let arc = ritual.activeArc(on: now()) else {
print("No active arcs to complete") Design.debugLog("No active arcs to complete")
return return
} }
@ -1573,7 +1574,7 @@ final class RitualStore: RitualStoreProviding {
// Trigger the completion check - this will set ritualNeedingRenewal // Trigger the completion check - this will set ritualNeedingRenewal
checkForCompletedArcs() checkForCompletedArcs()
print("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.") Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
} }
#endif #endif
} }

View File

@ -0,0 +1,105 @@
import Foundation
import SwiftData
struct AppLaunchContext {
enum LaunchTab: String {
case today
case rituals
case insights
case history
case settings
}
let isRunningTests: Bool
let isUITesting: Bool
let shouldResetUserDefaults: Bool
let hasCompletedSetupWizardOverride: Bool?
let shouldSeedThreePresets: Bool
let shouldPreloadDemoData: Bool
let requestedInitialTab: LaunchTab?
init(environment: [String: String] = ProcessInfo.processInfo.environment) {
isRunningTests = environment["XCTestConfigurationFilePath"] != nil
isUITesting = Self.isEnabled(environment["UITEST_MODE"])
shouldResetUserDefaults = Self.isEnabled(environment["UITEST_RESET_USER_DEFAULTS"])
hasCompletedSetupWizardOverride = Self.optionalBool(environment["UITEST_HAS_COMPLETED_SETUP_WIZARD"])
shouldSeedThreePresets = Self.isEnabled(environment["UITEST_SEED_THREE_PRESETS"])
shouldPreloadDemoData = Self.isEnabled(environment["UITEST_PRELOAD_DEMO_DATA"])
requestedInitialTab = Self.parseTab(environment["UITEST_INITIAL_TAB"])
}
func applyUserDefaultsOverrides(bundleIdentifier: String?) {
guard isUITesting else { return }
if shouldResetUserDefaults, let bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
}
if let hasCompletedSetupWizardOverride {
UserDefaults.standard.set(hasCompletedSetupWizardOverride, forKey: "hasCompletedSetupWizard")
}
}
func modelConfiguration(for schema: Schema) -> ModelConfiguration {
if isUITesting {
// UI tests should always run with isolated in-memory persistence.
return ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
}
// 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")
return ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
)
}
func applyUITestSeeding(to ritualStore: RitualStore) {
guard isUITesting else { return }
if shouldSeedThreePresets {
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening
}
#if DEBUG
if shouldPreloadDemoData {
ritualStore.preloadDemoData()
}
#endif
}
var initialTabOverride: RootView.RootTab? {
guard let requestedInitialTab else { return nil }
switch requestedInitialTab {
case .today: return .today
case .rituals: return .rituals
case .insights: return .insights
case .history: return .history
case .settings: return .settings
}
}
private static func isEnabled(_ value: String?) -> Bool {
optionalBool(value) ?? false
}
private static func optionalBool(_ value: String?) -> Bool? {
guard let value else { return nil }
return value == "1" || value.lowercased() == "true"
}
private static func parseTab(_ value: String?) -> LaunchTab? {
guard let value else { return nil }
return LaunchTab(rawValue: value.lowercased())
}
}

View File

@ -47,7 +47,8 @@ struct AndromidaTests {
let store = RitualStore( let store = RitualStore(
modelContext: container.mainContext, modelContext: container.mainContext,
seedService: EmptySeedService(), seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings() settingsStore: TestFeedbackSettings(),
isRunningTests: true
) )
store.createQuickRitual() store.createQuickRitual()

View File

@ -433,6 +433,7 @@ private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
modelContext: container.mainContext, modelContext: container.mainContext,
seedService: EmptySeedService(), seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings(), settingsStore: TestFeedbackSettings(),
isRunningTests: true,
now: now now: now
) )
} }