Andromida/AndromidaTests/RitualStoreTests.swift
Matt Bruce 469f960fec updates from codex
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-08 10:57:39 -06:00

476 lines
17 KiB
Swift

import Foundation
import SwiftData
import Testing
@testable import Rituals
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 container = SharedTestContainer.container
clearSharedTestContainer(container.mainContext)
return RitualStore(
modelContext: container.mainContext,
seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings(),
now: now
)
}
@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] {
[]
}
}
private struct TestFeedbackSettings: RitualFeedbackSettingsProviding {
var hapticsEnabled: Bool = false
var soundEnabled: Bool = false
}
private enum TestError: Error {
case missingHabit
case missingRitual
}