import Foundation import Testing @testable import Cable /// The retention counters are the only way to reconstruct D_k curves from an analytics backend /// that cannot identify a device across days, so their arithmetic is verified here rather than /// trusted in production. Serialized because `UsageMetrics.store` is process-wide state. @Suite(.serialized) struct UsageMetricsTests { /// Runs `body` against an isolated defaults suite and a clock the test drives itself. private func withFreshStore(_ body: (UserDefaults, _ setDay: (Int) -> Void) -> Void) { let name = "usage.tests.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: name) else { Issue.record("could not create a test defaults suite") return } let previousStore = UsageMetrics.store let previousClock = UsageMetrics.clock var day = 20_000 UsageMetrics.store = defaults UsageMetrics.clock = { Date(timeIntervalSince1970: Double(day) * 86_400 + 3_600) } defer { UsageMetrics.store = previousStore UsageMetrics.clock = previousClock defaults.removePersistentDomain(forName: name) } body(defaults, { day = $0 }) } private func props() -> [String: Int] { UsageMetrics.eventProps.compactMapValues { $0 as? Int } } @Test func firstLaunchOfANewInstallStartsTheCounters() { withFreshStore { _, _ in UsageMetrics.beginLaunch(isFirstLaunch: true) #expect(props() == [ "tenure_days": 0, "launch_no": 1, "active_days": 1, "dormant_days": -1, ]) } } @Test func furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() { withFreshStore { _, _ in UsageMetrics.beginLaunch(isFirstLaunch: true) UsageMetrics.beginLaunch(isFirstLaunch: false) UsageMetrics.beginLaunch(isFirstLaunch: false) #expect(props() == [ "tenure_days": 0, "launch_no": 3, "active_days": 1, "dormant_days": 0, ]) } } @Test func tenureAndActiveDaysAdvanceAcrossDays() { withFreshStore { _, setDay in UsageMetrics.beginLaunch(isFirstLaunch: true) setDay(20_001) UsageMetrics.beginLaunch(isFirstLaunch: false) #expect(props() == [ "tenure_days": 1, "launch_no": 2, "active_days": 2, "dormant_days": 1, ]) setDay(20_007) UsageMetrics.beginLaunch(isFirstLaunch: false) #expect(props() == [ "tenure_days": 7, "launch_no": 3, "active_days": 3, "dormant_days": 6, ]) } } /// The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly /// one launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in /// the export measure launches instead of installs. @Test func exactlyOneLaunchPerDayMarksTheDayBoundary() { withFreshStore { _, setDay in var installMarkers = 0 var dayMarkers = 0 var isFirst = true for day in 20_000...20_004 { setDay(day) for _ in 0..<3 { UsageMetrics.beginLaunch(isFirstLaunch: isFirst) isFirst = false if props()["launch_no"] == 1 { installMarkers += 1 } if props()["dormant_days", default: 0] >= 1 { dayMarkers += 1 } } } #expect(installMarkers == 1) // Day 0 is counted by the install marker, so the boundary marks days 1...4. #expect(dayMarkers == 4) #expect(props()["active_days"] == 5) #expect(props()["launch_no"] == 15) } } /// Installs that predate the counters have no install date. They must stay distinguishable /// from fresh installs forever, otherwise the update inflates the new-install cohort. @Test func installsPredatingTheCountersReportUnknownTenure() { withFreshStore { _, setDay in UsageMetrics.beginLaunch(isFirstLaunch: false) #expect(props() == [ "tenure_days": -1, "launch_no": 1, "active_days": 1, "dormant_days": -1, ]) setDay(20_003) UsageMetrics.beginLaunch(isFirstLaunch: false) #expect(props() == [ "tenure_days": -1, "launch_no": 2, "active_days": 2, "dormant_days": 3, ]) } } @Test func aBackwardsClockNeverProducesNegativeCounts() { withFreshStore { _, setDay in setDay(20_010) UsageMetrics.beginLaunch(isFirstLaunch: true) setDay(20_002) UsageMetrics.beginLaunch(isFirstLaunch: false) #expect(props()["tenure_days"] == 0) #expect(props()["dormant_days"] == 0) } } @Test func dayIndexIsAUTCDayCount() { #expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 0)) == 0) #expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_399)) == 0) #expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_400)) == 1) } }