analytics(persistence): file-based store + reconciliation selection (KC-51 ph2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
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>
This commit is contained in:
94
KisaniCalTests/AnalyticsStoreTests.swift
Normal file
94
KisaniCalTests/AnalyticsStoreTests.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user