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 }