crossmate

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

PersistenceControllerHealTests.swift (6829B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Covers the TEMPORARY v1.1 heal for `GameEntity` rows stranded at
      9 /// `databaseScope == 1` with the owner placeholder. Delete this file as one
     10 /// block with `PersistenceController.healStrandedSharedGameRows_v1`.
     11 @Suite("Stranded shared game row heal")
     12 @MainActor
     13 struct PersistenceControllerHealTests {
     14 
     15     private static let puzzleSource = """
     16     Title: Test Puzzle
     17     Author: Test
     18 
     19 
     20     AB
     21     CD
     22 
     23 
     24     A1. Top row ~ AB
     25     A3. Bottom row ~ CD
     26 
     27     D1. Left column ~ AC
     28     D2. Right column ~ BD
     29     """
     30 
     31     /// The shape the mis-scoped placeholder branch left behind: shared scope,
     32     /// owner placeholder, no puzzle.
     33     @discardableResult
     34     private func makeStranded(
     35         id: UUID,
     36         in ctx: NSManagedObjectContext
     37     ) -> GameEntity {
     38         let entity = GameEntity(context: ctx)
     39         entity.id = id
     40         entity.ckRecordName = "game-\(id.uuidString)"
     41         entity.ckZoneName = "game-\(id.uuidString)"
     42         entity.ckZoneOwnerName = CKCurrentUserDefaultName
     43         entity.databaseScope = 1
     44         entity.title = "Joining\u{2026}"
     45         entity.puzzleSource = ""
     46         entity.createdAt = .now
     47         entity.updatedAt = .now
     48         return entity
     49     }
     50 
     51     @discardableResult
     52     private func makeReal(
     53         id: UUID,
     54         in ctx: NSManagedObjectContext
     55     ) -> GameEntity {
     56         let entity = GameEntity(context: ctx)
     57         entity.id = id
     58         entity.ckRecordName = "game-\(id.uuidString)"
     59         entity.ckZoneName = "game-\(id.uuidString)"
     60         entity.ckZoneOwnerName = nil
     61         entity.databaseScope = 0
     62         entity.title = "Test Puzzle"
     63         entity.puzzleSource = Self.puzzleSource
     64         entity.createdAt = .now
     65         entity.updatedAt = .now
     66         return entity
     67     }
     68 
     69     @discardableResult
     70     private func makeMoves(
     71         recordName: String,
     72         on game: GameEntity,
     73         in ctx: NSManagedObjectContext
     74     ) -> MovesEntity {
     75         let moves = MovesEntity(context: ctx)
     76         moves.ckRecordName = recordName
     77         moves.authorID = "alice"
     78         moves.deviceID = "devicea"
     79         moves.cells = Data()
     80         moves.updatedAt = .now
     81         moves.game = game
     82         return moves
     83     }
     84 
     85     private func games(in ctx: NSManagedObjectContext, id: UUID) throws -> [GameEntity] {
     86         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     87         req.predicate = NSPredicate(format: "id == %@", id as CVarArg)
     88         return try ctx.fetch(req)
     89     }
     90 
     91     @Test("a stranded row is merged away when the real row exists")
     92     func mergesStrandedRowIntoSibling() throws {
     93         let persistence = makeTestPersistence()
     94         let ctx = persistence.viewContext
     95         let id = UUID()
     96         makeStranded(id: id, in: ctx)
     97         makeReal(id: id, in: ctx)
     98         try ctx.save()
     99         #expect(try games(in: ctx, id: id).count == 2)
    100 
    101         persistence.healStrandedSharedGameRows_v1(force: true)
    102         ctx.refreshAllObjects()
    103 
    104         let survivors = try games(in: ctx, id: id)
    105         #expect(survivors.count == 1)
    106         // The row that survives is the playable one — that is the whole point.
    107         #expect(survivors.first?.puzzleSource == Self.puzzleSource)
    108         #expect(survivors.first?.databaseScope == 0)
    109     }
    110 
    111     @Test("moves parented to the stranded row move across, without duplicating")
    112     func adoptsStrandedChildren() throws {
    113         let persistence = makeTestPersistence()
    114         let ctx = persistence.viewContext
    115         let id = UUID()
    116         let stranded = makeStranded(id: id, in: ctx)
    117         let real = makeReal(id: id, in: ctx)
    118         // One row only the stranded parent holds, and one both hold — the
    119         // latter is what a doubly-applied Moves record leaves behind.
    120         makeMoves(recordName: "moves-\(id.uuidString)-alice-devicea", on: stranded, in: ctx)
    121         makeMoves(recordName: "moves-\(id.uuidString)-bob-deviceb", on: stranded, in: ctx)
    122         makeMoves(recordName: "moves-\(id.uuidString)-bob-deviceb", on: real, in: ctx)
    123         try ctx.save()
    124 
    125         persistence.healStrandedSharedGameRows_v1(force: true)
    126         ctx.refreshAllObjects()
    127 
    128         let survivor = try #require(try games(in: ctx, id: id).first)
    129         let names = ((survivor.moves as? Set<MovesEntity>) ?? []).compactMap(\.ckRecordName)
    130         #expect(Set(names) == [
    131             "moves-\(id.uuidString)-alice-devicea",
    132             "moves-\(id.uuidString)-bob-deviceb",
    133         ])
    134         // The already-present row is not duplicated by the adoption.
    135         #expect(names.count == 2)
    136 
    137         let allMoves = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    138         #expect(try ctx.fetch(allMoves).count == 2)
    139     }
    140 
    141     @Test("a stranded row with no sibling is repaired, never deleted")
    142     func repairsLoneStrandedRow() throws {
    143         let persistence = makeTestPersistence()
    144         let ctx = persistence.viewContext
    145         let id = UUID()
    146         makeStranded(id: id, in: ctx)
    147         try ctx.save()
    148 
    149         persistence.healStrandedSharedGameRows_v1(force: true)
    150         ctx.refreshAllObjects()
    151 
    152         // Deleting would lose the only row for a game the user owns; repairing
    153         // it to private scope lets the ordinary sync path fill it in.
    154         let rows = try games(in: ctx, id: id)
    155         #expect(rows.count == 1)
    156         #expect(rows.first?.databaseScope == 0)
    157         #expect(rows.first?.ckZoneOwnerName == nil)
    158     }
    159 
    160     @Test("a shared row that carries a puzzle is left alone")
    161     func leavesPopulatedSharedRowsAlone() throws {
    162         let persistence = makeTestPersistence()
    163         let ctx = persistence.viewContext
    164         let id = UUID()
    165         // Same stranded owner spelling, but with real content: not this bug's
    166         // work, and scope 0 would wrongly claim ownership of a joined game.
    167         let joined = makeStranded(id: id, in: ctx)
    168         joined.title = "Test Puzzle"
    169         joined.puzzleSource = Self.puzzleSource
    170         try ctx.save()
    171 
    172         persistence.healStrandedSharedGameRows_v1(force: true)
    173         ctx.refreshAllObjects()
    174 
    175         let rows = try games(in: ctx, id: id)
    176         #expect(rows.count == 1)
    177         #expect(rows.first?.databaseScope == 1)
    178         #expect(rows.first?.ckZoneOwnerName == CKCurrentUserDefaultName)
    179     }
    180 
    181     @Test("a healthy store is untouched")
    182     func healthyStoreIsUntouched() throws {
    183         let persistence = makeTestPersistence()
    184         let ctx = persistence.viewContext
    185         let id = UUID()
    186         makeReal(id: id, in: ctx)
    187         try ctx.save()
    188 
    189         persistence.healStrandedSharedGameRows_v1(force: true)
    190         ctx.refreshAllObjects()
    191 
    192         let rows = try games(in: ctx, id: id)
    193         #expect(rows.count == 1)
    194         #expect(rows.first?.puzzleSource == Self.puzzleSource)
    195     }
    196 }