import Foundation import SwiftData import Testing @testable import Rituals @Suite(.serialized) struct RitualStoreTests { @MainActor @Test func quickRitualStartsIncomplete() throws { let store = makeStore() store.createQuickRitual() #expect(store.activeRitual != nil) #expect(abs(store.activeRitualProgress) < 0.0001) } @MainActor @Test func toggleHabitCompletionMarksComplete() throws { let store = makeStore() store.createQuickRitual() guard let habit = store.activeRitual?.habits.first else { throw TestError.missingHabit } store.toggleHabitCompletion(habit) #expect(store.isHabitCompletedToday(habit) == true) } @MainActor @Test func toggleHabitCompletionForSpecificDate() throws { let store = makeStore() store.createQuickRitual() guard let ritual = store.activeRitual, let habit = ritual.habits.first, let arc = ritual.currentArc else { throw TestError.missingHabit } let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())! arc.startDate = Calendar.current.startOfDay(for: yesterday) arc.endDate = Calendar.current.date( byAdding: .day, value: arc.durationDays - 1, to: arc.startDate ) ?? arc.endDate store.toggleHabitCompletion(habit, date: yesterday) let completions = store.habitCompletions(for: yesterday) let completion = completions.first { $0.habit.id == habit.id } #expect(completion?.isCompleted == true) #expect(store.isHabitCompletedToday(habit) == false) } @MainActor @Test func arcRenewalCreatesNewArc() throws { let store = makeStore() store.createQuickRitual() guard let ritual = store.activeRitual else { throw TestError.missingHabit } #expect((ritual.arcs?.count ?? 0) == 1) #expect(ritual.currentArc?.arcNumber == 1) // End the current arc store.endArc(for: ritual) #expect(ritual.currentArc == nil) // Renew the arc store.renewArc(for: ritual, durationDays: 30, copyHabits: true) #expect((ritual.arcs?.count ?? 0) == 2) #expect(ritual.currentArc?.arcNumber == 2) #expect((ritual.currentArc?.habits?.count ?? 0) == 3) } @MainActor @Test func currentStreakCountsOnlyPerfectDays() throws { let store = makeStore() store.createQuickRitual() guard let ritual = store.activeRitual, let arc = ritual.currentArc else { throw TestError.missingRitual } let habits = ritual.habits guard habits.count >= 2 else { throw TestError.missingHabit } let calendar = Calendar.current let today = Date() let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today) ?? today arc.startDate = calendar.startOfDay(for: twoDaysAgo) arc.endDate = calendar.date( byAdding: .day, value: arc.durationDays - 1, to: arc.startDate ) ?? arc.endDate // Yesterday is perfect: complete all habits. for habit in habits { store.toggleHabitCompletion(habit, date: yesterday) } // Today is partial: complete only one habit. store.toggleHabitCompletion(habits[0], date: today) #expect(store.currentStreak() == 1) } @MainActor @Test func allRitualCompletionRateIncludesEndedArcsForHistoricalDates() throws { let store = makeStore() store.createQuickRitual() guard let ritual = store.activeRitual, let habit = ritual.habits.first, let arcStart = ritual.currentArc?.startDate else { throw TestError.missingHabit } store.toggleHabitCompletion(habit, date: arcStart) store.endArc(for: ritual) let rate = store.completionRate(for: arcStart, ritual: nil) #expect(rate > 0.0) } @MainActor @Test func completedActiveArcStillTriggersRenewalPrompt() throws { let store = makeStore() store.createQuickRitual() guard let ritual = store.activeRitual, let arc = ritual.currentArc else { throw TestError.missingRitual } let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() arc.endDate = yesterday #expect(ritual.hasActiveArc == false) store.checkForCompletedArcs() #expect(store.ritualNeedingRenewal?.id == ritual.id) } @MainActor @Test func currentArcUsesMostRecentActiveArc() throws { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let oldStart = calendar.date(byAdding: .day, value: -20, to: today) ?? today let oldEnd = calendar.date(byAdding: .day, value: -5, to: today) ?? today let currentStart = calendar.date(byAdding: .day, value: -2, to: today) ?? today let currentEnd = calendar.date(byAdding: .day, value: 10, to: today) ?? today let oldArc = RitualArc(startDate: oldStart, endDate: oldEnd, arcNumber: 1, isActive: true) let currentArc = RitualArc(startDate: currentStart, endDate: currentEnd, arcNumber: 2, isActive: true) let ritual = Ritual(title: "Test", theme: "Theme", arcs: [oldArc, currentArc]) #expect(ritual.currentArc?.arcNumber == 2) #expect(ritual.hasActiveArc == true) } @MainActor @Test func completedArcCountIncludesExpiredActiveArcs() throws { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let start = calendar.date(byAdding: .day, value: -10, to: today) ?? today let end = calendar.date(byAdding: .day, value: -1, to: today) ?? today let expiredButActiveArc = RitualArc(startDate: start, endDate: end, arcNumber: 1, isActive: true) let ritual = Ritual(title: "Expired", theme: "Theme", arcs: [expiredButActiveArc]) #expect(ritual.completedArcCount == 1) #expect(Calendar.current.isDate(ritual.lastCompletedDate ?? .distantPast, inSameDayAs: end)) } @MainActor @Test func nextUpcomingRitualContextIdentifiesTomorrowForFutureArc() throws { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today let nextWeek = calendar.date(byAdding: .day, value: 7, to: tomorrow) ?? tomorrow let middayToday = calendar.date(byAdding: .hour, value: 12, to: today) ?? today let tomorrowArc = RitualArc(startDate: tomorrow, endDate: nextWeek, arcNumber: 1, isActive: true) let eveningRitual = Ritual( title: "Evening Wind Down", theme: "Test", timeOfDay: .evening, arcs: [tomorrowArc] ) let context = RitualAnalytics.nextUpcomingRitualContext(from: [eveningRitual], currentDate: middayToday) #expect(context?.ritual.id == eveningRitual.id) #expect(context?.isTomorrow == true) } @MainActor @Test func ritualConvenienceAccessorsPreferInProgressArcOverFutureActiveArc() throws { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today let nextWeek = calendar.date(byAdding: .day, value: 7, to: today) ?? today let futureEnd = calendar.date(byAdding: .day, value: 14, to: today) ?? today let todayHabit = ArcHabit(title: "Today Habit", symbolName: "sun.max.fill", sortIndex: 0) let futureHabit = ArcHabit(title: "Future Habit", symbolName: "moon.fill", sortIndex: 0) let todayArc = RitualArc(startDate: yesterday, endDate: tomorrow, arcNumber: 1, isActive: true, habits: [todayHabit]) let futureArc = RitualArc(startDate: nextWeek, endDate: futureEnd, arcNumber: 2, isActive: true, habits: [futureHabit]) let ritual = Ritual(title: "Arc Accessors", theme: "Test", defaultDurationDays: 28, arcs: [todayArc, futureArc]) #expect(ritual.currentArc?.arcNumber == 2) // Newest active arc #expect(ritual.habits.first?.title == "Today Habit") #expect(Calendar.current.isDate(ritual.startDate, inSameDayAs: yesterday)) #expect(Calendar.current.isDate(ritual.endDate, inSameDayAs: tomorrow)) #expect(ritual.durationDays == todayArc.durationDays) } @MainActor @Test func addHabitTargetsInProgressArcWhenFutureArcIsAlsoActive() throws { let store = makeStore() let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) store.createRitual( title: "Add Habit Routing", theme: "Test", habits: [ArcHabit(title: "Now", symbolName: "clock", sortIndex: 0)] ) guard let ritual = store.rituals.first, let inProgressArc = ritual.activeArc(on: today) else { throw TestError.missingRitual } let futureArc = RitualArc( startDate: calendar.date(byAdding: .day, value: 5, to: today) ?? today, endDate: calendar.date(byAdding: .day, value: 12, to: today) ?? today, arcNumber: 2, isActive: true, habits: [ArcHabit(title: "Future", symbolName: "calendar", sortIndex: 0)] ) var arcs = ritual.arcs ?? [] arcs.append(futureArc) ritual.arcs = arcs let inProgressBefore = inProgressArc.habits?.count ?? 0 let futureBefore = futureArc.habits?.count ?? 0 store.addHabit(to: ritual, title: "Added Today", symbolName: "plus.circle") #expect((inProgressArc.habits?.count ?? 0) == inProgressBefore + 1) #expect((futureArc.habits?.count ?? 0) == futureBefore) } @MainActor @Test func ritualStoreUsesInjectedCurrentDateForDayIndex() throws { let calendar = Calendar.current let fixedNow = calendar.date(from: DateComponents(year: 2026, month: 2, day: 8, hour: 10)) ?? Date() let startDate = calendar.date(byAdding: .day, value: -3, to: fixedNow) ?? fixedNow let endDate = calendar.date(byAdding: .day, value: 10, to: fixedNow) ?? fixedNow let store = makeStore(now: { fixedNow }) store.createRitual( title: "Injected Time", theme: "Test", habits: [ArcHabit(title: "Habit", symbolName: "clock", sortIndex: 0)] ) guard let ritual = store.rituals.first, let arc = ritual.currentArc else { throw TestError.missingRitual } arc.startDate = calendar.startOfDay(for: startDate) arc.endDate = calendar.startOfDay(for: endDate) let expectedDay = arc.dayIndex(for: fixedNow) #expect(store.ritualDayIndex(for: ritual) == expectedDay) } @MainActor @Test func currentRitualClassificationUsesInjectedCurrentDate() throws { let calendar = Calendar.current let fixedNow = calendar.date(from: DateComponents(year: 2030, month: 6, day: 15, hour: 10)) ?? Date() let store = makeStore(now: { fixedNow }) store.createRitual( title: "Future Relative To System Clock", theme: "Test", habits: [ArcHabit(title: "Habit", symbolName: "clock", sortIndex: 0)] ) #expect(store.currentRituals.count == 1) #expect(store.pastRituals.isEmpty) } @MainActor @Test func currentStreakCalculationRespectsProvidedCurrentDate() throws { let calendar = Calendar.current let fixedNow = calendar.date(from: DateComponents(year: 2026, month: 2, day: 8, hour: 9)) ?? Date() let dayBefore = calendar.date(byAdding: .day, value: -1, to: fixedNow) ?? fixedNow let twoDaysBefore = calendar.date(byAdding: .day, value: -2, to: fixedNow) ?? fixedNow let dayBeforeID = RitualAnalytics.dayIdentifier(for: dayBefore, calendar: calendar) let twoDaysBeforeID = RitualAnalytics.dayIdentifier(for: twoDaysBefore, calendar: calendar) let habitOne = ArcHabit(title: "One", symbolName: "1.circle", completedDayIDs: [dayBeforeID, twoDaysBeforeID]) let habitTwo = ArcHabit(title: "Two", symbolName: "2.circle", completedDayIDs: [dayBeforeID, twoDaysBeforeID]) let arc = RitualArc( startDate: calendar.date(byAdding: .day, value: -10, to: fixedNow) ?? fixedNow, endDate: calendar.date(byAdding: .day, value: 10, to: fixedNow) ?? fixedNow, arcNumber: 1, isActive: true, habits: [habitOne, habitTwo] ) let ritual = Ritual(title: "Streak", theme: "Test", arcs: [arc]) let streak = RitualAnalytics.calculateCurrentStreak( rituals: [ritual], calendar: calendar, currentDate: fixedNow ) #expect(streak == 2) } @MainActor @Test func dataIntegrityMigrationRepairsArcsAndSortIndexes() throws { let store = makeStore() let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let habits = [ ArcHabit(title: "Habit A", symbolName: "a.circle", sortIndex: 5), ArcHabit(title: "Habit B", symbolName: "b.circle", sortIndex: 2) ] store.createRitual( title: "Migration Ritual", theme: "Test", durationDays: 28, habits: habits ) guard let ritual = store.rituals.first, let firstArc = ritual.currentArc else { throw TestError.missingRitual } ritual.sortIndex = 99 firstArc.startDate = calendar.date(byAdding: .day, value: -2, to: today) ?? today firstArc.endDate = calendar.date(byAdding: .day, value: 5, to: today) ?? today firstArc.habits?[0].sortIndex = 10 firstArc.habits?[1].sortIndex = 7 let secondArc = RitualArc( startDate: calendar.date(byAdding: .day, value: -1, to: today) ?? today, endDate: calendar.date(byAdding: .day, value: 7, to: today) ?? today, arcNumber: 2, isActive: true, habits: [ ArcHabit(title: "Habit C", symbolName: "c.circle", sortIndex: 8), ArcHabit(title: "Habit D", symbolName: "d.circle", sortIndex: 4) ] ) let invalidArc = RitualArc( startDate: calendar.date(byAdding: .day, value: 3, to: today) ?? today, endDate: calendar.date(byAdding: .day, value: 1, to: today) ?? today, arcNumber: 3, isActive: false, habits: [] ) var arcs = ritual.arcs ?? [] arcs.append(secondArc) arcs.append(invalidArc) ritual.arcs = arcs store.runDataIntegrityMigrationIfNeeded(force: true) #expect(ritual.sortIndex == 0) #expect(invalidArc.endDate >= invalidArc.startDate) #expect(firstArc.isActive == false) #expect(secondArc.isActive == true) #expect((ritual.arcs ?? []).filter { $0.isInProgress(on: today) }.count == 1) for arc in ritual.arcs ?? [] { let normalized = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex } for (index, habit) in normalized.enumerated() { #expect(habit.sortIndex == index) } } } } @MainActor private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore { 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, seedService: EmptySeedService(), settingsStore: TestFeedbackSettings(), now: now ) } private struct EmptySeedService: RitualSeedProviding { func makeSeedRituals(startDate: Date) -> [Ritual] { [] } } private struct TestFeedbackSettings: RitualFeedbackSettingsProviding { var hapticsEnabled: Bool = false var soundEnabled: Bool = false } private enum TestError: Error { case missingHabit case missingRitual }