crossmate

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

PendingEditFlagTests.swift (8655B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// Pins down the disappearing-letter race. While a typed letter is buffered
      8 /// in `MovesUpdater` (not yet in `MovesEntity`), `Square.enqueuedAt` is set
      9 /// and `GameStore.restore` must not revert the cell from the merged grid on a
     10 /// remote refresh. The flag is retired by `restore` itself, once the local
     11 /// device's row shows the cell at a timestamp >= the flag.
     12 @Suite("Pending-edit flag", .serialized)
     13 @MainActor
     14 struct PendingEditFlagTests {
     15 
     16     private static let localAuthorID = "local-author"
     17     private static let otherAuthorID = "other-author"
     18 
     19     private static let puzzleSource = """
     20     Title: Test Puzzle
     21     Author: Test
     22 
     23 
     24     ABC
     25     D#E
     26     FGH
     27 
     28 
     29     A1. Across 1 ~ ABC
     30     A4. Across 4 ~ DE
     31     A5. Across 5 ~ FGH
     32     D1. Down 1 ~ ADF
     33     D2. Down 2 ~ BG
     34     D3. Down 3 ~ CEH
     35     """
     36 
     37     private func seedGame(in ctx: NSManagedObjectContext) throws -> (GameEntity, UUID) {
     38         let gameID = UUID()
     39         let xd = try XD.parse(Self.puzzleSource)
     40         let puzzle = Puzzle(xd: xd)
     41         let entity = GameEntity(context: ctx)
     42         entity.id = gameID
     43         entity.title = "Test"
     44         entity.puzzleSource = Self.puzzleSource
     45         entity.createdAt = Date()
     46         entity.updatedAt = Date()
     47         entity.ckRecordName = "game-\(gameID.uuidString)"
     48         entity.populateCachedSummaryFields(from: puzzle)
     49         try ctx.save()
     50         return (entity, gameID)
     51     }
     52 
     53     /// Upserts a `MovesEntity` row for `(authorID, deviceID)` carrying a
     54     /// single cell at (0,0).
     55     private func writeMovesRow(
     56         for entity: GameEntity,
     57         gameID: UUID,
     58         authorID: String,
     59         deviceID: String,
     60         letter: String,
     61         updatedAt: Date,
     62         in ctx: NSManagedObjectContext
     63     ) throws {
     64         let recordName = RecordSerializer.recordName(
     65             forMovesInGame: gameID,
     66             authorID: authorID,
     67             deviceID: deviceID
     68         )
     69         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
     70         req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
     71         req.fetchLimit = 1
     72         let row = (try? ctx.fetch(req).first) ?? MovesEntity(context: ctx)
     73         row.game = entity
     74         row.authorID = authorID
     75         row.deviceID = deviceID
     76         row.ckRecordName = recordName
     77         let cells: [GridPosition: TimestampedCell] = [
     78             GridPosition(row: 0, col: 0): TimestampedCell(
     79                 letter: letter,
     80                 mark: .pen(checked: nil),
     81                 updatedAt: updatedAt,
     82                 authorID: authorID
     83             )
     84         ]
     85         row.cells = try MovesCodec.encode(cells)
     86         row.updatedAt = updatedAt
     87         try ctx.save()
     88     }
     89 
     90     private func makeStore(_ persistence: PersistenceController) -> GameStore {
     91         makeTestStore(
     92             persistence: persistence,
     93             authorIDProvider: { Self.localAuthorID }
     94         )
     95     }
     96 
     97     @Test("Remote refresh during the debounce window keeps the typed letter")
     98     func remoteRefreshKeepsBufferedLetter() throws {
     99         let persistence = makeTestPersistence()
    100         let store = makeStore(persistence)
    101         let ctx = persistence.viewContext
    102         let (entity, gameID) = try seedGame(in: ctx)
    103 
    104         let t0 = Date(timeIntervalSinceNow: -10)
    105         try writeMovesRow(
    106             for: entity, gameID: gameID,
    107             authorID: Self.localAuthorID,
    108             deviceID: RecordSerializer.localDeviceID,
    109             letter: "A", updatedAt: t0, in: ctx
    110         )
    111 
    112         let (game, _) = try store.loadGame(id: gameID)
    113         #expect(game.squares[0][0].entry == "A")
    114 
    115         // User types "B" over "A". GameMutator stamps the flag synchronously;
    116         // the buffered edit has NOT reached MovesEntity yet (still "A"@t0).
    117         let t1 = Date()
    118         game.squares[0][0].entry = "B"
    119         game.squares[0][0].enqueuedAt = t1
    120 
    121         // An inbound remote refresh re-runs restore against the merged grid,
    122         // which still resolves to "A".
    123         store.refreshCurrentGame()
    124 
    125         #expect(game.squares[0][0].entry == "B")
    126         #expect(game.squares[0][0].enqueuedAt == t1)
    127     }
    128 
    129     @Test("Flag retires once the edit lands in the local row")
    130     func flagRetiresAfterFlush() throws {
    131         let persistence = makeTestPersistence()
    132         let store = makeStore(persistence)
    133         let ctx = persistence.viewContext
    134         let (entity, gameID) = try seedGame(in: ctx)
    135 
    136         let t0 = Date(timeIntervalSinceNow: -10)
    137         try writeMovesRow(
    138             for: entity, gameID: gameID,
    139             authorID: Self.localAuthorID,
    140             deviceID: RecordSerializer.localDeviceID,
    141             letter: "A", updatedAt: t0, in: ctx
    142         )
    143 
    144         let (game, _) = try store.loadGame(id: gameID)
    145 
    146         let t1 = Date()
    147         game.squares[0][0].entry = "B"
    148         game.squares[0][0].enqueuedAt = t1
    149 
    150         // Simulate the MovesUpdater flush: "B" lands in the local row with
    151         // updatedAt == the flag's timestamp.
    152         try writeMovesRow(
    153             for: entity, gameID: gameID,
    154             authorID: Self.localAuthorID,
    155             deviceID: RecordSerializer.localDeviceID,
    156             letter: "B", updatedAt: t1, in: ctx
    157         )
    158 
    159         store.refreshCurrentGame()
    160 
    161         #expect(game.squares[0][0].entry == "B")
    162         #expect(game.squares[0][0].enqueuedAt == nil)
    163     }
    164 
    165     @Test("A newer same-cell edit is not retired by an older landed value")
    166     func newerEditNotRetiredByOlderLanded() throws {
    167         let persistence = makeTestPersistence()
    168         let store = makeStore(persistence)
    169         let ctx = persistence.viewContext
    170         let (entity, gameID) = try seedGame(in: ctx)
    171 
    172         let t0 = Date(timeIntervalSinceNow: -10)
    173         try writeMovesRow(
    174             for: entity, gameID: gameID,
    175             authorID: Self.localAuthorID,
    176             deviceID: RecordSerializer.localDeviceID,
    177             letter: "A", updatedAt: t0, in: ctx
    178         )
    179 
    180         let (game, _) = try store.loadGame(id: gameID)
    181 
    182         // "B" typed and flushed (lands at t1)...
    183         let t1 = Date(timeIntervalSinceNow: -1)
    184         try writeMovesRow(
    185             for: entity, gameID: gameID,
    186             authorID: Self.localAuthorID,
    187             deviceID: RecordSerializer.localDeviceID,
    188             letter: "B", updatedAt: t1, in: ctx
    189         )
    190         // ...then the user re-types "C" before the next flush. The flag now
    191         // carries t2 > t1; the local row is still at "B"@t1.
    192         let t2 = Date()
    193         game.squares[0][0].entry = "C"
    194         game.squares[0][0].enqueuedAt = t2
    195 
    196         store.refreshCurrentGame()
    197 
    198         // t1 >= t2 is false, so the flag must not retire and "C" must stay.
    199         #expect(game.squares[0][0].entry == "C")
    200         #expect(game.squares[0][0].enqueuedAt == t2)
    201     }
    202 
    203     @Test("Diverged peer write converges to the LWW winner after flush")
    204     func divergesThenConvergesToLWWWinner() throws {
    205         let persistence = makeTestPersistence()
    206         let store = makeStore(persistence)
    207         let ctx = persistence.viewContext
    208         let (entity, gameID) = try seedGame(in: ctx)
    209 
    210         let t0 = Date(timeIntervalSinceNow: -10)
    211         try writeMovesRow(
    212             for: entity, gameID: gameID,
    213             authorID: Self.localAuthorID,
    214             deviceID: RecordSerializer.localDeviceID,
    215             letter: "A", updatedAt: t0, in: ctx
    216         )
    217 
    218         let (game, _) = try store.loadGame(id: gameID)
    219 
    220         // Local user types "B" (buffered, not landed).
    221         let t1 = Date(timeIntervalSinceNow: -1)
    222         game.squares[0][0].entry = "B"
    223         game.squares[0][0].enqueuedAt = t1
    224 
    225         // A peer writes "Z" with a newer timestamp; it arrives via fetch.
    226         let t2 = Date()
    227         try writeMovesRow(
    228             for: entity, gameID: gameID,
    229             authorID: Self.otherAuthorID,
    230             deviceID: "device-other",
    231             letter: "Z", updatedAt: t2, in: ctx
    232         )
    233 
    234         store.refreshCurrentGame()
    235         // Diverged: the still-buffered local edit wins on screen.
    236         #expect(game.squares[0][0].entry == "B")
    237 
    238         // Local "B" flushes (lands at t1). Next refresh retires the flag and
    239         // adopts the merged grid; peer "Z"@t2 beats local "B"@t1.
    240         try writeMovesRow(
    241             for: entity, gameID: gameID,
    242             authorID: Self.localAuthorID,
    243             deviceID: RecordSerializer.localDeviceID,
    244             letter: "B", updatedAt: t1, in: ctx
    245         )
    246         store.refreshCurrentGame()
    247 
    248         #expect(game.squares[0][0].entry == "Z")
    249         #expect(game.squares[0][0].enqueuedAt == nil)
    250     }
    251 }