TimeLogTests.swift (10028B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("TimeLog union accumulation") 7 struct TimeLogTests { 8 9 /// Reference instant; offsets are seconds from here for readability. 10 private let t0 = Date(timeIntervalSinceReferenceDate: 100_000) 11 private func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) } 12 13 /// A log holding a single device's sealed intervals, given as (start, end) 14 /// second-offsets. 15 private func sealed(_ device: String, _ spans: [(TimeInterval, TimeInterval)]) -> TimeLog { 16 let intervals = spans.map { TimeLog.Interval(start: at($0.0), end: at($0.1)) } 17 return TimeLog(devices: [device: .init(intervals: intervals)]) 18 } 19 20 // MARK: - Solo, single device 21 22 @Test("Open then seal accumulates the elapsed span") 23 func openSealAccumulates() { 24 var log = TimeLog() 25 log.open(deviceID: "alice-phone", at: at(0)) 26 log.seal(deviceID: "alice-phone", at: at(120)) 27 let total = TimeLog.accumulatedSeconds( 28 forLogs: [log], localDeviceID: "alice-phone", asOf: at(200) 29 ) 30 #expect(total == 120) 31 } 32 33 @Test("Reopening after a sealed session adds the second span") 34 func reopenAdds() { 35 var log = TimeLog() 36 log.open(deviceID: "alice-phone", at: at(0)) 37 log.seal(deviceID: "alice-phone", at: at(60)) 38 log.open(deviceID: "alice-phone", at: at(300)) 39 log.seal(deviceID: "alice-phone", at: at(330)) 40 let total = TimeLog.accumulatedSeconds( 41 forLogs: [log], localDeviceID: "alice-phone", asOf: at(400) 42 ) 43 #expect(total == 90) 44 } 45 46 @Test("A re-open while already open is idempotent — the first start wins") 47 func reopenWhileOpenIdempotent() { 48 var log = TimeLog() 49 log.open(deviceID: "alice-phone", at: at(0)) 50 log.open(deviceID: "alice-phone", at: at(50)) // renewal, not a new session 51 log.seal(deviceID: "alice-phone", at: at(120)) 52 let total = TimeLog.accumulatedSeconds( 53 forLogs: [log], localDeviceID: "alice-phone", asOf: at(200) 54 ) 55 #expect(total == 120) 56 } 57 58 // MARK: - Multi-device, same author 59 60 @Test("Disjoint sessions on two of one account's devices sum") 61 func multiDeviceDisjointSums() { 62 let phone = sealed("alice-phone", [(0, 100)]) 63 let pad = sealed("alice-pad", [(200, 250)]) 64 // Both slots live on the same author's record in practice; modelled as one 65 // log with two device keys. 66 let log = TimeLog(devices: phone.devices.merging(pad.devices) { a, _ in a }) 67 let total = TimeLog.accumulatedSeconds( 68 forLogs: [log], localDeviceID: "alice-phone", asOf: at(300) 69 ) 70 #expect(total == 150) 71 } 72 73 @Test("Overlapping sessions on two devices collapse to the union, not the sum") 74 func multiDeviceOverlapDedups() { 75 let log = TimeLog(devices: [ 76 "alice-phone": .init(intervals: [.init(start: at(0), end: at(100))]), 77 "alice-pad": .init(intervals: [.init(start: at(60), end: at(160))]), 78 ]) 79 let total = TimeLog.accumulatedSeconds( 80 forLogs: [log], localDeviceID: "alice-phone", asOf: at(300) 81 ) 82 // Union [0,160] = 160, not 100 + 100 = 200. 83 #expect(total == 160) 84 } 85 86 // MARK: - Multiplayer 87 88 @Test("Simultaneous co-solving is counted once") 89 func multiplayerSimultaneousCountedOnce() { 90 let alice = sealed("alice-phone", [(0, 600)]) 91 let bob = sealed("bob-phone", [(0, 600)]) 92 let total = TimeLog.accumulatedSeconds( 93 forLogs: [alice, bob], localDeviceID: "carol-phone", asOf: at(1000) 94 ) 95 #expect(total == 600) 96 } 97 98 @Test("A handoff between players sums the disjoint spans") 99 func multiplayerHandoffSums() { 100 let alice = sealed("alice-phone", [(0, 600)]) 101 let bob = sealed("bob-phone", [(1200, 1500)]) 102 let total = TimeLog.accumulatedSeconds( 103 forLogs: [alice, bob], localDeviceID: "carol-phone", asOf: at(2000) 104 ) 105 #expect(total == 900) 106 } 107 108 @Test("Partial overlap across players counts the union span") 109 func multiplayerPartialOverlap() { 110 let alice = sealed("alice-phone", [(0, 100)]) 111 let bob = sealed("bob-phone", [(80, 200)]) 112 let carol = sealed("carol-phone", [(300, 350)]) 113 let total = TimeLog.accumulatedSeconds( 114 forLogs: [alice, bob, carol], localDeviceID: "alice-phone", asOf: at(400) 115 ) 116 // Union [0,200] (=200) + [300,350] (=50) = 250. 117 #expect(total == 250) 118 } 119 120 // MARK: - Live / in-progress sessions 121 122 @Test("The local device's open session is trusted live to now") 123 func localOpenExtrapolatesToNow() { 124 var log = TimeLog() 125 log.open(deviceID: "alice-phone", at: at(0)) 126 let total = TimeLog.accumulatedSeconds( 127 forLogs: [log], localDeviceID: "alice-phone", asOf: at(45) 128 ) 129 #expect(total == 45) 130 } 131 132 @Test("A peer's open session extrapolates only to its last beat plus grace") 133 func peerOpenBoundedByBeat() { 134 var bob = TimeLog() 135 bob.open(deviceID: "bob-phone", at: at(0)) 136 bob.beat(deviceID: "bob-phone", at: at(100)) // last heartbeat at 100 137 // We (alice) read it far in the future; bob never sealed. 138 let total = TimeLog.accumulatedSeconds( 139 forLogs: [bob], localDeviceID: "alice-phone", asOf: at(10_000) 140 ) 141 // Capped at beat(100) + grace(180) = 280. 142 #expect(total == 100 + TimeLog.openGrace) 143 } 144 145 @Test("A never-sealed local session is capped at maxSessionCap") 146 func localOpenCappedBySessionCap() { 147 var log = TimeLog() 148 log.open(deviceID: "alice-phone", at: at(0)) 149 let total = TimeLog.accumulatedSeconds( 150 forLogs: [log], localDeviceID: "alice-phone", 151 asOf: at(TimeLog.maxSessionCap + 10_000) 152 ) 153 #expect(total == TimeLog.maxSessionCap) 154 } 155 156 @Test("A heartbeat with no open session is a no-op (no bare slot)") 157 func beatWithoutOpenIsNoOp() { 158 var log = TimeLog() 159 log.beat(deviceID: "alice-phone", at: at(0)) 160 #expect(log.devices.isEmpty) 161 // After sealing, a later beat must not resurrect an open session. 162 log.open(deviceID: "alice-phone", at: at(10)) 163 log.seal(deviceID: "alice-phone", at: at(20)) 164 log.beat(deviceID: "alice-phone", at: at(30)) 165 #expect(log.devices["alice-phone"]?.openStart == nil) 166 let total = TimeLog.accumulatedSeconds( 167 forLogs: [log], localDeviceID: "alice-phone", asOf: at(100) 168 ) 169 #expect(total == 10) 170 } 171 172 @Test("A crashed session is banked bounded on the next launch's first open") 173 func staleSessionReconciledOnReopen() { 174 var log = TimeLog() 175 // Sitting one: opened at 0, last heartbeat at 100, then force-quit — never 176 // sealed, so `openStart` persists. 177 log.open(deviceID: "alice-phone", at: at(0)) 178 log.beat(deviceID: "alice-phone", at: at(100)) 179 // A long time later, the first open since launch reconciles it. 180 log.open(deviceID: "alice-phone", at: at(200_000), reconcileStale: true) 181 log.seal(deviceID: "alice-phone", at: at(200_060)) 182 let total = TimeLog.accumulatedSeconds( 183 forLogs: [log], localDeviceID: "alice-phone", asOf: at(300_000) 184 ) 185 // Crashed sitting banks bounded at beat(100) + grace, NOT the dead gap to 186 // 200_000; plus the fresh 60s sitting. 187 #expect(total == 100 + TimeLog.openGrace + 60) 188 } 189 190 @Test("A resume (not reconciled) keeps one continuous session, no split") 191 func resumeKeepsContinuousSession() { 192 var log = TimeLog() 193 log.open(deviceID: "alice-phone", at: at(0)) 194 // An in-run `.active` re-fire (e.g. Control Center) — reconcileStale stays 195 // false, so the sitting is not split. 196 log.open(deviceID: "alice-phone", at: at(600)) 197 log.seal(deviceID: "alice-phone", at: at(900)) 198 let total = TimeLog.accumulatedSeconds( 199 forLogs: [log], localDeviceID: "alice-phone", asOf: at(1000) 200 ) 201 #expect(total == 900) 202 } 203 204 // MARK: - Merge discipline 205 206 @Test("Merging an inbound copy preserves the local device's own open slot") 207 func mergePreservesLocalSlot() { 208 // Local state: my phone has a live open session. 209 var local = TimeLog() 210 local.open(deviceID: "alice-phone", at: at(500)) 211 // Inbound sibling copy: a stale view of my phone (closed) plus my pad's work. 212 let inbound = TimeLog(devices: [ 213 "alice-phone": .init(intervals: [.init(start: at(0), end: at(50))]), 214 "alice-pad": .init(intervals: [.init(start: at(100), end: at(200))]), 215 ]) 216 local.merge(inbound: inbound, preservingDevice: "alice-phone") 217 // My phone's live open session is kept; the pad slot is adopted; the stale 218 // phone history from the sibling is discarded in favour of my own slot. 219 #expect(local.devices["alice-phone"]?.openStart == at(500)) 220 #expect(local.devices["alice-pad"]?.intervals.count == 1) 221 } 222 223 // MARK: - Codec tolerance 224 225 @Test("Decoding nil or empty data yields an empty log") 226 func decodeToleratesEmpty() { 227 #expect(TimeLog.decode(nil).devices.isEmpty) 228 #expect(TimeLog.decode(Data()).devices.isEmpty) 229 } 230 231 @Test("Encode then decode round-trips") 232 func codecRoundTrips() { 233 var log = TimeLog() 234 log.open(deviceID: "alice-phone", at: at(0)) 235 log.seal(deviceID: "alice-phone", at: at(120)) 236 let restored = TimeLog.decode(TimeLog.encode(log)) 237 #expect(restored == log) 238 } 239 240 @Test("An empty game has a zero clock") 241 func emptyIsZero() { 242 #expect(TimeLog.accumulatedSeconds(forLogs: [], localDeviceID: "x") == 0) 243 #expect(TimeLog.accumulatedSeconds(forLogs: [TimeLog()], localDeviceID: "x") == 0) 244 } 245 }