GameStoreLibraryDatesTests.swift (8782B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// Covers the query behind the External browser's "already in your library" 8 /// calendar marker. A day is marked whenever the user holds that puzzle in any 9 /// form — live row or Chronicle — and not on the strength of a game the block 10 /// table has taken away from them. 11 @Suite("GameStore library dates") 12 @MainActor 13 struct GameStoreLibraryDatesTests { 14 15 private static func source(publisher: String, date: String) -> String { 16 """ 17 Title: Test Puzzle 18 Author: Test 19 Publisher: \(publisher) 20 Date: \(date) 21 22 23 ABC 24 D#E 25 FGH 26 27 28 A1. Across 1 ~ ABC 29 A4. Across 4 ~ DE 30 A5. Across 5 ~ FGH 31 D1. Down 1 ~ ADF 32 D2. Down 2 ~ BG 33 D3. Down 3 ~ CEH 34 """ 35 } 36 37 /// The NY-midnight Date that `XD` parses a `YYYY-MM-DD` header into, which 38 /// is also what the calendar grid produces for that day. 39 private static func nytDay(_ year: Int, _ month: Int, _ day: Int) -> Date { 40 var calendar = Calendar(identifier: .gregorian) 41 calendar.timeZone = TimeZone(identifier: "America/New_York")! 42 var comps = DateComponents() 43 comps.year = year 44 comps.month = month 45 comps.day = day 46 return calendar.date(from: comps)! 47 } 48 49 @discardableResult 50 private func addGame( 51 publisher: String = "New York Times", 52 date: String, 53 isHidden: Bool = false, 54 in ctx: NSManagedObjectContext 55 ) throws -> GameEntity { 56 let source = Self.source(publisher: publisher, date: date) 57 let puzzle = Puzzle(xd: try XD.parse(source)) 58 59 let gameID = UUID() 60 let entity = GameEntity(context: ctx) 61 entity.id = gameID 62 entity.title = puzzle.title 63 entity.puzzleSource = source 64 entity.createdAt = Date() 65 entity.updatedAt = Date() 66 entity.ckRecordName = "game-\(gameID.uuidString)" 67 entity.ckZoneName = "game-\(gameID.uuidString)" 68 entity.isHidden = isHidden 69 entity.populateCachedSummaryFields(from: puzzle) 70 try ctx.save() 71 return entity 72 } 73 74 @Test("Stored NYT dates match the calendar's day values") 75 func returnsStoredNYTDates() throws { 76 let persistence = makeTestPersistence() 77 let store = makeTestStore(persistence: persistence) 78 try addGame(date: "2020-01-01", in: persistence.viewContext) 79 try addGame(date: "2024-07-04", in: persistence.viewContext) 80 81 let dates = store.nytPuzzleDatesInLibrary() 82 83 #expect(dates == [Self.nytDay(2020, 1, 1), Self.nytDay(2024, 7, 4)]) 84 } 85 86 @Test("A game hidden by the block table is not reported as held") 87 func excludesHiddenGames() throws { 88 let persistence = makeTestPersistence() 89 let store = makeTestStore(persistence: persistence) 90 try addGame(date: "2020-01-01", isHidden: true, in: persistence.viewContext) 91 92 #expect(store.nytPuzzleDatesInLibrary().isEmpty) 93 } 94 95 @Test("Puzzles from other publishers are excluded") 96 func excludesOtherPublishers() throws { 97 let persistence = makeTestPersistence() 98 let store = makeTestStore(persistence: persistence) 99 try addGame(publisher: "Crossmate", date: "2020-01-01", in: persistence.viewContext) 100 101 #expect(store.nytPuzzleDatesInLibrary().isEmpty) 102 } 103 104 @Test("The same publication date held twice collapses to one entry") 105 func deduplicatesRepeatedDates() throws { 106 let persistence = makeTestPersistence() 107 let store = makeTestStore(persistence: persistence) 108 try addGame(date: "2020-01-01", in: persistence.viewContext) 109 try addGame(date: "2020-01-01", in: persistence.viewContext) 110 111 #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2020, 1, 1)]) 112 } 113 114 // MARK: - Chronicle ledger 115 116 @discardableResult 117 private func addLedgerEntry( 118 publisher: String = "New York Times", 119 date: Date, 120 participants: String? = nil, 121 in ctx: NSManagedObjectContext 122 ) throws -> UUID { 123 let originalGameID = UUID() 124 ChronicleLedgerEntity.upsert( 125 originalGameID: originalGameID, 126 publisher: publisher, 127 puzzleDate: date, 128 participants: participants, 129 in: ctx 130 ) 131 try ctx.save() 132 return originalGameID 133 } 134 135 private func addBlockedFriend(authorID: String, in ctx: NSManagedObjectContext) throws { 136 let friend = FriendEntity(context: ctx) 137 friend.authorID = authorID 138 friend.pairKey = "pair-\(authorID)" 139 friend.createdAt = Date() 140 friend.isBlocked = true 141 try ctx.save() 142 } 143 144 @Test("A compacted puzzle with no local row is still reported as held") 145 func reportsLedgerOnlyDates() throws { 146 let persistence = makeTestPersistence() 147 let store = makeTestStore(persistence: persistence) 148 try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: persistence.viewContext) 149 150 #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)]) 151 } 152 153 @Test("A ledger entry for another publisher is excluded") 154 func excludesLedgerEntriesFromOtherPublishers() throws { 155 let persistence = makeTestPersistence() 156 let store = makeTestStore(persistence: persistence) 157 try addLedgerEntry( 158 publisher: "Crossmate", 159 date: Self.nytDay(2026, 7, 14), 160 in: persistence.viewContext 161 ) 162 163 #expect(store.nytPuzzleDatesInLibrary().isEmpty) 164 } 165 166 @Test("A ledger entry featuring a blocked collaborator is not reported as held") 167 func excludesLedgerEntriesWithBlockedParticipants() throws { 168 let persistence = makeTestPersistence() 169 let store = makeTestStore(persistence: persistence) 170 try addBlockedFriend(authorID: "_bob", in: persistence.viewContext) 171 try addLedgerEntry( 172 date: Self.nytDay(2026, 7, 14), 173 participants: "_alice,_bob", 174 in: persistence.viewContext 175 ) 176 177 #expect(store.nytPuzzleDatesInLibrary().isEmpty) 178 } 179 180 @Test("A ledger entry whose collaborators are unblocked is reported as held") 181 func includesLedgerEntriesWithUnblockedParticipants() throws { 182 let persistence = makeTestPersistence() 183 let store = makeTestStore(persistence: persistence) 184 try addBlockedFriend(authorID: "_carol", in: persistence.viewContext) 185 try addLedgerEntry( 186 date: Self.nytDay(2026, 7, 14), 187 participants: "_alice,_bob", 188 in: persistence.viewContext 189 ) 190 191 #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)]) 192 } 193 194 @Test("A date held as both a live row and a ledger entry collapses to one") 195 func deduplicatesAcrossRowAndLedger() throws { 196 let persistence = makeTestPersistence() 197 let store = makeTestStore(persistence: persistence) 198 try addGame(date: "2026-07-14", in: persistence.viewContext) 199 try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: persistence.viewContext) 200 201 #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)]) 202 } 203 204 @Test("Deleting an archived game drops its ledger entry") 205 func deleteGamePrunesLedgerEntry() throws { 206 let persistence = makeTestPersistence() 207 let store = makeTestStore(persistence: persistence) 208 let ctx = persistence.viewContext 209 let game = try addGame(date: "2026-07-14", in: ctx) 210 let gameID = try #require(game.id) 211 // What compaction leaves behind: an archived live row whose publication 212 // date is also recorded in the ledger. 213 game.archivedAt = Date() 214 ChronicleLedgerEntity.upsert( 215 originalGameID: gameID, 216 publisher: "New York Times", 217 puzzleDate: Self.nytDay(2026, 7, 14), 218 participants: nil, 219 in: ctx 220 ) 221 try ctx.save() 222 223 try store.deleteGame(id: gameID) 224 225 #expect(ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx).isEmpty) 226 #expect(store.nytPuzzleDatesInLibrary().isEmpty) 227 } 228 229 @Test("Reconciling against the archive zone drops entries whose Chronicle is gone") 230 func retainOnlyDropsMissingChronicles() throws { 231 let persistence = makeTestPersistence() 232 let ctx = persistence.viewContext 233 let kept = try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: ctx) 234 try addLedgerEntry(date: Self.nytDay(2026, 7, 15), in: ctx) 235 236 let removed = ChronicleLedgerEntity.retainOnly([kept], in: ctx) 237 try ctx.save() 238 239 #expect(removed == 1) 240 #expect(ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx) == [kept]) 241 } 242 }