crossmate

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

GameSummaryThumbnailTests.swift (7284B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// Pins down `GameSummary`'s thumbnail derivation. An in-progress game's
      8 /// thumbnail mirrors the `CellEntity` cache, but a completed game is terminal
      9 /// and always renders solved (restore seals it to the solution), so its
     10 /// thumbnail must come from the completion latch — the raw merge behind the
     11 /// cache can permanently lack the winning letter when a clear stamped after
     12 /// the latch wins LWW.
     13 @Suite("GameSummary thumbnail")
     14 @MainActor
     15 struct GameSummaryThumbnailTests {
     16 
     17     private static let puzzleSource = """
     18     Title: Test Puzzle
     19     Author: Test
     20 
     21 
     22     ABC
     23     D#E
     24     FGH
     25 
     26 
     27     A1. Across 1 ~ ABC
     28     A4. Across 4 ~ DE
     29     A5. Across 5 ~ FGH
     30     D1. Down 1 ~ ADF
     31     D2. Down 2 ~ BG
     32     D3. Down 3 ~ CEH
     33     """
     34 
     35     private func makeGame(in ctx: NSManagedObjectContext) throws -> GameEntity {
     36         let xd = try XD.parse(Self.puzzleSource)
     37         let puzzle = Puzzle(xd: xd)
     38 
     39         let entity = GameEntity(context: ctx)
     40         entity.id = UUID()
     41         entity.title = "Thumbnail"
     42         entity.puzzleSource = Self.puzzleSource
     43         entity.createdAt = Date()
     44         entity.updatedAt = Date()
     45         entity.populateCachedSummaryFields(from: puzzle)
     46         return entity
     47     }
     48 
     49     private func addCell(
     50         _ letter: String,
     51         row: Int16,
     52         col: Int16,
     53         to entity: GameEntity,
     54         in ctx: NSManagedObjectContext,
     55         authorID: String? = nil,
     56         mark: CellMark = .none
     57     ) {
     58         let cell = CellEntity(context: ctx)
     59         cell.game = entity
     60         cell.row = row
     61         cell.col = col
     62         cell.letter = letter
     63         cell.letterAuthorID = authorID
     64         cell.markCode = mark.code
     65     }
     66 
     67     private func addPlayer(
     68         authorID: String,
     69         name: String,
     70         to entity: GameEntity,
     71         in ctx: NSManagedObjectContext
     72     ) {
     73         let player = PlayerEntity(context: ctx)
     74         player.game = entity
     75         player.authorID = authorID
     76         player.name = name
     77         player.ckRecordName = "player-\(authorID)"
     78         player.updatedAt = Date()
     79     }
     80 
     81     private func addMoves(
     82         authorID: String,
     83         to entity: GameEntity,
     84         in ctx: NSManagedObjectContext
     85     ) {
     86         let moves = MovesEntity(context: ctx)
     87         moves.game = entity
     88         moves.authorID = authorID
     89         moves.deviceID = "device-\(authorID)"
     90         moves.cells = Data()
     91         moves.updatedAt = Date()
     92         moves.ckRecordName = "moves-\(authorID)"
     93     }
     94 
     95     @Test("In-progress thumbnail mirrors the CellEntity cache")
     96     func inProgressThumbnailMirrorsCache() throws {
     97         let persistence = makeTestPersistence()
     98         let ctx = persistence.viewContext
     99         let entity = try makeGame(in: ctx)
    100         addCell("A", row: 0, col: 0, to: entity, in: ctx)
    101         addCell("", row: 2, col: 2, to: entity, in: ctx)
    102         try ctx.save()
    103 
    104         let summary = try #require(GameSummary(entity: entity))
    105         #expect(summary.thumbnailCells == [
    106             .filled, .empty, .empty,
    107             .empty, .block, .empty,
    108             .empty, .empty, .empty,
    109         ])
    110     }
    111 
    112     @Test("Completed thumbnail is full even when the cache has a hole")
    113     func completedThumbnailIgnoresCacheHoles() throws {
    114         let persistence = makeTestPersistence()
    115         let ctx = persistence.viewContext
    116         let entity = try makeGame(in: ctx)
    117         // The winning letter never reached the durable merge: its cell holds
    118         // an empty tombstone and another square has no row at all.
    119         addCell("A", row: 0, col: 0, to: entity, in: ctx)
    120         addCell("", row: 2, col: 2, to: entity, in: ctx)
    121         entity.completedAt = Date()
    122         try ctx.save()
    123 
    124         let summary = try #require(GameSummary(entity: entity))
    125         #expect(summary.thumbnailCells == [
    126             .filled, .filled, .filled,
    127             .filled, .block, .filled,
    128             .filled, .filled, .filled,
    129         ])
    130     }
    131 
    132     @Test("Shared summary lists remote participants with deterministic colours")
    133     func sharedSummaryListsRemoteParticipants() throws {
    134         let persistence = makeTestPersistence()
    135         let ctx = persistence.viewContext
    136         let entity = try makeGame(in: ctx)
    137         entity.ckShareRecordName = "cloudkit.zoneshare"
    138         addPlayer(authorID: "_Local", name: "Local", to: entity, in: ctx)
    139         addPlayer(authorID: "_Alice", name: "Alice", to: entity, in: ctx)
    140         addMoves(authorID: "_Bob", to: entity, in: ctx)
    141 
    142         let friend = FriendEntity(context: ctx)
    143         friend.authorID = "_Alice"
    144         friend.createdAt = Date()
    145         friend.databaseScope = 0
    146         friend.displayName = "Alice"
    147         friend.displayNameVersion = 0
    148         friend.friendZoneName = "friend-zone"
    149         friend.friendZoneOwnerName = "_Local"
    150         friend.isBlocked = false
    151         friend.nickname = "Ace"
    152         friend.nicknameVersion = 1
    153         friend.pairKey = "pair"
    154         try ctx.save()
    155 
    156         let summary = try #require(GameSummary(
    157             entity: entity,
    158             localAuthorID: "_Local",
    159             localName: "Local Player",
    160             localColor: .blue
    161         ))
    162 
    163         #expect(summary.allParticipants.map(\.authorID) == ["_Local", "_Alice", "_Bob"])
    164         #expect(summary.allParticipants.map(\.name) == ["Local Player", "Ace", "Waiting for player..."])
    165         #expect(summary.allParticipants.map(\.isLocal) == [true, false, false])
    166         let remoteParticipants = summary.allParticipants.filter { !$0.isLocal }
    167         // Collaborators are de-conflicted within the game, so the two remotes
    168         // get distinct colours and neither collides with the local 'blue'.
    169         #expect(!remoteParticipants.map(\.color.id).contains(PlayerColor.blue.id))
    170         #expect(Set(remoteParticipants.map(\.color.id)).count == 2)
    171     }
    172 
    173     @Test("Shared strip includes local player and orders by score")
    174     func sharedStripIncludesLocalPlayerAndOrdersByScore() throws {
    175         let persistence = makeTestPersistence()
    176         let ctx = persistence.viewContext
    177         let entity = try makeGame(in: ctx)
    178         entity.ckShareRecordName = "cloudkit.zoneshare"
    179         addPlayer(authorID: "_Local", name: "Local", to: entity, in: ctx)
    180         addPlayer(authorID: "_Alice", name: "Alice", to: entity, in: ctx)
    181         addPlayer(authorID: "_Bob", name: "Bob", to: entity, in: ctx)
    182         addCell("A", row: 0, col: 0, to: entity, in: ctx, authorID: "_Alice")
    183         addCell("B", row: 0, col: 1, to: entity, in: ctx, authorID: "_Local")
    184         addCell("C", row: 0, col: 2, to: entity, in: ctx, authorID: "_Bob")
    185         addCell("D", row: 1, col: 0, to: entity, in: ctx, authorID: "_Alice")
    186         addCell("F", row: 2, col: 0, to: entity, in: ctx, authorID: "_Bob", mark: .revealed)
    187         try ctx.save()
    188 
    189         let summary = try #require(GameSummary(
    190             entity: entity,
    191             localAuthorID: "_Local",
    192             localName: "Local Player",
    193             localColor: .blue
    194         ))
    195 
    196         #expect(summary.stripParticipants.map(\.authorID) == ["_Alice", "_Bob", "_Local"])
    197         #expect(summary.stripParticipants.map(\.isLocal) == [false, false, true])
    198         #expect(summary.stripParticipants.map(\.color.id).contains(PlayerColor.blue.id))
    199     }
    200 }