crossmate

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

MoveBufferTests.swift (13612B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("MoveBuffer", .serialized)
      8 @MainActor
      9 struct MoveBufferTests {
     10 
     11     /// Thread-safe collector for moves emitted by the buffer under test.
     12     actor Capture {
     13         private(set) var flushes: [[Move]] = []
     14         var allMoves: [Move] { flushes.flatMap { $0 } }
     15         var flushCount: Int { flushes.count }
     16         func append(_ moves: [Move]) { flushes.append(moves) }
     17     }
     18 
     19     /// Builds a `PersistenceController` backed by an in-memory store and
     20     /// seeds a single `GameEntity`, returning its id so tests can target it.
     21     private func makePersistenceWithGame(
     22         lamportHighWater: Int64 = 0
     23     ) throws -> (PersistenceController, UUID) {
     24         let persistence = makeTestPersistence()
     25         let context = persistence.viewContext
     26         let gameID = UUID()
     27         let entity = GameEntity(context: context)
     28         entity.id = gameID
     29         entity.title = "Test"
     30         entity.puzzleSource = ""
     31         entity.createdAt = Date()
     32         entity.updatedAt = Date()
     33         entity.ckRecordName = "game-\(gameID.uuidString)"
     34         entity.lamportHighWater = lamportHighWater
     35         try context.save()
     36         return (persistence, gameID)
     37     }
     38 
     39     @Test("Same-cell enqueues coalesce to one move carrying the latest value")
     40     func coalescesSameCell() async throws {
     41         let (persistence, gameID) = try makePersistenceWithGame()
     42         let capture = Capture()
     43         let buffer = MoveBuffer(
     44             debounceInterval: .seconds(10),
     45             persistence: persistence,
     46             sink: { await capture.append($0) }
     47         )
     48 
     49         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
     50         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: nil)
     51         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedWrong: false, authorID: nil)
     52         await buffer.flush()
     53 
     54         let moves = await capture.allMoves
     55         #expect(moves.count == 1)
     56         #expect(moves.first?.letter == "C")
     57         #expect(moves.first?.lamport == 1)
     58     }
     59 
     60     @Test("Enqueuing a different cell persists and enqueues the previous cell")
     61     func cellChangeFlushesPrevious() async throws {
     62         // A long debounce makes this test insensitive to timer jitter:
     63         // only the cell-change trigger (and the final explicit flush) can
     64         // fire.
     65         let (persistence, gameID) = try makePersistenceWithGame()
     66         let capture = Capture()
     67         let buffer = MoveBuffer(
     68             debounceInterval: .seconds(10),
     69             persistence: persistence,
     70             sink: { await capture.append($0) }
     71         )
     72 
     73         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
     74         await buffer.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: nil)
     75 
     76         // The cell-change flush allocates Lamport 1, writes the prior cell
     77         // durably, and immediately hands it to the sink.
     78         #expect(await capture.flushCount == 1)
     79         let persistedBeforeFinalFlush = fetchMoveValues(gameID: gameID, persistence: persistence)
     80         #expect(persistedBeforeFinalFlush.count == 1)
     81         #expect(persistedBeforeFinalFlush.first?.letter == "A")
     82         #expect(persistedBeforeFinalFlush.first?.lamport == 1)
     83 
     84         await buffer.flush()
     85 
     86         let flushes = await capture.flushes
     87         #expect(flushes.count == 2)
     88         #expect(flushes.map { $0.map(\.letter) } == [["A"], ["B"]])
     89         #expect(flushes.map { $0.map(\.lamport) } == [[1], [2]])
     90     }
     91 
     92     @Test("Lamports are allocated from GameEntity.lamportHighWater and bump it")
     93     func lamportsUseGameHighWater() async throws {
     94         let (persistence, gameID) = try makePersistenceWithGame(lamportHighWater: 10)
     95         let capture = Capture()
     96         let buffer = MoveBuffer(
     97             debounceInterval: .seconds(10),
     98             persistence: persistence,
     99             sink: { await capture.append($0) }
    100         )
    101 
    102         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil)
    103         await buffer.flush()
    104 
    105         let moves = await capture.allMoves
    106         #expect(moves.first?.lamport == 11)
    107 
    108         // Verify the bump landed in Core Data by reading the game back from
    109         // a fresh context — the writer used a background context, so we
    110         // need to read from the same underlying store.
    111         let highWater = fetchHighWater(gameID: gameID, persistence: persistence)
    112         #expect(highWater == 11)
    113     }
    114 
    115     @Test("Flushed moves bump the parent game's updatedAt timestamp")
    116     func flushedMovesUpdateGameTimestamp() async throws {
    117         let (persistence, gameID) = try makePersistenceWithGame()
    118         let before = try #require(fetchUpdatedAt(gameID: gameID, persistence: persistence))
    119         let buffer = MoveBuffer(
    120             debounceInterval: .seconds(10),
    121             persistence: persistence,
    122             sink: { _ in }
    123         )
    124 
    125         try await Task.sleep(for: .milliseconds(10))
    126         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil)
    127         await buffer.flush()
    128 
    129         let after = try #require(fetchUpdatedAt(gameID: gameID, persistence: persistence))
    130         #expect(after > before)
    131     }
    132 
    133     @Test("Debounce coalesces rapid same-cell enqueues into one flush")
    134     func debounceCoalescesRapidEnqueues() async throws {
    135         let (persistence, gameID) = try makePersistenceWithGame()
    136         let capture = Capture()
    137         let buffer = MoveBuffer(
    138             debounceInterval: .milliseconds(80),
    139             persistence: persistence,
    140             sink: { await capture.append($0) }
    141         )
    142 
    143         for letter in ["A", "B", "C", "D", "E"] {
    144             await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: letter, markKind: 0, checkedWrong: false, authorID: nil)
    145             try await Task.sleep(for: .milliseconds(20))
    146         }
    147         try await Task.sleep(for: .milliseconds(250))
    148 
    149         let count = await capture.flushCount
    150         #expect(count == 1)
    151         let moves = await capture.allMoves
    152         #expect(moves.first?.letter == "E")
    153     }
    154 
    155     @Test("Explicit flush fires immediately and cancels any pending debounce")
    156     func flushCancelsDebounce() async throws {
    157         let (persistence, gameID) = try makePersistenceWithGame()
    158         let capture = Capture()
    159         let buffer = MoveBuffer(
    160             debounceInterval: .seconds(5),
    161             persistence: persistence,
    162             sink: { await capture.append($0) }
    163         )
    164 
    165         await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil)
    166         await buffer.flush()
    167         let afterFlush = await capture.flushCount
    168         #expect(afterFlush == 1)
    169 
    170         // The debounce should have been cancelled by flush, so waiting past
    171         // the original interval must not add a second call.
    172         try await Task.sleep(for: .milliseconds(200))
    173         let later = await capture.flushCount
    174         #expect(later == 1)
    175     }
    176 
    177     @Test("Flush on an empty buffer does not invoke the sink")
    178     func emptyFlushDoesNothing() async throws {
    179         let (persistence, _) = try makePersistenceWithGame()
    180         let capture = Capture()
    181         let buffer = MoveBuffer(
    182             debounceInterval: .milliseconds(50),
    183             persistence: persistence,
    184             sink: { await capture.append($0) }
    185         )
    186 
    187         await buffer.flush()
    188 
    189         let count = await capture.flushCount
    190         #expect(count == 0)
    191     }
    192 
    193     @Test("MoveEntity rows are written with the enqueued fields")
    194     func persistsMoveEntity() async throws {
    195         let (persistence, gameID) = try makePersistenceWithGame()
    196         let buffer = MoveBuffer(
    197             debounceInterval: .seconds(10),
    198             persistence: persistence,
    199             sink: { _ in }
    200         )
    201 
    202         await buffer.enqueue(
    203             gameID: gameID, row: 2, col: 3,
    204             letter: "Q", markKind: 1, checkedWrong: true, authorID: "alice"
    205         )
    206         await buffer.flush()
    207 
    208         let moves = fetchMoveValues(gameID: gameID, persistence: persistence)
    209         #expect(moves.count == 1)
    210         let m = moves.first
    211         #expect(m?.letter == "Q")
    212         #expect(m?.row == 2)
    213         #expect(m?.col == 3)
    214         #expect(m?.markKind == 1)
    215         #expect(m?.checkedWrong == true)
    216         #expect(m?.authorID == "alice")
    217         #expect(m?.lamport == 1)
    218         #expect(m?.ckRecordName == "move-\(gameID.uuidString)-1-\(RecordSerializer.localDeviceID)")
    219     }
    220 
    221     @Test("Flush updates cell cache with the latest local cell values")
    222     func flushUpdatesCellCache() async throws {
    223         let (persistence, gameID) = try makePersistenceWithGame()
    224         let buffer = MoveBuffer(
    225             debounceInterval: .seconds(10),
    226             persistence: persistence,
    227             sink: { _ in }
    228         )
    229 
    230         await buffer.enqueue(
    231             gameID: gameID,
    232             row: 2,
    233             col: 3,
    234             letter: "Q",
    235             markKind: 1,
    236             checkedWrong: true,
    237             authorID: "alice"
    238         )
    239         await buffer.enqueue(
    240             gameID: gameID,
    241             row: 2,
    242             col: 3,
    243             letter: "R",
    244             markKind: 2,
    245             checkedWrong: false,
    246             authorID: "bob"
    247         )
    248         await buffer.flush()
    249 
    250         let cells = fetchCellValues(gameID: gameID, persistence: persistence)
    251         #expect(cells.count == 1)
    252         let cell = cells.first
    253         #expect(cell?.row == 2)
    254         #expect(cell?.col == 3)
    255         #expect(cell?.letter == "R")
    256         #expect(cell?.markKind == 2)
    257         #expect(cell?.checkedWrong == false)
    258         #expect(cell?.authorID == "bob")
    259     }
    260 
    261     // MARK: - Helpers
    262 
    263     /// Reads the game's lamport high-water from a fresh background context.
    264     private func fetchHighWater(gameID: UUID, persistence: PersistenceController) -> Int64 {
    265         let context = persistence.container.newBackgroundContext()
    266         return context.performAndWait {
    267             let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    268             request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    269             request.fetchLimit = 1
    270             return (try? context.fetch(request).first?.lamportHighWater) ?? 0
    271         }
    272     }
    273 
    274     private func fetchUpdatedAt(gameID: UUID, persistence: PersistenceController) -> Date? {
    275         let context = persistence.container.newBackgroundContext()
    276         return context.performAndWait {
    277             let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    278             request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    279             request.fetchLimit = 1
    280             return try? context.fetch(request).first?.updatedAt
    281         }
    282     }
    283 
    284     /// Extracts `MoveEntity` field values inside the background context so
    285     /// no `NSManagedObject` escapes its owning context.
    286     struct MoveValues {
    287         let letter: String
    288         let row: Int16
    289         let col: Int16
    290         let markKind: Int16
    291         let checkedWrong: Bool
    292         let authorID: String?
    293         let lamport: Int64
    294         let ckRecordName: String?
    295     }
    296 
    297     struct CellValues {
    298         let letter: String
    299         let row: Int16
    300         let col: Int16
    301         let markKind: Int16
    302         let checkedWrong: Bool
    303         let authorID: String?
    304     }
    305 
    306     private func fetchMoveValues(gameID: UUID, persistence: PersistenceController) -> [MoveValues] {
    307         let context = persistence.container.newBackgroundContext()
    308         return context.performAndWait {
    309             let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
    310             request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
    311             request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)]
    312             guard let entities = try? context.fetch(request) else { return [] }
    313             return entities.map {
    314                 MoveValues(
    315                     letter: $0.letter ?? "",
    316                     row: $0.row,
    317                     col: $0.col,
    318                     markKind: $0.markKind,
    319                     checkedWrong: $0.checkedWrong,
    320                     authorID: $0.authorID,
    321                     lamport: $0.lamport,
    322                     ckRecordName: $0.ckRecordName
    323                 )
    324             }
    325         }
    326     }
    327 
    328     private func fetchCellValues(gameID: UUID, persistence: PersistenceController) -> [CellValues] {
    329         let context = persistence.container.newBackgroundContext()
    330         return context.performAndWait {
    331             let request = NSFetchRequest<CellEntity>(entityName: "CellEntity")
    332             request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
    333             request.sortDescriptors = [
    334                 NSSortDescriptor(key: "row", ascending: true),
    335                 NSSortDescriptor(key: "col", ascending: true)
    336             ]
    337             guard let entities = try? context.fetch(request) else { return [] }
    338             return entities.map {
    339                 CellValues(
    340                     letter: $0.letter ?? "",
    341                     row: $0.row,
    342                     col: $0.col,
    343                     markKind: $0.markKind,
    344                     checkedWrong: $0.checkedWrong,
    345                     authorID: $0.letterAuthorID
    346                 )
    347             }
    348         }
    349     }
    350 }