crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }