crossmate

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

GameStoreMergedAuthorCellsTests.swift (5156B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// `GameStore.mergedAuthorCells` is the data source for the per-recipient
      8 /// pause-push diff: each touched grid position's winning `TimestampedCell`
      9 /// after LWW-merging the author's MovesEntities across devices, including
     10 /// cleared cells whose latest write left them empty.
     11 @Suite("GameStore.mergedAuthorCells", .isolatedNotificationState)
     12 @MainActor
     13 struct GameStoreMergedAuthorCellsTests {
     14 
     15     private static let authorID = "alice"
     16     private static let puzzleSource = """
     17     Title: Test Puzzle
     18     Author: Test
     19 
     20 
     21     AB
     22     CD
     23     """
     24 
     25     private func makeGame(in ctx: NSManagedObjectContext) throws -> (GameEntity, UUID) {
     26         let gameID = UUID()
     27         let entity = GameEntity(context: ctx)
     28         entity.id = gameID
     29         entity.title = "Test"
     30         entity.puzzleSource = Self.puzzleSource
     31         entity.createdAt = Date()
     32         entity.updatedAt = Date()
     33         entity.ckRecordName = "game-\(gameID.uuidString)"
     34         entity.ckZoneName = "game-\(gameID.uuidString)"
     35         entity.databaseScope = 1
     36         try ctx.save()
     37         return (entity, gameID)
     38     }
     39 
     40     private func addMoves(
     41         for entity: GameEntity,
     42         gameID: UUID,
     43         deviceID: String,
     44         cells: [GridPosition: TimestampedCell],
     45         updatedAt: Date,
     46         in ctx: NSManagedObjectContext
     47     ) throws {
     48         let row = MovesEntity(context: ctx)
     49         row.game = entity
     50         row.authorID = Self.authorID
     51         row.deviceID = deviceID
     52         row.cells = try MovesCodec.encode(cells)
     53         row.updatedAt = updatedAt
     54         row.ckRecordName = RecordSerializer.recordName(
     55             forMovesInGame: gameID,
     56             authorID: Self.authorID,
     57             deviceID: deviceID
     58         )
     59         try ctx.save()
     60     }
     61 
     62     private func cell(
     63         letter: String,
     64         at updatedAt: Date
     65     ) -> TimestampedCell {
     66         TimestampedCell(
     67             letter: letter,
     68             mark: .none,
     69             updatedAt: updatedAt,
     70             authorID: Self.authorID
     71         )
     72     }
     73 
     74     @Test("Returns an empty list when the author has no Moves rows")
     75     func emptyWhenNoMoves() throws {
     76         let persistence = makeTestPersistence()
     77         let store = makeTestStore(persistence: persistence)
     78         let (_, gameID) = try makeGame(in: persistence.viewContext)
     79 
     80         let cells = store.mergedAuthorCells(for: gameID, by: Self.authorID)
     81 
     82         #expect(cells.isEmpty)
     83     }
     84 
     85     @Test("Later-timestamped write across devices wins for the same position")
     86     func laterWriteWins() throws {
     87         let persistence = makeTestPersistence()
     88         let store = makeTestStore(persistence: persistence)
     89         let (entity, gameID) = try makeGame(in: persistence.viewContext)
     90         let earlier = Date(timeIntervalSince1970: 1_000)
     91         let later = Date(timeIntervalSince1970: 2_000)
     92 
     93         try addMoves(
     94             for: entity,
     95             gameID: gameID,
     96             deviceID: "ipad",
     97             cells: [GridPosition(row: 0, col: 0): cell(letter: "A", at: earlier)],
     98             updatedAt: earlier,
     99             in: persistence.viewContext
    100         )
    101         try addMoves(
    102             for: entity,
    103             gameID: gameID,
    104             deviceID: "iphone",
    105             cells: [GridPosition(row: 0, col: 0): cell(letter: "B", at: later)],
    106             updatedAt: later,
    107             in: persistence.viewContext
    108         )
    109 
    110         let cells = store.mergedAuthorCells(for: gameID, by: Self.authorID)
    111 
    112         #expect(cells.count == 1)
    113         let winner = try #require(cells.first)
    114         #expect(winner.letter == "B")
    115         #expect(winner.updatedAt == later)
    116     }
    117 
    118     @Test("Cleared cells (empty letter) are retained so the diff can count them")
    119     func clearedCellsRetained() throws {
    120         let persistence = makeTestPersistence()
    121         let store = makeTestStore(persistence: persistence)
    122         let (entity, gameID) = try makeGame(in: persistence.viewContext)
    123         let early = Date(timeIntervalSince1970: 1_000)
    124         let later = Date(timeIntervalSince1970: 2_000)
    125 
    126         try addMoves(
    127             for: entity,
    128             gameID: gameID,
    129             deviceID: "ipad",
    130             cells: [
    131                 GridPosition(row: 0, col: 0): cell(letter: "A", at: early),
    132                 GridPosition(row: 0, col: 1): cell(letter: "B", at: early)
    133             ],
    134             updatedAt: early,
    135             in: persistence.viewContext
    136         )
    137         // Later write on a sibling device clears (0, 1).
    138         try addMoves(
    139             for: entity,
    140             gameID: gameID,
    141             deviceID: "iphone",
    142             cells: [GridPosition(row: 0, col: 1): cell(letter: "", at: later)],
    143             updatedAt: later,
    144             in: persistence.viewContext
    145         )
    146 
    147         let cells = store.mergedAuthorCells(for: gameID, by: Self.authorID)
    148 
    149         #expect(cells.count == 2)
    150         let cleared = cells.first { $0.letter.isEmpty }
    151         let filled = cells.first { !$0.letter.isEmpty }
    152         #expect(cleared?.updatedAt == later)
    153         #expect(filled?.letter == "A")
    154         #expect(filled?.updatedAt == early)
    155     }
    156 }