456 lines
17 KiB
Swift
456 lines
17 KiB
Swift
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 var retainedTestContainers: [ModelContainer] = []
|
|
|
|
@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)")
|
|
}
|
|
retainedTestContainers.append(container)
|
|
|
|
return RitualStore(
|
|
modelContext: container.mainContext,
|
|
seedService: EmptySeedService(),
|
|
settingsStore: TestFeedbackSettings(),
|
|
isRunningTests: true,
|
|
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
|
|
}
|