Some checks failed
CI / build-and-test (push) Has been cancelled
Completes 'available HealthKit trends' for weekly/monthly reports. Adds HealthKitManager.dailyReference (3 parallel bucketed HKStatisticsCollectionQuery for steps/active-calories/resting-HR over ~13 months), AnalyticsService. refreshHealthReference to cache and merge it into DaySamples, and avgSteps/ avgActiveCalories/avgRestingHR on PeriodSummary (nil when absent — honest). Surfaced in AnalyticsView's weekly/monthly cards. Verified standalone (swiftc, 8/8): aggregation correct when present, nil when absent, workout metrics unaffected, reports stay deterministic. Tests added to AnalyticsEngineTests. This completes all analytics logic — remaining work is the first Xcode build (device, user-run) to catch any SwiftUI compile issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
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))
|
|
}
|
|
}
|