Phase 2: AnalyticsStore (JSON files under Application Support) with immutable frozen daily snapshots + immutable, idempotent, deduped weekly/monthly reports, and 12-month retention pruning. Engine gains completedWeekStartKeys/ completedMonthKeys/missingKeys for idempotent reconciliation selection. AnalyticsStoreTests: 6 XCTest cases. Verified standalone via swiftc — 23- assertion temp-dir harness all pass. Registered files via xcodegen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
4.7 KiB
Swift
95 lines
4.7 KiB
Swift
import XCTest
|
|
@testable import KisaniCal
|
|
|
|
/// Persistence tests: snapshot immutability, report idempotency/dedup,
|
|
/// reconciliation selection, and retention pruning. Each test runs against a
|
|
/// fresh temp directory. (Verified standalone via swiftc; mirrored here for the
|
|
/// Xcode test target.)
|
|
final class AnalyticsStoreTests: XCTestCase {
|
|
|
|
private var tmp: URL!
|
|
private var store: AnalyticsStore!
|
|
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
|
|
|
private func cal(_ tz: String = "UTC") -> Calendar {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.timeZone = TimeZone(identifier: tz)!
|
|
c.locale = Locale(identifier: "en_US_POSIX")
|
|
return c
|
|
}
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("analytics_test_\(UUID().uuidString)")
|
|
store = AnalyticsStore(root: tmp)
|
|
}
|
|
|
|
override func tearDown() {
|
|
try? FileManager.default.removeItem(at: tmp)
|
|
super.tearDown()
|
|
}
|
|
|
|
func testPastSnapshotIsFrozen() {
|
|
let past = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: 500)
|
|
XCTAssertTrue(store.saveSnapshot(past, isPast: true))
|
|
let edited = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 9, sets: 99, volume: 9999)
|
|
XCTAssertFalse(store.saveSnapshot(edited, isPast: true), "past snapshot must not be overwritten")
|
|
XCTAssertEqual(store.snapshot(dateKey: "2026-06-01")?.sets, 10)
|
|
}
|
|
|
|
func testTodaySnapshotUpdatable() {
|
|
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: true), isPast: false)
|
|
XCTAssertTrue(store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000), isPast: false))
|
|
XCTAssertEqual(store.snapshot(dateKey: "2026-07-08")?.sets, 20)
|
|
}
|
|
|
|
func testWeeklyReportIdempotentAndImmutable() {
|
|
let wr = AnalyticsEngine.weeklyReport(
|
|
weekStartKey: "2026-06-29",
|
|
days: [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
|
|
previousWeekDays: [DaySample(dateKey: "2026-06-22", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
|
|
generatedAt: now)
|
|
XCTAssertTrue(store.saveWeeklyReport(wr))
|
|
XCTAssertFalse(store.saveWeeklyReport(wr), "re-saving must be a no-op (dedup)")
|
|
XCTAssertEqual(store.weeklyReport(weekStartKey: "2026-06-29")?.progression, .improving)
|
|
}
|
|
|
|
func testMonthlyReportDedup() {
|
|
let mr = AnalyticsEngine.monthlyReport(
|
|
monthKey: "2026-06",
|
|
days: [DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
|
|
previousMonthDays: [DaySample(dateKey: "2026-05-01", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
|
|
generatedAt: now)
|
|
XCTAssertTrue(store.saveMonthlyReport(mr))
|
|
XCTAssertFalse(store.saveMonthlyReport(mr))
|
|
XCTAssertNotNil(store.monthlyReport(monthKey: "2026-06"))
|
|
}
|
|
|
|
func testReconciliationSelection() {
|
|
let c = cal()
|
|
let weekKeys = AnalyticsEngine.completedWeekStartKeys(now: now, months: 12, calendar: c)
|
|
XCTAssertTrue((50...53).contains(weekKeys.count))
|
|
let currentWeekKey = AnalyticsEngine.dateKey(for: AnalyticsEngine.weekInterval(containing: now, calendar: c)!.start, calendar: c)
|
|
XCTAssertFalse(weekKeys.contains(currentWeekKey), "current (incomplete) week excluded")
|
|
|
|
let monthKeys = AnalyticsEngine.completedMonthKeys(now: now, months: 12, calendar: c)
|
|
XCTAssertEqual(monthKeys.count, 12)
|
|
XCTAssertFalse(monthKeys.contains("2026-07"), "current month excluded")
|
|
|
|
// A saved report is not re-selected (idempotent reconciliation).
|
|
let wr = AnalyticsEngine.weeklyReport(weekStartKey: weekKeys.last!, days: [], previousWeekDays: [], generatedAt: now)
|
|
_ = store.saveWeeklyReport(wr)
|
|
XCTAssertFalse(AnalyticsEngine.missingKeys(weekKeys, existing: store.existingWeeklyKeys()).contains(weekKeys.last!))
|
|
}
|
|
|
|
func testRetentionPrune() {
|
|
let c = cal()
|
|
_ = store.saveSnapshot(DaySample(dateKey: "2025-01-01", didWorkout: true), isPast: true) // >12mo old
|
|
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true), isPast: false)
|
|
let removed = store.prune(now: now, months: 12, calendar: c)
|
|
XCTAssertGreaterThanOrEqual(removed, 1)
|
|
XCTAssertNil(store.snapshot(dateKey: "2025-01-01"), "expired snapshot pruned")
|
|
XCTAssertNotNil(store.snapshot(dateKey: "2026-07-08"), "recent snapshot survives")
|
|
}
|
|
}
|