import XCTest @testable import KisaniCal /// Deterministic tests for the pure analytics engine — formulas, honest-degradation, /// trend classification, and calendar/timezone/DST/firstWeekday boundary behavior. final class AnalyticsEngineTests: XCTestCase { // MARK: - Delta / percentage change func testDeltaNormal() { let d = AnalyticsEngine.delta(current: 120, baseline: 100) XCTAssertEqual(d.absolute, 20) XCTAssertEqual(d.percent!, 20, accuracy: 0.0001) XCTAssertEqual(d.direction, .up) XCTAssertTrue(d.meaningful) } func testDeltaDecrease() { let d = AnalyticsEngine.delta(current: 80, baseline: 100) XCTAssertEqual(d.percent!, -20, accuracy: 0.0001) XCTAssertEqual(d.direction, .down) } func testDeltaNoBaselineIsNotMeaningful() { let d = AnalyticsEngine.delta(current: 50, baseline: nil) XCTAssertNil(d.percent) XCTAssertNil(d.absolute) XCTAssertFalse(d.meaningful) } func testDeltaZeroBaselineNoPercent() { // Divide-by-zero guard: zero baseline yields an absolute but no percent. let d = AnalyticsEngine.delta(current: 30, baseline: 0) XCTAssertEqual(d.absolute, 30) XCTAssertNil(d.percent) XCTAssertFalse(d.meaningful) } func testDeltaTinyBaselineNoPercent() { let d = AnalyticsEngine.delta(current: 10, baseline: 0.00001) XCTAssertNil(d.percent, "percentage against a negligible baseline is suppressed") } func testDeltaNewExercise() { let d = AnalyticsEngine.delta(current: 500, baseline: 400, isNew: true) XCTAssertNil(d.percent) XCTAssertFalse(d.meaningful) } func testDeltaRestDayBaseline() { let d = AnalyticsEngine.delta(current: 500, baseline: 400, restDay: true) XCTAssertFalse(d.meaningful) } // MARK: - Consistency func testConsistencyNormal() { XCTAssertEqual(AnalyticsEngine.consistency(sessions: 4, scheduled: 5), 0.8, accuracy: 0.0001) } func testConsistencyZeroScheduled() { XCTAssertEqual(AnalyticsEngine.consistency(sessions: 3, scheduled: 0), 0) } func testConsistencyCappedAtOne() { XCTAssertEqual(AnalyticsEngine.consistency(sessions: 6, scheduled: 5), 1.0) } // MARK: - Volume func testVolume() { XCTAssertEqual(AnalyticsEngine.volume(weight: 60, reps: 10), 600) } // MARK: - Trend classification (multi-point) func testTrendImproving() { XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110, 120, 130]).classification, .improving) } func testTrendDeclining() { XCTAssertEqual(AnalyticsEngine.classifyTrend([130, 120, 110, 100]).classification, .declining) } func testTrendPlateau() { XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 101, 99, 100]).classification, .plateau) } func testTrendInsufficientData() { XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110]).classification, .insufficientData) } func testTrendAllZeroIsPlateau() { XCTAssertEqual(AnalyticsEngine.classifyTrend([0, 0, 0, 0]).classification, .plateau) } // MARK: - classifyChange (week/month over period) func testClassifyChangeImproving() { let d = AnalyticsEngine.delta(current: 120, baseline: 100) XCTAssertEqual(AnalyticsEngine.classifyChange(d), .improving) } func testClassifyChangePlateau() { let d = AnalyticsEngine.delta(current: 101, baseline: 100) XCTAssertEqual(AnalyticsEngine.classifyChange(d), .plateau) } func testClassifyChangeInsufficient() { let d = AnalyticsEngine.delta(current: 100, baseline: nil) XCTAssertEqual(AnalyticsEngine.classifyChange(d), .insufficientData) } // MARK: - Outliers func testWinsorizeClampsExtremes() { let w = AnalyticsEngine.winsorize([1, 2, 3, 4, 5, 6, 7, 8, 9, 1000]) XCTAssertLessThan(w.max()!, 1000, "extreme high value should be clamped") } func testWinsorizeSmallSampleNoop() { let input = [1.0, 999.0] XCTAssertEqual(AnalyticsEngine.winsorize(input), input) } // MARK: - Exercise identity & comparison func testExerciseIdentityNormalizes() { XCTAssertEqual(AnalyticsEngine.exerciseIdentity(" Bench Press "), AnalyticsEngine.exerciseIdentity("bench press")) } func testCompareExercisesMarksNew() { let cur = [ExerciseSample(identity: "squat", sets: 3, reps: 15, volume: 900), ExerciseSample(identity: "curl", sets: 3, reps: 30, volume: 300)] let base = [ExerciseSample(identity: "squat", sets: 3, reps: 12, volume: 720)] let comps = AnalyticsEngine.compareExercises(current: cur, baseline: base) let squat = comps.first { $0.identity == "squat" }! let curl = comps.first { $0.identity == "curl" }! XCTAssertFalse(squat.isNew) XCTAssertEqual(squat.volume.percent!, 25, accuracy: 0.0001) // 900 vs 720 XCTAssertTrue(curl.isNew) XCTAssertNil(curl.volume.percent) } // MARK: - Unit compatibility func testUnitComparability() { XCTAssertTrue(MetricUnit.kilograms.isComparable(with: .pounds)) XCTAssertFalse(MetricUnit.kilograms.isComparable(with: .minutes)) XCTAssertTrue(MetricUnit.kilometers.isComparable(with: .miles)) XCTAssertFalse(MetricUnit.bpm.isComparable(with: .kilocalories)) } // MARK: - Period summary func testSummarizeCountsSessionsAndMissed() { let days = [ DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, durationMinutes: 60), DaySample(dateKey: "2026-07-07", didWorkout: false, scheduled: true), // missed DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, durationMinutes: 55), DaySample(dateKey: "2026-07-09", didWorkout: false, scheduled: false), // rest ] let s = AnalyticsEngine.summarize(days) XCTAssertEqual(s.sessions, 2) XCTAssertEqual(s.scheduled, 3) XCTAssertEqual(s.missed, 1) XCTAssertEqual(s.sets, 38) XCTAssertEqual(s.volume, 1900) XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001) } // MARK: - HealthKit reference aggregation (partial/missing data honest) func testSummarizeAggregatesHealthKitAveragesWhenPresent() { let days = [ DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, steps: 8000, activeCalories: 500, restingHeartRate: 55), DaySample(dateKey: "2026-07-07", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, steps: 10000, activeCalories: 600, restingHeartRate: 57), DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: false) // no HK reference ] let s = AnalyticsEngine.summarize(days) XCTAssertEqual(s.avgSteps, 9000) XCTAssertEqual(s.avgActiveCalories, 550) XCTAssertEqual(s.avgRestingHR, 56) } func testSummarizeHealthKitNilWhenAbsent() { let s = AnalyticsEngine.summarize([DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sets: 20, volume: 1000)]) XCTAssertNil(s.avgSteps) XCTAssertNil(s.avgActiveCalories) XCTAssertNil(s.avgRestingHR) XCTAssertEqual(s.volume, 1000) // workout metrics unaffected } // MARK: - Reports (idempotency / determinism) func testWeeklyReportDeterministic() { let gen = Date(timeIntervalSince1970: 1_780_000_000) let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)] let prev = [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)] let r1 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen) let r2 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen) XCTAssertEqual(r1, r2, "same inputs → identical report (idempotent)") XCTAssertEqual(r1.vsPreviousWeek["volume"]!.percent!, 25, accuracy: 0.0001) XCTAssertEqual(r1.progression, .improving) XCTAssertFalse(r1.disclaimer.isEmpty) } func testMonthlyReportInsufficientWhenNoPrevious() { let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)] let r = AnalyticsEngine.monthlyReport(monthKey: "2026-07", days: days, previousMonthDays: [], generatedAt: Date()) // previous volume is 0 → no meaningful %, classification insufficient. XCTAssertEqual(r.classification, .insufficientData) } // MARK: - Calendar / timezone / firstWeekday / DST boundaries private func calendar(tz: String, firstWeekday: Int = 1) -> Calendar { var c = Calendar(identifier: .gregorian) c.timeZone = TimeZone(identifier: tz)! c.firstWeekday = firstWeekday c.locale = Locale(identifier: "en_US_POSIX") return c } func testDateKeyRespectsTimezone() { // 2026-07-07T02:30Z is still 2026-07-06 (22:30) in New York (UTC-4). let instant = Date(timeIntervalSince1970: 1_783_391_400) // 2026-07-07T02:30:00Z let utc = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "UTC")) let ny = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "America/New_York")) XCTAssertEqual(utc, "2026-07-07") XCTAssertEqual(ny, "2026-07-06") } func testFirstWeekdayChangesWeekStart() { let date = Date(timeIntervalSince1970: 1_783_500_000) // a Tuesday let sundayFirst = calendar(tz: "UTC", firstWeekday: 1) let mondayFirst = calendar(tz: "UTC", firstWeekday: 2) let wSun = AnalyticsEngine.weekInterval(containing: date, calendar: sundayFirst)! let wMon = AnalyticsEngine.weekInterval(containing: date, calendar: mondayFirst)! XCTAssertNotEqual(wSun.start, wMon.start, "week start differs by firstWeekday") } func testDSTSpringForwardDayKeyStable() { // US DST spring-forward 2026-03-08. The 23-hour day must still key correctly. let cal = calendar(tz: "America/New_York") var comps = DateComponents(); comps.year = 2026; comps.month = 3; comps.day = 8; comps.hour = 12 let noon = cal.date(from: comps)! XCTAssertEqual(AnalyticsEngine.dateKey(for: noon, calendar: cal), "2026-03-08") // start-of-day is well-defined despite the missing hour let interval = AnalyticsEngine.weekInterval(containing: noon, calendar: cal) XCTAssertNotNil(interval) } func testMonthKey() { let cal = calendar(tz: "UTC") let d = Date(timeIntervalSince1970: 1_783_500_000) XCTAssertEqual(AnalyticsEngine.monthKey(for: d, calendar: cal), "2026-07") } // MARK: - Completed-period gating & retention func testCompletedWeekGating() { let cal = calendar(tz: "UTC") let now = Date(timeIntervalSince1970: 1_783_500_000) // "this week" let lastWeek = cal.date(byAdding: .weekOfYear, value: -1, to: now)! XCTAssertTrue(AnalyticsEngine.isCompletedWeek(lastWeek, now: now, calendar: cal)) XCTAssertFalse(AnalyticsEngine.isCompletedWeek(now, now: now, calendar: cal)) } func testRetentionExpiry() { let cal = calendar(tz: "UTC") let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07 let old = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -13, to: now)!, calendar: cal) let recent = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -1, to: now)!, calendar: cal) let expired = AnalyticsEngine.expiredDayKeys([old, recent], now: now, months: 12, calendar: cal) XCTAssertTrue(expired.contains(old)) XCTAssertFalse(expired.contains(recent)) } }