crossmate

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

commit 961f6b8a7565dd5f36958d01b4195c31a9650de0
parent 0d9f9f1730ae5597a4485d192ee65582b015ef4b
Author: Michael Camilleri <[email protected]>
Date:   Sat, 11 Apr 2026 19:14:38 +0900

Add CloudKit-based sync engine

This commit adds a CloudKit-based sync engine that supports cross-device and
(eventually) collaborative play. It includes an AppDelegate for remote
notifications, a SyncEngine that handles push/fetch/bootstrap and a refactored
persistence layer. Cell mutations are moved to a dedicated GameMutator and
per-cell state is captured in a Square value type. In addition, unit tests are
added to cover GameMutator, PendingChange helpers and RecordSerializer.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme | 14++++++++++++++
ACrossmate/Crossmate.entitlements | 16++++++++++++++++
MCrossmate/CrossmateApp.swift | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCrossmate/Info.plist | 4++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 28++++++++++++++++++++++++++++
MCrossmate/Models/Game.swift | 43+++++++++++++++++++------------------------
MCrossmate/Models/PlayerSession.swift | 34++++++++++++++++++----------------
ACrossmate/Models/Square.swift | 10++++++++++
ACrossmate/Persistence/GameMutator.swift | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 130+++++++++++++++++++++++++++++--------------------------------------------------
ACrossmate/Persistence/PendingChange+Helpers.swift | 40++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/PendingChangePayload.swift | 27+++++++++++++++++++++++++++
ACrossmate/Sync/RecordSerializer.swift | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/SyncEngine.swift | 474+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/SyncState+Helpers.swift | 40++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GridView.swift | 4++--
AScripts/test-unit.sh | 31+++++++++++++++++++++++++++++++
ATests/Support/TestHelpers.swift | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/GameMutatorTests.swift | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/PendingChangeTests.swift | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/RecordSerializerTests.swift | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 24++++++++++++++++++++++++
23 files changed, 2011 insertions(+), 129 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -7,52 +7,118 @@ objects = { /* Begin PBXBuildFile section */ + 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; + 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; + 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; + 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; + 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; + AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; + C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; }; + CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; + D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; + ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; + F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F0122CF3E216720C4437CE6A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9167165F088B7698D1319D3C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7708D1C8A0145D43BD15DEB7; + remoteInfo = Crossmate; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; + 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; }; 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; }; + 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; + 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; + 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; + 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; + BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; + D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; + DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; + E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangePayload.swift; sourceTree = "<group>"; }; + E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PendingChange+Helpers.swift"; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; + F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ + 01B07D8724DEA04C3E74558E /* Support */ = { + isa = PBXGroup; + children = ( + F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */, + ); + name = Support; + path = Tests/Support; + sourceTree = "<group>"; + }; + 074C2962E79CAE6C0EA6431A /* Sync */ = { + isa = PBXGroup; + children = ( + E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */, + 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, + 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, + 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */, + ); + path = Sync; + sourceTree = "<group>"; + }; 12BCF7948BC2C200C647C279 /* Products */ = { isa = PBXGroup; children = ( + D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */, B689A7138429641E61E9E558 /* Crossmate.app */, ); name = Products; sourceTree = "<group>"; }; + 212DB6FCF46C41F81C41D232 /* Unit */ = { + isa = PBXGroup; + children = ( + BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, + 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */, + 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, + ); + name = Unit; + path = Tests/Unit; + sourceTree = "<group>"; + }; 41DB2417FF67A47FE6890256 /* Models */ = { isa = PBXGroup; children = ( @@ -61,6 +127,7 @@ DB55FC337CF72C650373210A /* PlayerColor.swift */, 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, + DB851649DE78AAAC5A928C52 /* Square.swift */, B9031A1574C21866940F6A2C /* XD.swift */, F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */, ); @@ -70,7 +137,9 @@ 565DBAFC8DB2589B3F0AF90E /* Persistence */ = { isa = PBXGroup; children = ( + 43DC132D49361C56DE79C13E /* GameMutator.swift */, 93EE5BA78566EDED68D846AB /* GameStore.swift */, + E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */, ACC295195602B3DDF7BB3895 /* PersistenceController.swift */, ); path = Persistence; @@ -79,11 +148,13 @@ 5770CE69DB2B0B7462FACE53 /* Crossmate */ = { isa = PBXGroup; children = ( + 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */, 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */, 9447F0FE34C63810C6F1D8BE /* Info.plist */, 41DB2417FF67A47FE6890256 /* Models */, 565DBAFC8DB2589B3F0AF90E /* Persistence */, F53443E4827221C62DB7AA36 /* Resources */, + 074C2962E79CAE6C0EA6431A /* Sync */, 84445EA9CACB6AAAEDE6965F /* Views */, ); path = Crossmate; @@ -104,6 +175,8 @@ isa = PBXGroup; children = ( 5770CE69DB2B0B7462FACE53 /* Crossmate */, + 01B07D8724DEA04C3E74558E /* Support */, + 212DB6FCF46C41F81C41D232 /* Unit */, 12BCF7948BC2C200C647C279 /* Products */, ); sourceTree = "<group>"; @@ -137,6 +210,24 @@ productReference = B689A7138429641E61E9E558 /* Crossmate.app */; productType = "com.apple.product-type.application"; }; + C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0C7AF110B3697D116B91817A /* Build configuration list for PBXNativeTarget "Crossmate Unit Tests" */; + buildPhases = ( + 931E2DAAD4EC47B06F7AB60A /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */, + ); + name = "Crossmate Unit Tests"; + packageProductDependencies = ( + ); + productName = "Crossmate Unit Tests"; + productReference = D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -150,6 +241,10 @@ DevelopmentTeam = 7TD7PZBNXP; ProvisioningStyle = Automatic; }; + C38EBD1A6B9D37EF81FF3511 = { + DevelopmentTeam = 7TD7PZBNXP; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */; @@ -167,6 +262,7 @@ projectRoot = ""; targets = ( 7708D1C8A0145D43BD15DEB7 /* Crossmate */, + C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */, ); }; /* End PBXProject section */ @@ -183,6 +279,17 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 931E2DAAD4EC47B06F7AB60A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, + 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */, + ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, + 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C17B62906BBF281D006D8DC2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -192,20 +299,35 @@ DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, + 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, + D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */, + C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */, 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, + CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, + AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, + 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, + F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7708D1C8A0145D43BD15DEB7 /* Crossmate */; + targetProxy = F0122CF3E216720C4437CE6A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 209C1E6D178C7EF962FC85A5 /* Release */ = { isa = XCBuildConfiguration; @@ -270,10 +392,47 @@ }; name = Release; }; + 42CA2E441989D32BE123F48A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate.unittests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Crossmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Crossmate"; + }; + name = Debug; + }; + 642190659D822637293D1645 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate.unittests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Crossmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Crossmate"; + }; + name = Release; + }; 8BC97916898B0BF1E6951C48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Crossmate/Info.plist; @@ -291,6 +450,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = Crossmate/Info.plist; @@ -377,6 +537,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 0C7AF110B3697D116B91817A /* Build configuration list for PBXNativeTarget "Crossmate Unit Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 42CA2E441989D32BE123F48A /* Debug */, + 642190659D822637293D1645 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme b/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme @@ -39,7 +39,21 @@ </BuildableReference> </MacroExpansion> <Testables> + <TestableReference + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "C38EBD1A6B9D37EF81FF3511" + BuildableName = "Crossmate Unit Tests.xctest" + BlueprintName = "Crossmate Unit Tests" + ReferencedContainer = "container:Crossmate.xcodeproj"> + </BuildableReference> + </TestableReference> </Testables> + <CommandLineArguments> + </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>aps-environment</key> + <string>development</string> + <key>com.apple.developer.icloud-container-identifiers</key> + <array> + <string>iCloud.net.inqk.crossmate</string> + </array> + <key>com.apple.developer.icloud-services</key> + <array> + <string>CloudKit</string> + </array> +</dict> +</plist> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -1,19 +1,64 @@ +import CloudKit import SwiftUI @main struct CrossmateApp: App { - @State private var store = GameStore(persistence: PersistenceController()) + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + @State private var store: GameStore + @State private var syncEngine: SyncEngine + private let persistence: PersistenceController + + init() { + let persistence = PersistenceController() + let store = GameStore(persistence: persistence) + let engine = SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate"), + persistence: persistence + ) + self.persistence = persistence + self._store = State(initialValue: store) + self._syncEngine = State(initialValue: engine) + } var body: some Scene { WindowGroup { - RootView(store: store) + RootView(store: store, syncEngine: syncEngine, appDelegate: appDelegate) } } } +// MARK: - App Delegate + +final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { + var onRemoteNotification: (() async -> Void)? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + application.registerForRemoteNotifications() + return true + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any] + ) async -> UIBackgroundFetchResult { + await onRemoteNotification?() + return .newData + } +} + +// MARK: - Root View + struct RootView: View { let store: GameStore + let syncEngine: SyncEngine + let appDelegate: AppDelegate + @Environment(\.scenePhase) private var scenePhase + @State private var syncBootstrapped = false @State private var session: PlayerSession? @State private var loadError: String? @@ -36,12 +81,46 @@ struct RootView: View { .navigationBarTitleDisplayMode(.inline) } .task { + guard !syncBootstrapped else { return } + syncBootstrapped = true + + // Wire app delegate → sync engine fetch + appDelegate.onRemoteNotification = { [syncEngine] in + try? await syncEngine.fetchChanges() + } + + // Wire sync engine → store refresh + await syncEngine.setOnRemoteChangesApplied { [store] in + store.refreshFromRemote() + } + + // Bootstrap and initial sync + try? await syncEngine.bootstrap() + try? await syncEngine.fetchChanges() + try? await syncEngine.pushChanges() + + // Load the current game do { - let game = try store.loadOrCreateCurrentGame() - session = PlayerSession(game: game) + let (game, mutator) = try store.loadOrCreateCurrentGame() + session = PlayerSession(game: game, mutator: mutator) + + // Wire mutator → sync engine push + mutator.onLocalMutation = { [syncEngine] in + Task { + try? await syncEngine.pushChanges() + } + } } catch { loadError = String(describing: error) } } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + Task { + try? await syncEngine.fetchChanges() + try? await syncEngine.pushChanges() + } + } + } } } diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -24,6 +24,10 @@ <false/> <key>LSRequiresIPhoneOS</key> <true/> + <key>UIBackgroundModes</key> + <array> + <string>remote-notification</string> + </array> <key>UILaunchScreen</key> <dict/> <key>UISupportedInterfaceOrientations</key> diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -1,8 +1,13 @@ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="NO" userDefinedModelVersionIdentifier=""> <entity name="GameEntity" representedClassName="GameEntity" syncable="YES" codeGenerationType="class"> + <attribute name="ckRecordName" optional="YES" attributeType="String"/> + <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> + <attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="puzzleSource" attributeType="String"/> <attribute name="title" attributeType="String"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> @@ -10,10 +15,33 @@ </entity> <entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class"> <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="ckRecordName" optional="YES" attributeType="String"/> + <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="letter" attributeType="String" defaultValueString=""/> + <attribute name="letterAuthorID" optional="YES" attributeType="String"/> <attribute name="markKind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="cells" inverseEntity="GameEntity"/> </entity> + <entity name="SyncStateEntity" representedClassName="SyncStateEntity" syncable="YES" codeGenerationType="class"> + <attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="privateDatabaseToken" optional="YES" attributeType="Binary"/> + <attribute name="privateZoneToken" optional="YES" attributeType="Binary"/> + <attribute name="subscriptionCreated" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="zoneCreated" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + </entity> + <entity name="PendingChangeEntity" representedClassName="PendingChangeEntity" syncable="YES" codeGenerationType="class"> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="payload" attributeType="String"/> + <attribute name="recordName" attributeType="String"/> + <attribute name="recordType" attributeType="String"/> + <fetchIndex name="byRecordName"> + <fetchIndexElement property="recordName" type="Binary" order="ascending"/> + </fetchIndex> + </entity> </model> diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -10,17 +10,12 @@ import Observation @Observable final class Game { let puzzle: Puzzle - var entries: [[String]] - var marks: [[CellMark]] + var squares: [[Square]] init(puzzle: Puzzle) { self.puzzle = puzzle - self.entries = Array( - repeating: Array(repeating: "", count: puzzle.width), - count: puzzle.height - ) - self.marks = Array( - repeating: Array(repeating: .none, count: puzzle.width), + self.squares = Array( + repeating: Array(repeating: Square(), count: puzzle.width), count: puzzle.height ) } @@ -33,18 +28,18 @@ final class Game { func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) { let cell = puzzle.cells[row][col] guard !cell.isBlock else { return } - guard !marks[row][col].isRevealed else { return } - entries[row][col] = letter.uppercased() - marks[row][col] = pencil ? .pencil(checkedWrong: false) : .none + guard !squares[row][col].mark.isRevealed else { return } + squares[row][col].entry = letter.uppercased() + squares[row][col].mark = pencil ? .pencil(checkedWrong: false) : .none } /// Clears the entry and mark at `(row, col)`. No-op on revealed cells. func clearLetter(atRow row: Int, atCol col: Int) { let cell = puzzle.cells[row][col] guard !cell.isBlock else { return } - guard !marks[row][col].isRevealed else { return } - entries[row][col] = "" - marks[row][col] = .none + guard !squares[row][col].mark.isRevealed else { return } + squares[row][col].entry = "" + squares[row][col].mark = .none } // MARK: - Check / Reveal / Clear @@ -57,16 +52,16 @@ final class Game { for cell in cells { guard !cell.isBlock else { continue } guard let solution = cell.solution else { continue } - let entry = entries[cell.row][cell.col] + let entry = squares[cell.row][cell.col].entry guard !entry.isEmpty else { continue } - guard !marks[cell.row][cell.col].isRevealed else { continue } + guard !squares[cell.row][cell.col].mark.isRevealed else { continue } let isWrong = entry != solution.uppercased() - switch marks[cell.row][cell.col] { + switch squares[cell.row][cell.col].mark { case .pencil: - marks[cell.row][cell.col] = .pencil(checkedWrong: isWrong) + squares[cell.row][cell.col].mark = .pencil(checkedWrong: isWrong) case .none, .pen: - marks[cell.row][cell.col] = isWrong ? .pen(checkedWrong: true) : .none + squares[cell.row][cell.col].mark = isWrong ? .pen(checkedWrong: true) : .none case .revealed: break // unreachable; guarded above } @@ -83,8 +78,8 @@ final class Game { for cell in cells { guard !cell.isBlock else { continue } guard let solution = cell.solution else { continue } - entries[cell.row][cell.col] = solution.uppercased() - marks[cell.row][cell.col] = .revealed + squares[cell.row][cell.col].entry = solution.uppercased() + squares[cell.row][cell.col].mark = .revealed } } @@ -98,9 +93,9 @@ final class Game { func clearCells(_ cells: [Puzzle.Cell]) { for cell in cells { guard !cell.isBlock else { continue } - guard !marks[cell.row][cell.col].isRevealed else { continue } - entries[cell.row][cell.col] = "" - marks[cell.row][cell.col] = .none + guard !squares[cell.row][cell.col].mark.isRevealed else { continue } + squares[cell.row][cell.col].entry = "" + squares[cell.row][cell.col].mark = .none } } diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -10,6 +10,7 @@ import Observation @Observable final class PlayerSession { let game: Game + let mutator: GameMutator var selectedRow: Int var selectedCol: Int var direction: Puzzle.Direction = .across @@ -17,15 +18,16 @@ final class PlayerSession { /// Rebus mode lets the player type a multi-character value into a single /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in - /// `rebusBuffer` rather than going straight to `Game.entries`; on commit + /// `rebusBuffer` rather than going straight to `Game.squares`; on commit /// the buffer is written to the cell and the cursor advances. var isRebusActive: Bool = false var rebusBuffer: String = "" var puzzle: Puzzle { game.puzzle } - init(game: Game) { + init(game: Game, mutator: GameMutator) { self.game = game + self.mutator = mutator let puzzle = game.puzzle // Start at the first across clue. Fall back to the first down clue if @@ -118,37 +120,37 @@ final class PlayerSession { func checkSquare() { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } - game.checkCells([cell]) + mutator.checkCells([cell], origin: .local) } func checkCurrentWord() { - game.checkCells(currentWordCells()) + mutator.checkCells(currentWordCells(), origin: .local) } func checkPuzzle() { - game.checkPuzzle() + mutator.checkCells(puzzle.cells.flatMap { $0 }, origin: .local) } func revealSquare() { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } - game.revealCells([cell]) + mutator.revealCells([cell], origin: .local) } func revealCurrentWord() { - game.revealCells(currentWordCells()) + mutator.revealCells(currentWordCells(), origin: .local) } func revealPuzzle() { - game.revealPuzzle() + mutator.revealCells(puzzle.cells.flatMap { $0 }, origin: .local) } func clearCurrentWord() { - game.clearCells(currentWordCells()) + mutator.clearCells(currentWordCells(), origin: .local) } func clearPuzzle() { - game.clearPuzzle() + mutator.clearCells(puzzle.cells.flatMap { $0 }, origin: .local) } private func currentClueNumber() -> Int? { @@ -171,7 +173,7 @@ final class PlayerSession { func enter(_ letter: String) { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } - game.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode) + mutator.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode, origin: .local) advance() } @@ -181,12 +183,12 @@ final class PlayerSession { // tunnels past them to the previous editable cell. `clearLetter` // itself no-ops on revealed cells, so calling it unconditionally // after retreat is safe. - let currentMark = game.marks[selectedRow][selectedCol] - let currentEmpty = game.entries[selectedRow][selectedCol].isEmpty + let currentMark = game.squares[selectedRow][selectedCol].mark + let currentEmpty = game.squares[selectedRow][selectedCol].entry.isEmpty if currentEmpty || currentMark.isRevealed { retreat() } - game.clearLetter(atRow: selectedRow, atCol: selectedCol) + mutator.clearLetter(atRow: selectedRow, atCol: selectedCol, origin: .local) } // MARK: - Rebus @@ -194,7 +196,7 @@ final class PlayerSession { func startRebus() { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } - rebusBuffer = game.entries[selectedRow][selectedCol] + rebusBuffer = game.squares[selectedRow][selectedCol].entry isRebusActive = true } @@ -211,7 +213,7 @@ final class PlayerSession { let value = rebusBuffer isRebusActive = false rebusBuffer = "" - game.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode) + mutator.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode, origin: .local) advance() } diff --git a/Crossmate/Models/Square.swift b/Crossmate/Models/Square.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Per-cell state for one square in the grid. Combines the letter entry and +/// mark (previously parallel arrays on `Game`) with sync bookkeeping fields. +struct Square: Sendable { + var entry: String = "" + var mark: CellMark = .none + var updatedAt: Date? + var letterAuthorID: String? +} diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -0,0 +1,231 @@ +import CoreData +import Foundation + +/// Unified mutation processor that sits between `PlayerSession` (or the sync +/// engine) and `Game`. Every mutation flows through here so that: +/// +/// 1. Timestamps are captured (local) or accepted (remote) for LWW. +/// 2. The in-memory `Game` is updated. +/// 3. Changed cells are persisted to Core Data. +/// 4. For local mutations, a `PendingChangeEntity` is enqueued for upload. +/// +/// All methods are `@MainActor` because `Game` is `@MainActor`. +@MainActor +final class GameMutator { + private let game: Game + private let gameEntity: GameEntity + private let context: NSManagedObjectContext + + /// Wired to SyncEngine in Phase E to trigger a push after local mutations. + var onLocalMutation: (() -> Void)? + + enum Origin { + case local + case remote(timestamp: Date, authorID: String?) + } + + init(game: Game, gameEntity: GameEntity, context: NSManagedObjectContext) { + self.game = game + self.gameEntity = gameEntity + self.context = context + } + + // MARK: - Single-cell mutations + + func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, origin: Origin) { + let now = timestamp(for: origin) + guard shouldApply(row: row, col: col, incomingTimestamp: now) else { return } + + game.setLetter(letter, atRow: row, atCol: col, pencil: pencil) + game.squares[row][col].updatedAt = now + game.squares[row][col].letterAuthorID = authorID(for: origin) + + persistCell(atRow: row, atCol: col) + enqueueIfLocal(origin: origin, row: row, col: col) + } + + func clearLetter(atRow row: Int, atCol col: Int, origin: Origin) { + let now = timestamp(for: origin) + guard shouldApply(row: row, col: col, incomingTimestamp: now) else { return } + + game.clearLetter(atRow: row, atCol: col) + game.squares[row][col].updatedAt = now + game.squares[row][col].letterAuthorID = nil + + persistCell(atRow: row, atCol: col) + enqueueIfLocal(origin: origin, row: row, col: col) + } + + // MARK: - Bulk mutations + + func checkCells(_ cells: [Puzzle.Cell], origin: Origin) { + let now = timestamp(for: origin) + var changed: [Puzzle.Cell] = [] + + for cell in cells { + guard !cell.isBlock else { continue } + guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue } + changed.append(cell) + } + + guard !changed.isEmpty else { return } + game.checkCells(changed) + + for cell in changed { + game.squares[cell.row][cell.col].updatedAt = now + persistCell(atRow: cell.row, atCol: cell.col) + enqueueIfLocal(origin: origin, row: cell.row, col: cell.col) + } + } + + func revealCells(_ cells: [Puzzle.Cell], origin: Origin) { + let now = timestamp(for: origin) + var changed: [Puzzle.Cell] = [] + + for cell in cells { + guard !cell.isBlock else { continue } + guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue } + changed.append(cell) + } + + guard !changed.isEmpty else { return } + game.revealCells(changed) + + for cell in changed { + game.squares[cell.row][cell.col].updatedAt = now + persistCell(atRow: cell.row, atCol: cell.col) + enqueueIfLocal(origin: origin, row: cell.row, col: cell.col) + } + } + + func clearCells(_ cells: [Puzzle.Cell], origin: Origin) { + let now = timestamp(for: origin) + var changed: [Puzzle.Cell] = [] + + for cell in cells { + guard !cell.isBlock else { continue } + guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue } + changed.append(cell) + } + + guard !changed.isEmpty else { return } + game.clearCells(changed) + + for cell in changed { + game.squares[cell.row][cell.col].updatedAt = now + game.squares[cell.row][cell.col].letterAuthorID = nil + persistCell(atRow: cell.row, atCol: cell.col) + enqueueIfLocal(origin: origin, row: cell.row, col: cell.col) + } + } + + // MARK: - Helpers + + private func timestamp(for origin: Origin) -> Date { + switch origin { + case .local: + return Date() + case .remote(let timestamp, _): + return timestamp + } + } + + private func authorID(for origin: Origin) -> String? { + switch origin { + case .local: + return nil // Will be set to the local user's CKRecord.ID in Phase E + case .remote(_, let authorID): + return authorID + } + } + + /// LWW gate: returns `true` if the incoming timestamp is newer than (or + /// equal to) the cell's current `updatedAt`. Cells that have never been + /// stamped always accept the incoming value. + private func shouldApply(row: Int, col: Int, incomingTimestamp: Date) -> Bool { + guard let existing = game.squares[row][col].updatedAt else { return true } + return incomingTimestamp >= existing + } + + // MARK: - Core Data persistence + + private func persistCell(atRow row: Int, atCol col: Int) { + let square = game.squares[row][col] + guard let cellEntity = findCellEntity(row: row, col: col) else { return } + + cellEntity.letter = square.entry + let (kind, wrong) = encodeMark(square.mark) + cellEntity.markKind = kind + cellEntity.checkedWrong = wrong + cellEntity.updatedAt = square.updatedAt + cellEntity.letterAuthorID = square.letterAuthorID + + gameEntity.updatedAt = Date() + saveContext() + } + + private func findCellEntity(row: Int, col: Int) -> CellEntity? { + let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? [] + return cellEntities.first { Int($0.row) == row && Int($0.col) == col } + } + + private func saveContext() { + guard context.hasChanges else { return } + do { + try context.save() + } catch { + print("GameMutator: failed to save context: \(error)") + } + } + + private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) { + switch mark { + case .none: + return (0, false) + case .pen(let wrong): + return (1, wrong) + case .pencil(let wrong): + return (2, wrong) + case .revealed: + return (3, false) + } + } + + // MARK: - Outbox + + private func enqueueIfLocal(origin: Origin, row: Int, col: Int) { + guard case .local = origin else { return } + + let square = game.squares[row][col] + guard let gameID = gameEntity.id else { return } + let recordName = RecordSerializer.recordName(forCellInGame: gameID, row: row, col: col) + + let (markKind, checkedWrong) = encodeMark(square.mark) + let payload = PendingChangePayload( + recordType: .cell, + recordName: recordName, + letter: square.entry, + markKind: markKind, + checkedWrong: checkedWrong, + updatedAt: square.updatedAt, + letterAuthorID: square.letterAuthorID, + parentGameRecordName: RecordSerializer.recordName(forGameID: gameID) + ) + + do { + let jsonData = try JSONEncoder().encode(payload) + let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" + PendingChangeEntity.upsert( + recordName: recordName, + recordType: "Cell", + payload: jsonString, + in: context + ) + saveContext() + } catch { + print("GameMutator: failed to encode pending change: \(error)") + } + + onLocalMutation?() + } +} diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -3,23 +3,18 @@ import Foundation import Observation /// Repository over the local Core Data store. Owns the lifecycle of the -/// "current game" — loading it on launch (or seeding from `sample.xd` on -/// first run) and write-throughing every in-memory mutation back to the -/// persistent store. -/// -/// Persistence is whole-game write-through: any change to `Game.entries` -/// or `Game.marks` triggers a save of every cell. For puzzle-sized data -/// (a few hundred cells at most) the cost is negligible, and it lets us -/// skip per-cell dirty tracking entirely. If write throughput ever shows -/// up as a problem, debouncing or per-cell tracking can be layered on -/// without changing the call sites. +/// current game — loading it on launch (or seeding from `sample.xd` on +/// first run). Persistence of individual cell mutations is handled by +/// `GameMutator`. @MainActor +@Observable final class GameStore { - private let persistence: PersistenceController + let persistence: PersistenceController private var context: NSManagedObjectContext { persistence.viewContext } - private var currentGame: Game? - private var currentEntity: GameEntity? + private(set) var currentGame: Game? + private(set) var currentMutator: GameMutator? + private(set) var currentEntity: GameEntity? init(persistence: PersistenceController) { self.persistence = persistence @@ -30,10 +25,11 @@ final class GameStore { case persistedSourceMissing } - /// Returns the single current game, creating it from `sample.xd` on - /// first launch. Subsequent launches rehydrate the in-memory `Game` - /// from the stored `CellEntity` rows so any prior progress is restored. - func loadOrCreateCurrentGame() throws -> Game { + /// Returns the single current game and its mutator, creating from + /// `sample.xd` on first launch. Subsequent launches rehydrate the + /// in-memory `Game` from the stored `CellEntity` rows so any prior + /// progress is restored. + func loadOrCreateCurrentGame() throws -> (Game, GameMutator) { let entity: GameEntity let puzzle: Puzzle @@ -51,11 +47,13 @@ final class GameStore { let game = Game(puzzle: puzzle) restore(game: game, from: entity) + let mutator = GameMutator(game: game, gameEntity: entity, context: context) + currentGame = game + currentMutator = mutator currentEntity = entity - startObserving(game) - return game + return (game, mutator) } // MARK: - Loading @@ -76,12 +74,14 @@ final class GameStore { let puzzle = Puzzle(xd: xd) let now = Date() + let gameID = UUID() let entity = GameEntity(context: context) - entity.id = UUID() + entity.id = gameID entity.title = puzzle.title entity.puzzleSource = source entity.createdAt = now entity.updatedAt = now + entity.ckRecordName = "game-\(gameID.uuidString)" for row in puzzle.cells { for cell in row where !cell.isBlock { @@ -91,6 +91,7 @@ final class GameStore { cellEntity.letter = "" cellEntity.markKind = 0 cellEntity.checkedWrong = false + cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)" cellEntity.game = entity } } @@ -105,86 +106,49 @@ final class GameStore { let r = Int(cellEntity.row) let c = Int(cellEntity.col) guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } - game.entries[r][c] = cellEntity.letter ?? "" - game.marks[r][c] = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong) + game.squares[r][c].entry = cellEntity.letter ?? "" + game.squares[r][c].mark = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong) + game.squares[r][c].updatedAt = cellEntity.updatedAt + game.squares[r][c].letterAuthorID = cellEntity.letterAuthorID } } - // MARK: - Observation - - /// Registers a one-shot observation on the game's mutable state. The - /// callback fires once on the next change, persists, and re-registers. - /// `withObservationTracking` is intentionally one-shot; the re-arm in - /// `persistCurrent` is what makes this run for the lifetime of the game. - private func startObserving(_ game: Game) { - withObservationTracking { - // Touch the properties we care about so the tracker knows to - // watch them. Mutating an element of `entries` or `marks` is a - // setter call on the array property under the hood, which - // @Observable picks up. - _ = game.entries - _ = game.marks - } onChange: { [weak self] in - Task { @MainActor [weak self] in - self?.persistCurrent() - } - } - } + // MARK: - Remote refresh - private func persistCurrent() { + /// Re-reads CellEntity values from Core Data into the in-memory Game. + /// Called on the MainActor after the sync engine applies remote changes + /// to the background context (which merges into viewContext via + /// `automaticallyMergesChangesFromParent`). + func refreshFromRemote() { guard let game = currentGame, let entity = currentEntity else { return } + // Fault the relationship to pick up merged background changes + context.refresh(entity, mergeChanges: true) + let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] for cellEntity in cellEntities { let r = Int(cellEntity.row) let c = Int(cellEntity.col) guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } - let newLetter = game.entries[r][c] - let (kind, wrong) = encodeMark(game.marks[r][c]) - if cellEntity.letter != newLetter { - cellEntity.letter = newLetter - } - if cellEntity.markKind != kind { - cellEntity.markKind = kind - } - if cellEntity.checkedWrong != wrong { - cellEntity.checkedWrong = wrong - } - } - entity.updatedAt = Date() - - if context.hasChanges { - do { - try context.save() - } catch { - // Surfacing this to the user properly is a v1 problem; for - // now log so we notice in development. - print("GameStore: failed to save context: \(error)") + + let remoteTimestamp = cellEntity.updatedAt + let remoteAuthorID = cellEntity.letterAuthorID + let remoteLetter = cellEntity.letter ?? "" + let remoteMark = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong) + + // Only apply if the remote timestamp is newer (LWW) + let localTimestamp = game.squares[r][c].updatedAt + if let remote = remoteTimestamp, (localTimestamp == nil || remote >= localTimestamp!) { + game.squares[r][c].entry = remoteLetter + game.squares[r][c].mark = remoteMark + game.squares[r][c].updatedAt = remoteTimestamp + game.squares[r][c].letterAuthorID = remoteAuthorID } } - - startObserving(game) } // MARK: - CellMark coding - /// Maps `CellMark` onto two storage columns: a small integer kind and a - /// boolean for the orthogonal `checkedWrong` dimension. `.revealed` - /// always stores `checkedWrong = false` since revealed cells can't be - /// wrong; the column is ignored on decode for that case. - private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) { - switch mark { - case .none: - return (0, false) - case .pen(let wrong): - return (1, wrong) - case .pencil(let wrong): - return (2, wrong) - case .revealed: - return (3, false) - } - } - private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark { switch kind { case 1: return .pen(checkedWrong: checkedWrong) diff --git a/Crossmate/Persistence/PendingChange+Helpers.swift b/Crossmate/Persistence/PendingChange+Helpers.swift @@ -0,0 +1,40 @@ +import CoreData +import Foundation + +extension PendingChangeEntity { + /// Upserts a pending change for the given record name. If a row with the + /// same `recordName` already exists, its payload and timestamp are updated + /// (coalescing rapid edits into a single outbox entry). Otherwise a new + /// row is inserted. + static func upsert( + recordName: String, + recordType: String, + payload: String, + in context: NSManagedObjectContext + ) { + let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity") + request.predicate = NSPredicate(format: "recordName == %@", recordName) + request.fetchLimit = 1 + + let entity: PendingChangeEntity + if let existing = try? context.fetch(request).first { + entity = existing + } else { + entity = PendingChangeEntity(context: context) + entity.recordName = recordName + entity.recordType = recordType + } + + entity.payload = payload + entity.createdAt = Date() + } + + /// Fetches up to `limit` pending changes ordered by creation time + /// (oldest first). + static func drain(limit: Int, in context: NSManagedObjectContext) -> [PendingChangeEntity] { + let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity") + request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)] + request.fetchLimit = limit + return (try? context.fetch(request)) ?? [] + } +} diff --git a/Crossmate/Sync/PendingChangePayload.swift b/Crossmate/Sync/PendingChangePayload.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Codable snapshot stored as JSON in `PendingChangeEntity.payload`. Captures +/// everything the upload loop needs to build a `CKRecord` without going back +/// to Core Data or the in-memory model. +struct PendingChangePayload: Codable { + enum RecordType: String, Codable { + case game = "Game" + case cell = "Cell" + } + + let recordType: RecordType + let recordName: String + + // Cell fields (nil when recordType == .game) + var letter: String? + var markKind: Int16? + var checkedWrong: Bool? + var updatedAt: Date? + var letterAuthorID: String? + var parentGameRecordName: String? + + // Game fields (nil when recordType == .cell) + var title: String? + var puzzleSource: String? + var completedAt: Date? +} diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -0,0 +1,209 @@ +import CloudKit +import CoreData +import Foundation + +/// Pure-function helpers for converting between the app's Core Data / in-memory +/// models and CloudKit `CKRecord` objects. Stateless — all context is passed in. +enum RecordSerializer { + static let zoneName = "CrossmateZone" + + // MARK: - Record names + + static func recordName(forGameID gameID: UUID) -> String { + "game-\(gameID.uuidString)" + } + + static func recordName(forCellInGame gameID: UUID, row: Int, col: Int) -> String { + "cell-\(gameID.uuidString)-\(row)-\(col)" + } + + // MARK: - Zone + + static func zone() -> CKRecordZone { + CKRecordZone(zoneName: zoneName) + } + + static func zoneID() -> CKRecordZone.ID { + CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + } + + // MARK: - Building CKRecords from payloads + + static func gameRecord( + from payload: PendingChangePayload, + zone: CKRecordZone.ID, + systemFields: Data? + ) -> CKRecord { + let record = restoreOrCreate( + recordType: "Game", + recordName: payload.recordName, + zone: zone, + systemFields: systemFields + ) + + record["title"] = payload.title as CKRecordValue? + record["completedAt"] = payload.completedAt as CKRecordValue? + + // Puzzle source is stored as a CKAsset (written to a temp file) + // so it doesn't count against the per-record field size limit. + if let source = payload.puzzleSource { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("xd") + try? source.write(to: tempURL, atomically: true, encoding: .utf8) + record["puzzleSource"] = CKAsset(fileURL: tempURL) + } + + return record + } + + static func cellRecord( + from payload: PendingChangePayload, + zone: CKRecordZone.ID, + systemFields: Data? + ) -> CKRecord { + let record = restoreOrCreate( + recordType: "Cell", + recordName: payload.recordName, + zone: zone, + systemFields: systemFields + ) + + record["letter"] = payload.letter as CKRecordValue? + record["markKind"] = (payload.markKind ?? 0) as CKRecordValue + record["checkedWrong"] = (payload.checkedWrong ?? false) as CKRecordValue + record["updatedAt"] = payload.updatedAt as CKRecordValue? + record["letterAuthorID"] = payload.letterAuthorID as CKRecordValue? + + if let parentName = payload.parentGameRecordName { + let parentID = CKRecord.ID(recordName: parentName, zoneID: zone) + record.parent = CKRecord.Reference(recordID: parentID, action: .none) + } + + return record + } + + // MARK: - Applying incoming CKRecords to Core Data + + static func applyGameRecord( + _ record: CKRecord, + to context: NSManagedObjectContext + ) -> GameEntity { + let recordName = record.recordID.recordName + let entity = fetchOrCreate( + entityName: "GameEntity", + recordName: recordName, + in: context + ) as! GameEntity + + entity.ckRecordName = recordName + entity.ckSystemFields = encodeSystemFields(of: record) + entity.title = record["title"] as? String ?? entity.title + entity.completedAt = record["completedAt"] as? Date + + if let asset = record["puzzleSource"] as? CKAsset, + let fileURL = asset.fileURL, + let source = try? String(contentsOf: fileURL, encoding: .utf8) { + entity.puzzleSource = source + } + + return entity + } + + static func applyCellRecord( + _ record: CKRecord, + to context: NSManagedObjectContext, + game: GameEntity + ) -> CellEntity { + let recordName = record.recordID.recordName + let entity = fetchOrCreateCell( + recordName: recordName, + in: context, + game: game + ) + + entity.ckRecordName = recordName + entity.ckSystemFields = encodeSystemFields(of: record) + entity.letter = record["letter"] as? String ?? "" + entity.markKind = record["markKind"] as? Int16 ?? 0 + entity.checkedWrong = record["checkedWrong"] as? Bool ?? false + entity.updatedAt = record["updatedAt"] as? Date + entity.letterAuthorID = record["letterAuthorID"] as? String + + return entity + } + + // MARK: - System fields encode/decode + + static func encodeSystemFields(of record: CKRecord) -> Data? { + let coder = NSKeyedArchiver(requiringSecureCoding: true) + record.encodeSystemFields(with: coder) + coder.finishEncoding() + return coder.encodedData + } + + static func decodeRecord(from data: Data) -> CKRecord? { + guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } + coder.requiresSecureCoding = true + let record = CKRecord(coder: coder) + coder.finishDecoding() + return record + } + + // MARK: - Private helpers + + /// Restores a `CKRecord` from archived system fields (preserving the + /// server change tag) or creates a fresh one if no archive is available. + private static func restoreOrCreate( + recordType: String, + recordName: String, + zone: CKRecordZone.ID, + systemFields: Data? + ) -> CKRecord { + if let data = systemFields, let restored = decodeRecord(from: data) { + return restored + } + let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) + return CKRecord(recordType: recordType, recordID: recordID) + } + + private static func fetchOrCreate( + entityName: String, + recordName: String, + in context: NSManagedObjectContext + ) -> NSManagedObject { + let request = NSFetchRequest<NSManagedObject>(entityName: entityName) + request.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + request.fetchLimit = 1 + if let existing = try? context.fetch(request).first { + return existing + } + return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) + } + + private static func fetchOrCreateCell( + recordName: String, + in context: NSManagedObjectContext, + game: GameEntity + ) -> CellEntity { + // Try to find by ckRecordName first + let cells = (game.cells as? Set<CellEntity>) ?? [] + if let existing = cells.first(where: { $0.ckRecordName == recordName }) { + return existing + } + + // Parse row/col from record name (format: "cell-<uuid>-<row>-<col>") + let parts = recordName.split(separator: "-") + if parts.count >= 2, + let row = Int16(parts[parts.count - 2]), + let col = Int16(parts[parts.count - 1]), + let existing = cells.first(where: { $0.row == row && $0.col == col }) { + return existing + } + + // Create new + let entity = CellEntity(context: context) + entity.game = game + return entity + } +} diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -0,0 +1,474 @@ +import CloudKit +import CoreData +import Foundation + +/// Owns the CloudKit container, custom zone, and sync lifecycle. All CloudKit +/// operations run on this actor's serial executor, keeping token reads and +/// writes race-free. +actor SyncEngine { + let container: CKContainer + let privateDatabase: CKDatabase + let persistence: PersistenceController + + private let zoneID: CKRecordZone.ID + + /// Called on the MainActor after remote changes have been applied to + /// Core Data. Wired up in CrossmateApp to call GameStore.refreshFromRemote. + private var onRemoteChangesApplied: (@MainActor @Sendable () -> Void)? + + func setOnRemoteChangesApplied(_ callback: @MainActor @Sendable @escaping () -> Void) { + onRemoteChangesApplied = callback + } + + init(container: CKContainer, persistence: PersistenceController) { + self.container = container + self.privateDatabase = container.privateCloudDatabase + self.persistence = persistence + self.zoneID = RecordSerializer.zoneID() + } + + // MARK: - Bootstrap + + /// Ensures the custom zone exists in the private database. Idempotent — + /// safe to call on every launch. Skips the network call if the zone has + /// already been created (tracked via `SyncStateEntity`). + func bootstrap() async throws { + let context = persistence.container.newBackgroundContext() + let alreadyCreated: Bool = context.performAndWait { + SyncStateEntity.current(in: context).zoneCreated + } + + guard !alreadyCreated else { return } + + let zone = RecordSerializer.zone() + let operation = CKModifyRecordZonesOperation( + recordZonesToSave: [zone], + recordZoneIDsToDelete: nil + ) + operation.qualityOfService = .utility + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in + operation.modifyRecordZonesResultBlock = { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + privateDatabase.add(operation) + } + + context.performAndWait { + SyncStateEntity.current(in: context).zoneCreated = true + try? context.save() + } + + // Create the database subscription for silent push notifications + try await createSubscriptionIfNeeded(context: context) + } + + /// Creates a `CKDatabaseSubscription` for silent push so that remote + /// changes trigger a fetch. Idempotent — skipped if already created. + private func createSubscriptionIfNeeded(context: NSManagedObjectContext) async throws { + let alreadyCreated: Bool = context.performAndWait { + SyncStateEntity.current(in: context).subscriptionCreated + } + guard !alreadyCreated else { return } + + let subscription = CKDatabaseSubscription(subscriptionID: "private-changes") + let notificationInfo = CKSubscription.NotificationInfo() + notificationInfo.shouldSendContentAvailable = true + subscription.notificationInfo = notificationInfo + + let operation = CKModifySubscriptionsOperation( + subscriptionsToSave: [subscription], + subscriptionIDsToDelete: nil + ) + operation.qualityOfService = .utility + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in + operation.modifySubscriptionsResultBlock = { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + privateDatabase.add(operation) + } + + context.performAndWait { + SyncStateEntity.current(in: context).subscriptionCreated = true + try? context.save() + } + } + + // MARK: - Push + + /// Drains the `PendingChangeEntity` outbox and pushes records to CloudKit. + /// Loops until the outbox is empty, processing up to 400 records per batch + /// (the CloudKit per-operation limit). + func pushChanges() async throws { + let context = persistence.container.newBackgroundContext() + + while true { + let batch: [(recordName: String, recordType: String, payload: PendingChangePayload, systemFields: Data?)] = + context.performAndWait { + let pending = PendingChangeEntity.drain(limit: 400, in: context) + guard !pending.isEmpty else { return [] } + + return pending.compactMap { entity in + guard let payloadJSON = entity.payload, + let recordName = entity.recordName, + let recordType = entity.recordType, + let data = payloadJSON.data(using: .utf8), + let payload = try? JSONDecoder().decode(PendingChangePayload.self, from: data) + else { return nil } + + // Look up ckSystemFields from the corresponding entity + let systemFields = self.lookupSystemFields( + recordName: recordName, + recordType: recordType, + in: context + ) + + return (recordName, recordType, payload, systemFields) + } + } + + guard !batch.isEmpty else { break } + + // Build CKRecords + let records: [CKRecord] = batch.map { item in + switch item.payload.recordType { + case .game: + return RecordSerializer.gameRecord( + from: item.payload, + zone: zoneID, + systemFields: item.systemFields + ) + case .cell: + return RecordSerializer.cellRecord( + from: item.payload, + zone: zoneID, + systemFields: item.systemFields + ) + } + } + + // Push via CKModifyRecordsOperation + let perRecordResults = try await pushRecords(records) + + // Process results on the background context + context.performAndWait { + for (recordID, result) in perRecordResults { + let recordName = recordID.recordName + switch result { + case .success(let savedRecord): + // Write ckSystemFields back to the entity + self.writeBackSystemFields( + record: savedRecord, + recordName: recordName, + in: context + ) + // Delete the PendingChange row + self.deletePendingChange(recordName: recordName, in: context) + + case .failure(let error): + self.handlePushError( + error: error, + recordName: recordName, + in: context + ) + } + } + try? context.save() + } + } + } + + // MARK: - Fetch + + /// Fetches remote changes using a two-step process: + /// 1. `CKFetchDatabaseChangesOperation` to discover which zones changed. + /// 2. `CKFetchRecordZoneChangesOperation` to pull changed records from + /// our zone. + /// Applies incoming records to Core Data, persists change tokens, then + /// hops to MainActor to refresh the in-memory Game. + func fetchChanges() async throws { + let context = persistence.container.newBackgroundContext() + + // Step 1: Fetch database-level changes to discover changed zones + let databaseToken: CKServerChangeToken? = context.performAndWait { + SyncStateEntity.current(in: context).decodedPrivateDatabaseToken + } + + let changedZoneIDs = try await fetchDatabaseChanges(token: databaseToken, context: context) + + // Only proceed if our zone has changes + guard changedZoneIDs.contains(zoneID) else { return } + + // Step 2: Fetch zone-level changes + let zoneToken: CKServerChangeToken? = context.performAndWait { + SyncStateEntity.current(in: context).decodedPrivateZoneToken + } + + let incomingRecords = try await fetchZoneChanges(token: zoneToken, context: context) + + guard !incomingRecords.isEmpty else { return } + + // Step 3: Apply incoming records to Core Data + context.performAndWait { + self.applyIncomingRecords(incomingRecords, in: context) + try? context.save() + } + + // Step 4: Hop to MainActor to refresh the in-memory Game + if let onRemoteChangesApplied { + await onRemoteChangesApplied() + } + } + + // MARK: - Fetch helpers + + private func fetchDatabaseChanges( + token: CKServerChangeToken?, + context: NSManagedObjectContext + ) async throws -> [CKRecordZone.ID] { + let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken: token) + operation.qualityOfService = .utility + + var changedZoneIDs: [CKRecordZone.ID] = [] + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecordZone.ID], Error>) in + operation.recordZoneWithIDChangedBlock = { zoneID in + changedZoneIDs.append(zoneID) + } + + operation.fetchDatabaseChangesResultBlock = { result in + switch result { + case .success(let (serverToken, _)): + // Persist the database token + context.performAndWait { + SyncStateEntity.current(in: context).decodedPrivateDatabaseToken = serverToken + try? context.save() + } + continuation.resume(returning: changedZoneIDs) + case .failure(let error): + continuation.resume(throwing: error) + } + } + + privateDatabase.add(operation) + } + } + + private func fetchZoneChanges( + token: CKServerChangeToken?, + context: NSManagedObjectContext + ) async throws -> [CKRecord] { + let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + options.previousServerChangeToken = token + + let operation = CKFetchRecordZoneChangesOperation( + recordZoneIDs: [zoneID], + configurationsByRecordZoneID: [zoneID: options] + ) + operation.qualityOfService = .utility + + var incomingRecords: [CKRecord] = [] + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord], Error>) in + operation.recordWasChangedBlock = { _, result in + if case .success(let record) = result { + incomingRecords.append(record) + } + } + + operation.recordZoneFetchResultBlock = { _, result in + switch result { + case .success(let (serverToken, _, _)): + // Persist the zone token + context.performAndWait { + SyncStateEntity.current(in: context).decodedPrivateZoneToken = serverToken + try? context.save() + } + case .failure(let error): + print("SyncEngine: zone fetch error: \(error)") + } + } + + operation.fetchRecordZoneChangesResultBlock = { result in + switch result { + case .success: + continuation.resume(returning: incomingRecords) + case .failure(let error): + continuation.resume(throwing: error) + } + } + + privateDatabase.add(operation) + } + } + + private nonisolated func applyIncomingRecords( + _ records: [CKRecord], + in context: NSManagedObjectContext + ) { + // First pass: apply Game records so parent entities exist for cells + for record in records where record.recordType == "Game" { + let _ = RecordSerializer.applyGameRecord(record, to: context) + } + + // Second pass: apply Cell records + for record in records where record.recordType == "Cell" { + guard let parentRef = record.parent else { continue } + let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameRequest.predicate = NSPredicate(format: "ckRecordName == %@", parentRef.recordID.recordName) + gameRequest.fetchLimit = 1 + guard let gameEntity = try? context.fetch(gameRequest).first else { continue } + let _ = RecordSerializer.applyCellRecord(record, to: context, game: gameEntity) + } + } + + // MARK: - Push helpers + + private func pushRecords(_ records: [CKRecord]) async throws -> [CKRecord.ID: Result<CKRecord, Error>] { + let operation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil) + operation.savePolicy = .changedKeys + operation.isAtomic = false + operation.qualityOfService = .utility + + var perRecordResults: [CKRecord.ID: Result<CKRecord, Error>] = [:] + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord.ID: Result<CKRecord, Error>], Error>) in + operation.perRecordSaveBlock = { recordID, result in + perRecordResults[recordID] = result + } + + operation.modifyRecordsResultBlock = { result in + switch result { + case .success: + continuation.resume(returning: perRecordResults) + case .failure(let error): + // If the overall operation failed but we got per-record + // results, return those so we can handle partial success. + if !perRecordResults.isEmpty { + continuation.resume(returning: perRecordResults) + } else { + continuation.resume(throwing: error) + } + } + } + + privateDatabase.add(operation) + } + } + + private nonisolated func lookupSystemFields( + recordName: String, + recordType: String, + in context: NSManagedObjectContext + ) -> Data? { + let entityName = recordType == "Game" ? "GameEntity" : "CellEntity" + let request = NSFetchRequest<NSManagedObject>(entityName: entityName) + request.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + request.fetchLimit = 1 + request.propertiesToFetch = ["ckSystemFields"] + guard let entity = try? context.fetch(request).first else { return nil } + return entity.value(forKey: "ckSystemFields") as? Data + } + + private nonisolated func writeBackSystemFields( + record: CKRecord, + recordName: String, + in context: NSManagedObjectContext + ) { + let entityName = record.recordType == "Game" ? "GameEntity" : "CellEntity" + let request = NSFetchRequest<NSManagedObject>(entityName: entityName) + request.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + request.fetchLimit = 1 + guard let entity = try? context.fetch(request).first else { return } + + entity.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields") + entity.setValue(Date(), forKey: "lastSyncedAt") + } + + private nonisolated func deletePendingChange(recordName: String, in context: NSManagedObjectContext) { + let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity") + request.predicate = NSPredicate(format: "recordName == %@", recordName) + request.fetchLimit = 1 + if let entity = try? context.fetch(request).first { + context.delete(entity) + } + } + + private nonisolated func handlePushError( + error: Error, + recordName: String, + in context: NSManagedObjectContext + ) { + guard let ckError = error as? CKError else { + print("SyncEngine: non-CK error pushing \(recordName): \(error)") + return + } + + switch ckError.code { + case .serverRecordChanged: + // The server has a newer version. Extract the server record and + // merge using LWW on our `updatedAt` field. + guard let serverRecord = ckError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else { + print("SyncEngine: serverRecordChanged but no server record for \(recordName)") + return + } + + // Read the pending change to get our local values + let pendingRequest = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity") + pendingRequest.predicate = NSPredicate(format: "recordName == %@", recordName) + pendingRequest.fetchLimit = 1 + guard let pending = try? context.fetch(pendingRequest).first, + let payloadJSON = pending.payload, + let data = payloadJSON.data(using: .utf8), + let localPayload = try? JSONDecoder().decode(PendingChangePayload.self, from: data) + else { return } + + let serverUpdatedAt = serverRecord["updatedAt"] as? Date ?? .distantPast + let localUpdatedAt = localPayload.updatedAt ?? .distantPast + + if localUpdatedAt >= serverUpdatedAt { + // Local wins — re-enqueue with the server record's system + // fields so the next push succeeds. + let systemFields = RecordSerializer.encodeSystemFields(of: serverRecord) + writeBackSystemFields(record: serverRecord, recordName: recordName, in: context) + + // The pending change stays in the outbox; next loop iteration + // will pick it up with the updated system fields. + _ = systemFields + } else { + // Server wins — drop our pending change and apply the server + // record to Core Data. + context.delete(pending) + if serverRecord.recordType == "Game" { + let _ = RecordSerializer.applyGameRecord(serverRecord, to: context) + } else { + // Find the parent game for the cell + if let parentRef = serverRecord.parent { + let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameRequest.predicate = NSPredicate(format: "ckRecordName == %@", parentRef.recordID.recordName) + gameRequest.fetchLimit = 1 + if let gameEntity = try? context.fetch(gameRequest).first { + let _ = RecordSerializer.applyCellRecord(serverRecord, to: context, game: gameEntity) + } + } + } + } + + default: + // For other errors (network, throttle, etc.), leave the pending + // change in the outbox for the next push attempt. + print("SyncEngine: error pushing \(recordName): \(ckError)") + } + } +} diff --git a/Crossmate/Sync/SyncState+Helpers.swift b/Crossmate/Sync/SyncState+Helpers.swift @@ -0,0 +1,40 @@ +import CloudKit +import CoreData +import Foundation + +extension SyncStateEntity { + /// Returns the singleton sync-state row, creating it on first access. + static func current(in context: NSManagedObjectContext) -> SyncStateEntity { + let request = NSFetchRequest<SyncStateEntity>(entityName: "SyncStateEntity") + request.predicate = NSPredicate(format: "id == 0") + request.fetchLimit = 1 + + if let existing = try? context.fetch(request).first { + return existing + } + + let entity = SyncStateEntity(context: context) + entity.id = 0 + return entity + } + + // MARK: - Change token coding + + var decodedPrivateDatabaseToken: CKServerChangeToken? { + get { privateDatabaseToken.flatMap(Self.decodeToken) } + set { privateDatabaseToken = newValue.flatMap(Self.encodeToken) } + } + + var decodedPrivateZoneToken: CKServerChangeToken? { + get { privateZoneToken.flatMap(Self.decodeToken) } + set { privateZoneToken = newValue.flatMap(Self.encodeToken) } + } + + private static func encodeToken(_ token: CKServerChangeToken) -> Data? { + try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) + } + + private static func decodeToken(_ data: Data) -> CKServerChangeToken? { + try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data) + } +} diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -14,8 +14,8 @@ struct GridView: View { let c = index % width CellView( cell: session.puzzle.cells[r][c], - entry: session.game.entries[r][c], - mark: session.game.marks[r][c], + entry: session.game.squares[r][c].entry, + mark: session.game.squares[r][c].mark, isSelected: session.selectedRow == r && session.selectedCol == c, isHighlighted: session.isInCurrentWord(row: r, col: c), specialKind: session.puzzle.specialKind diff --git a/Scripts/test-unit.sh b/Scripts/test-unit.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +MAJOR="${1:-26}" +RUNTIME=$(xcrun simctl list runtimes available \ + | grep "iOS ${MAJOR}\." \ + | sed 's/.*iOS \([0-9.]*\).*/\1/' \ + | sort -t. -k1,1n -k2,2n \ + | tail -1) + +if [ -z "$RUNTIME" ]; then + echo "No available iOS ${MAJOR}.x simulator runtime found." >&2 + exit 1 +fi + +DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \ + | grep "iPhone" \ + | head -1 \ + | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/') + +if [ -z "$DEVICE" ]; then + echo "No available iPhone simulator found for iOS ${RUNTIME}." >&2 + exit 1 +fi + +echo "Using ${DEVICE}, iOS ${RUNTIME}" +xcodebuild test \ + -scheme "Crossmate" \ + -destination "platform=iOS Simulator,name=${DEVICE},OS=${RUNTIME}" \ + -only-testing:"Crossmate Unit Tests" \ + 2>&1 diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Creates a fresh PersistenceController with in-memory storage for isolated testing. +@MainActor +func makeTestPersistence() -> PersistenceController { + PersistenceController(inMemory: true) +} + +/// Creates a Game, GameEntity, and GameMutator backed by an in-memory store. +/// The puzzle is a minimal 3x3 grid with a single block at (1,1). +@MainActor +func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceController) { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + let source = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + let xd = try XD.parse(source) + let puzzle = Puzzle(xd: xd) + let game = Game(puzzle: puzzle) + + let gameID = UUID() + let entity = GameEntity(context: context) + entity.id = gameID + entity.title = puzzle.title + entity.puzzleSource = source + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + + for row in puzzle.cells { + for cell in row where !cell.isBlock { + let cellEntity = CellEntity(context: context) + cellEntity.row = Int16(cell.row) + cellEntity.col = Int16(cell.col) + cellEntity.letter = "" + cellEntity.markKind = 0 + cellEntity.checkedWrong = false + cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)" + cellEntity.game = entity + } + } + + try context.save() + + let mutator = GameMutator(game: game, gameEntity: entity, context: context) + return (game, mutator, entity, persistence) +} diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -0,0 +1,213 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("GameMutator", .serialized) +@MainActor +struct GameMutatorTests { + + // MARK: - Basic mutations + + @Test("setLetter writes entry and mark to game") + func setLetterWritesToGame() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local) + + #expect(game.squares[0][0].entry == "A") + #expect(game.squares[0][0].mark == .none) + #expect(game.squares[0][0].updatedAt != nil) + } + + @Test("setLetter in pencil mode sets pencil mark") + func setLetterPencilMode() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.setLetter("B", atRow: 0, atCol: 1, pencil: true, origin: .local) + + #expect(game.squares[0][1].entry == "B") + #expect(game.squares[0][1].mark == .pencil(checkedWrong: false)) + } + + @Test("clearLetter clears entry and mark") + func clearLetterClearsEntry() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local) + mutator.clearLetter(atRow: 0, atCol: 0, origin: .local) + + #expect(game.squares[0][0].entry == "") + #expect(game.squares[0][0].mark == .none) + #expect(game.squares[0][0].letterAuthorID == nil) + } + + // MARK: - LWW + + @Test("Remote mutation with newer timestamp overwrites local") + func remoteNewerOverwritesLocal() throws { + let (game, mutator, _, _) = try makeTestGame() + + let earlier = Date(timeIntervalSinceNow: -10) + let later = Date(timeIntervalSinceNow: 10) + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: earlier, authorID: "user1")) + mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: later, authorID: "user2")) + + #expect(game.squares[0][0].entry == "B") + #expect(game.squares[0][0].letterAuthorID == "user2") + } + + @Test("Remote mutation with older timestamp is rejected") + func remoteOlderIsRejected() throws { + let (game, mutator, _, _) = try makeTestGame() + + let earlier = Date(timeIntervalSinceNow: -10) + let later = Date(timeIntervalSinceNow: 10) + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: later, authorID: "user1")) + mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: earlier, authorID: "user2")) + + #expect(game.squares[0][0].entry == "A") + #expect(game.squares[0][0].letterAuthorID == "user1") + } + + @Test("Remote mutation with equal timestamp is accepted") + func remoteEqualTimestampAccepted() throws { + let (game, mutator, _, _) = try makeTestGame() + + let t = Date() + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: t, authorID: "user1")) + mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: t, authorID: "user2")) + + #expect(game.squares[0][0].entry == "B") + } + + // MARK: - Persistence + + @Test("setLetter persists to CellEntity") + func setLetterPersistsToCellEntity() throws { + let (_, mutator, entity, persistence) = try makeTestGame() + let context = persistence.viewContext + + mutator.setLetter("X", atRow: 0, atCol: 2, pencil: false, origin: .local) + + let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] + let cell = cellEntities.first { $0.row == 0 && $0.col == 2 } + + #expect(cell?.letter == "X") + #expect(cell?.updatedAt != nil) + } + + // MARK: - PendingChange outbox + + @Test("Local mutation enqueues a PendingChange") + func localMutationEnqueuesPendingChange() throws { + let (_, mutator, _, persistence) = try makeTestGame() + let context = persistence.viewContext + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local) + + let pending = PendingChangeEntity.drain(limit: 10, in: context) + #expect(pending.count == 1) + #expect(pending[0].recordType == "Cell") + #expect(pending[0].recordName?.contains("cell-") == true) + } + + @Test("Remote mutation does not enqueue a PendingChange") + func remoteMutationDoesNotEnqueue() throws { + let (_, mutator, _, persistence) = try makeTestGame() + let context = persistence.viewContext + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: Date(), authorID: "user1")) + + let pending = PendingChangeEntity.drain(limit: 10, in: context) + #expect(pending.isEmpty) + } + + @Test("Multiple local mutations to same cell coalesce into one PendingChange") + func localMutationsCoalesce() throws { + let (_, mutator, _, persistence) = try makeTestGame() + let context = persistence.viewContext + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local) + mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false, origin: .local) + mutator.setLetter("C", atRow: 0, atCol: 0, pencil: false, origin: .local) + + let pending = PendingChangeEntity.drain(limit: 10, in: context) + #expect(pending.count == 1) + + // The payload should reflect the latest value + let data = pending[0].payload!.data(using: .utf8)! + let payload = try JSONDecoder().decode(PendingChangePayload.self, from: data) + #expect(payload.letter == "C") + } + + // MARK: - onLocalMutation callback + + @Test("onLocalMutation fires on local mutation") + func onLocalMutationFires() throws { + let (_, mutator, _, _) = try makeTestGame() + var callCount = 0 + mutator.onLocalMutation = { callCount += 1 } + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local) + + #expect(callCount == 1) + } + + @Test("onLocalMutation does not fire on remote mutation") + func onLocalMutationSilentForRemote() throws { + let (_, mutator, _, _) = try makeTestGame() + var callCount = 0 + mutator.onLocalMutation = { callCount += 1 } + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, + origin: .remote(timestamp: Date(), authorID: "user1")) + + #expect(callCount == 0) + } + + // MARK: - Bulk mutations + + @Test("checkCells marks wrong entries via mutator") + func checkCellsMarksWrong() throws { + let (game, mutator, _, _) = try makeTestGame() + + // Cell (0,0) has solution "A", enter "Z" + mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false, origin: .local) + mutator.checkCells([game.puzzle.cells[0][0]], origin: .local) + + #expect(game.squares[0][0].mark == .pen(checkedWrong: true)) + } + + @Test("revealCells sets entry to solution and marks revealed") + func revealCellsSetsAnswer() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.revealCells([game.puzzle.cells[0][0]], origin: .local) + + #expect(game.squares[0][0].entry == "A") + #expect(game.squares[0][0].mark == .revealed) + } + + @Test("clearCells clears non-revealed cells") + func clearCellsClearsNonRevealed() throws { + let (game, mutator, _, _) = try makeTestGame() + + mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false, origin: .local) + mutator.clearCells([game.puzzle.cells[0][0]], origin: .local) + + #expect(game.squares[0][0].entry == "") + #expect(game.squares[0][0].mark == .none) + } +} diff --git a/Tests/Unit/PendingChangeTests.swift b/Tests/Unit/PendingChangeTests.swift @@ -0,0 +1,133 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PendingChange+Helpers", .serialized) +@MainActor +struct PendingChangeTests { + + @Test("Upsert creates a new row") + func upsertCreatesNew() { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + PendingChangeEntity.upsert( + recordName: "cell-1", + recordType: "Cell", + payload: "{\"letter\":\"A\"}", + in: context + ) + try? context.save() + + let results = PendingChangeEntity.drain(limit: 10, in: context) + #expect(results.count == 1) + #expect(results[0].recordName == "cell-1") + #expect(results[0].payload == "{\"letter\":\"A\"}") + } + + @Test("Upsert with same recordName updates existing row") + func upsertUpdatesExisting() { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + PendingChangeEntity.upsert( + recordName: "cell-1", + recordType: "Cell", + payload: "{\"letter\":\"A\"}", + in: context + ) + try? context.save() + + PendingChangeEntity.upsert( + recordName: "cell-1", + recordType: "Cell", + payload: "{\"letter\":\"B\"}", + in: context + ) + try? context.save() + + let results = PendingChangeEntity.drain(limit: 10, in: context) + #expect(results.count == 1) + #expect(results[0].payload == "{\"letter\":\"B\"}") + } + + @Test("Upsert with different recordNames creates separate rows") + func upsertDifferentNames() { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + PendingChangeEntity.upsert( + recordName: "cell-1", + recordType: "Cell", + payload: "{}", + in: context + ) + PendingChangeEntity.upsert( + recordName: "cell-2", + recordType: "Cell", + payload: "{}", + in: context + ) + try? context.save() + + let results = PendingChangeEntity.drain(limit: 10, in: context) + #expect(results.count == 2) + } + + @Test("Drain returns oldest first") + func drainReturnsOldestFirst() throws { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + PendingChangeEntity.upsert( + recordName: "cell-old", + recordType: "Cell", + payload: "{}", + in: context + ) + // Ensure different timestamps + Thread.sleep(forTimeInterval: 0.01) + PendingChangeEntity.upsert( + recordName: "cell-new", + recordType: "Cell", + payload: "{}", + in: context + ) + try context.save() + + let results = PendingChangeEntity.drain(limit: 10, in: context) + #expect(results.count == 2) + #expect(results[0].recordName == "cell-old") + #expect(results[1].recordName == "cell-new") + } + + @Test("Drain respects limit") + func drainRespectsLimit() { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + for i in 0..<5 { + PendingChangeEntity.upsert( + recordName: "cell-\(i)", + recordType: "Cell", + payload: "{}", + in: context + ) + } + try? context.save() + + let results = PendingChangeEntity.drain(limit: 3, in: context) + #expect(results.count == 3) + } + + @Test("Drain returns empty array when no pending changes") + func drainEmptyWhenNone() { + let persistence = makeTestPersistence() + let context = persistence.viewContext + + let results = PendingChangeEntity.drain(limit: 10, in: context) + #expect(results.isEmpty) + } +} diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -0,0 +1,112 @@ +import CloudKit +import Foundation +import Testing + +@testable import Crossmate + +@Suite("RecordSerializer") +struct RecordSerializerTests { + + // MARK: - Record name generation + + @Test("Game record name uses expected format") + func gameRecordNameFormat() { + let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! + let name = RecordSerializer.recordName(forGameID: id) + #expect(name == "game-12345678-1234-1234-1234-123456789ABC") + } + + @Test("Cell record name uses expected format") + func cellRecordNameFormat() { + let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! + let name = RecordSerializer.recordName(forCellInGame: id, row: 3, col: 7) + #expect(name == "cell-12345678-1234-1234-1234-123456789ABC-3-7") + } + + @Test("Record names are deterministic") + func recordNamesAreDeterministic() { + let id = UUID() + let a = RecordSerializer.recordName(forGameID: id) + let b = RecordSerializer.recordName(forGameID: id) + #expect(a == b) + + let c = RecordSerializer.recordName(forCellInGame: id, row: 0, col: 0) + let d = RecordSerializer.recordName(forCellInGame: id, row: 0, col: 0) + #expect(c == d) + } + + // MARK: - Zone + + @Test("Zone name is CrossmateZone") + func zoneNameIsCorrect() { + let zone = RecordSerializer.zone() + #expect(zone.zoneID.zoneName == "CrossmateZone") + } + + // MARK: - System fields round-trip + + @Test("Encode and decode system fields preserves record type and zone") + func systemFieldsRoundTrip() { + let zoneID = RecordSerializer.zoneID() + let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID) + let original = CKRecord(recordType: "Cell", recordID: recordID) + + let encoded = RecordSerializer.encodeSystemFields(of: original) + #expect(encoded != nil) + + let decoded = RecordSerializer.decodeRecord(from: encoded!) + #expect(decoded != nil) + #expect(decoded?.recordType == "Cell") + #expect(decoded?.recordID.zoneID.zoneName == "CrossmateZone") + #expect(decoded?.recordID.recordName == "test-record") + } +} + +@Suite("PendingChangePayload") +struct PendingChangePayloadTests { + + @Test("Cell payload round-trips through JSON") + func cellPayloadRoundTrip() throws { + let now = Date() + let original = PendingChangePayload( + recordType: .cell, + recordName: "cell-uuid-0-1", + letter: "A", + markKind: 2, + checkedWrong: false, + updatedAt: now, + letterAuthorID: "user123", + parentGameRecordName: "game-uuid" + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PendingChangePayload.self, from: data) + + #expect(decoded.recordType == .cell) + #expect(decoded.recordName == "cell-uuid-0-1") + #expect(decoded.letter == "A") + #expect(decoded.markKind == 2) + #expect(decoded.checkedWrong == false) + #expect(decoded.letterAuthorID == "user123") + #expect(decoded.parentGameRecordName == "game-uuid") + } + + @Test("Game payload round-trips through JSON") + func gamePayloadRoundTrip() throws { + let original = PendingChangePayload( + recordType: .game, + recordName: "game-uuid", + title: "My Puzzle", + puzzleSource: "Title: Test\n\nAB\nCD", + completedAt: nil + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PendingChangePayload.self, from: data) + + #expect(decoded.recordType == .game) + #expect(decoded.title == "My Puzzle") + #expect(decoded.puzzleSource == "Title: Test\n\nAB\nCD") + #expect(decoded.completedAt == nil) + } +} diff --git a/project.yml b/project.yml @@ -29,6 +29,8 @@ targets: CFBundleVersion: "1" ITSAppUsesNonExemptEncryption: false LSRequiresIPhoneOS: true + UIBackgroundModes: + - remote-notification UILaunchScreen: {} UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait @@ -37,9 +39,25 @@ targets: settings: PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate INFOPLIST_FILE: Crossmate/Info.plist + CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements TARGETED_DEVICE_FAMILY: "1" CODE_SIGN_STYLE: Automatic + Crossmate Unit Tests: + type: bundle.unit-test + platform: iOS + sources: + - Tests/Unit + - Tests/Support + dependencies: + - target: Crossmate + settings: + PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate.unittests + CODE_SIGN_STYLE: Automatic + TEST_HOST: $(BUILT_PRODUCTS_DIR)/Crossmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Crossmate + BUNDLE_LOADER: $(TEST_HOST) + GENERATE_INFOPLIST_FILE: YES + schemes: Crossmate: build: @@ -47,6 +65,12 @@ schemes: Crossmate: all run: config: Debug + test: + config: Debug + targets: + - name: Crossmate Unit Tests + parallelizable: true + randomExecutionOrder: true profile: config: Release analyze: