crossmate

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

commit 1835d68e6c8bcba1e6df4f392e2a41997b2790ac
parent 5e688df84884a75c038e09b923b2910c210a5fbb
Author: Michael Camilleri <[email protected]>
Date:   Mon, 11 May 2026 01:42:54 +0900

Use publisher as a consistent name

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 32++++++++++++++++----------------
MCrossmate/CrossmateApp.swift | 22+++++++++++-----------
MCrossmate/Models/PlayerSession.swift | 2+-
MCrossmate/Persistence/GameStore.swift | 2+-
MCrossmate/Services/AppServices.swift | 10+++++-----
DCrossmate/Services/NameBroadcaster.swift | 133-------------------------------------------------------------------------------
ACrossmate/Services/PlayerNamePublisher.swift | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/PlayerSelectionPublisher.swift | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DCrossmate/Sync/PresencePublisher.swift | 162-------------------------------------------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 4++--
DTests/Unit/NameBroadcasterTests.swift | 160-------------------------------------------------------------------------------
ATests/Unit/PlayerNamePublisherTests.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/PlayerSelectionPublisherTests.swift | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DTests/Unit/PresencePublisherTests.swift | 277-------------------------------------------------------------------------------
14 files changed, 768 insertions(+), 768 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; }; 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; - 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; @@ -23,10 +22,12 @@ 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; + 309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; }; 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; }; 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; + 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; }; 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; }; 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; @@ -41,7 +42,6 @@ 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; - 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; @@ -63,6 +63,7 @@ AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; + B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; }; B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; }; B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; }; BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; }; @@ -74,15 +75,14 @@ C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; - CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */; }; CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; }; + CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */; }; CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */; }; CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; - D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */; }; DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */; }; DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; }; @@ -112,11 +112,11 @@ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; + 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; }; 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; - 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisher.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; @@ -134,13 +134,13 @@ 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; }; 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; - 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; }; 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; + 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStore.swift; sourceTree = "<group>"; }; 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; }; @@ -148,6 +148,7 @@ 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; }; 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; }; 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; }; + 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; @@ -164,11 +165,9 @@ 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; - 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; - ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisherTests.swift; sourceTree = "<group>"; }; AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; }; B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; @@ -203,6 +202,7 @@ 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>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; + FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -222,7 +222,7 @@ 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */, 86470163BFF956F3DE438506 /* Moves.swift */, 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */, - 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */, + 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, @@ -248,13 +248,13 @@ D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, - 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, + 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, + FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */, 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */, - ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */, B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */, C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, @@ -395,10 +395,10 @@ 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, - 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, + 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */, B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */, ); path = Services; @@ -509,13 +509,13 @@ C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, - 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, + CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, + 309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */, 00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */, - 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */, 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */, @@ -567,17 +567,17 @@ D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */, 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */, B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, - CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */, DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */, 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */, CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */, 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, + 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */, F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */, 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */, 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */, + B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, - D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -394,12 +394,12 @@ private struct PuzzleDisplayView: View { openPuzzleFollowUpTask?.cancel() openPuzzleFollowUpTask = nil NotificationState.clearActivePuzzleID(if: gameID) - let presence = services.presencePublisher + let selectionPublisher = services.playerSelectionPublisher let movesUpdater = services.movesUpdater let exitedID = gameID Task { await movesUpdater.flush() - await presence.clear() + await selectionPublisher.clear() await movesUpdater.noteSessionEnded(gameID: exitedID) } } @@ -422,7 +422,7 @@ private struct PuzzleDisplayView: View { services.syncMonitor.note( "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster" ) - await services.presencePublisher.clear() + await services.playerSelectionPublisher.clear() } } @@ -434,7 +434,7 @@ private struct PuzzleDisplayView: View { } } - /// Initialises shared-game state (roster, presence, name broadcast) for + /// Initialises shared-game state (roster, selection publishing, name broadcast) for /// the open session. Called when the puzzle first appears as shared, and /// again if a previously-solo game becomes shared mid-session. private func activateSharing(for session: PlayerSession, refreshRoster: Bool = true) async { @@ -451,19 +451,19 @@ private struct PuzzleDisplayView: View { await activeRoster.refresh() } // Fan out the local user's name to every shared/joined game's zone - // before any presence write — otherwise the partner sees "Player" - // until we happen to rename ourselves and trigger NameBroadcaster's + // before any selection publish — otherwise the partner sees "Player" + // until we happen to rename ourselves and trigger PlayerNamePublisher's // observer. Idempotent if the name has already been broadcast. - await services.nameBroadcaster?.broadcastName() + await services.playerNamePublisher?.broadcastName() guard let authorID = services.identity.currentID else { return } - let presence = services.presencePublisher - await presence.begin( + let selectionPublisher = services.playerSelectionPublisher + await selectionPublisher.begin( gameID: gameID, authorID: authorID, currentName: preferences.name ) session.onSelectionChanged = { selection in - Task { await presence.publish(selection) } + Task { await selectionPublisher.publish(selection) } } let syncEngine = services.syncEngine let identity = services.identity @@ -489,7 +489,7 @@ private struct PuzzleDisplayView: View { col: session.selectedCol, direction: session.direction ) - await presence.publish(initial) + await selectionPublisher.publish(initial) } private func pollOpenSharedPuzzle() async { diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -23,7 +23,7 @@ final class PlayerSession { var isPencilMode: Bool = false /// Optional sink fired whenever the cursor moves. Wired to - /// `PresencePublisher` from the puzzle view so the local selection is + /// `PlayerSelectionPublisher` from the puzzle view so the local selection is /// debounced + pushed to CloudKit, and the peer can render the outline. /// Unset for solo (non-shared) games. var onSelectionChanged: ((PlayerSelection) -> Void)? diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -670,7 +670,7 @@ final class GameStore { /// Flips the active game's mutator to shared after `ShareController` /// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster, - /// starts publishing presence) without requiring the user to re-open. + /// starts publishing the local selection) without requiring the user to re-open. func markShared(gameID: UUID) { guard currentEntity?.id == gameID else { return } currentMutator?.isShared = true diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -13,7 +13,7 @@ final class AppServices { let nytFetcher: NYTPuzzleFetcher let inputMonitor: InputMonitor let movesUpdater: MovesUpdater - let presencePublisher: PresencePublisher + let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let shareController: ShareController let colorStore: GamePlayerColorStore @@ -25,7 +25,7 @@ final class AppServices { private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") private var started = false private var syncStarted = false - private(set) var nameBroadcaster: NameBroadcaster? + private(set) var playerNamePublisher: PlayerNamePublisher? private var isReadyForShareAcceptance = false private var isProcessingShareAcceptanceQueue = false private var pendingShareMetadatas: [CKShare.Metadata] = [] @@ -107,7 +107,7 @@ final class AppServices { self.shareController.onShareSaved = { [weak store] gameID in store?.markShared(gameID: gameID) } - self.presencePublisher = PresencePublisher( + self.playerSelectionPublisher = PlayerSelectionPublisher( persistence: persistence, sink: { gameID, authorID in let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } @@ -192,10 +192,10 @@ final class AppServices { ) } - // NameBroadcaster fans out name changes to all shared/joined games. + // PlayerNamePublisher fans out name changes to all shared/joined games. // PuzzleDisplayView also calls `broadcastName()` when a shared puzzle // is opened, which covers first-sync-after-share-create / accept. - nameBroadcaster = NameBroadcaster( + playerNamePublisher = PlayerNamePublisher( preferences: preferences, persistence: persistence, authorIdentity: identity, diff --git a/Crossmate/Services/NameBroadcaster.swift b/Crossmate/Services/NameBroadcaster.swift @@ -1,133 +0,0 @@ -import CoreData -import Foundation -import Observation - -/// Observes `PlayerPreferences.name` and writes a per-(game, author) -/// `PlayerEntity` for every shared or joined game when the name changes, -/// so remote participants see the updated display name within one sync cycle. -/// -/// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and -/// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call; -/// a single rename should produce exactly one fan-out. -@MainActor -final class NameBroadcaster { - private let preferences: PlayerPreferences - private let persistence: PersistenceController - private let authorIdentity: AuthorIdentity - private let enqueuePlayerRecord: (UUID, String) async -> Void - - private var debounceTask: Task<Void, Never>? - private var observationTask: Task<Void, Never>? - - /// Testing-only observer fired after each fan-out completes. Receives the - /// name that was just broadcast. Production callers should leave this nil. - var onFanOutForTesting: ((String) -> Void)? - - init( - preferences: PlayerPreferences, - persistence: PersistenceController, - authorIdentity: AuthorIdentity, - enqueuePlayerRecord: @escaping (UUID, String) async -> Void - ) { - self.preferences = preferences - self.persistence = persistence - self.authorIdentity = authorIdentity - self.enqueuePlayerRecord = enqueuePlayerRecord - startObserving() - } - - private func startObserving() { - observationTask = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in - withObservationTracking { - _ = self.preferences.name - } onChange: { - cont.resume() - } - } - guard !Task.isCancelled else { break } - self.scheduleDebounce() - } - } - } - - private func scheduleDebounce() { - debounceTask?.cancel() - debounceTask = Task { [weak self] in - do { - try await Task.sleep(for: .milliseconds(250)) - } catch { - return - } - guard let self, !Task.isCancelled else { return } - await self.fanOut(newName: self.preferences.name) - } - } - - /// Writes the local user's name into the `PlayerEntity` row for every - /// shared or joined game and asks the sync engine to push each one. Called - /// directly on game-share creation/accept so the partner sees a name on - /// the very first sync. - func broadcastName() async { - await fanOut(newName: preferences.name) - } - - private func fanOut(newName: String) async { - guard let authorID = authorIdentity.currentID else { return } - - let ctx = persistence.container.newBackgroundContext() - ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let touchedGameIDs = Self.upsertPlayerRecords( - in: ctx, - authorID: authorID, - name: newName - ) - - for gameID in touchedGameIDs { - await enqueuePlayerRecord(gameID, authorID) - } - - onFanOutForTesting?(newName) - } - - /// Background-context work — main-actor isolation does not apply here. - private nonisolated static func upsertPlayerRecords( - in ctx: NSManagedObjectContext, - authorID: String, - name: String - ) -> [UUID] { - ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate( - format: "ckShareRecordName != nil OR databaseScope == 1" - ) - let games = (try? ctx.fetch(req)) ?? [] - var ids: [UUID] = [] - let now = Date() - for game in games { - guard let gameID = game.id else { continue } - let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) - let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName) - lookup.fetchLimit = 1 - - let entity: PlayerEntity - if let existing = try? ctx.fetch(lookup).first { - entity = existing - } else { - entity = PlayerEntity(context: ctx) - entity.game = game - entity.ckRecordName = recordName - entity.authorID = authorID - } - entity.name = name - entity.updatedAt = now - ids.append(gameID) - } - if ctx.hasChanges { try? ctx.save() } - return ids - } - } -} diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift @@ -0,0 +1,133 @@ +import CoreData +import Foundation +import Observation + +/// Observes `PlayerPreferences.name` and writes a per-(game, author) +/// `PlayerEntity` for every shared or joined game when the name changes, +/// so remote participants see the updated display name within one sync cycle. +/// +/// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and +/// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call; +/// a single rename should produce exactly one fan-out. +@MainActor +final class PlayerNamePublisher { + private let preferences: PlayerPreferences + private let persistence: PersistenceController + private let authorIdentity: AuthorIdentity + private let enqueuePlayerRecord: (UUID, String) async -> Void + + private var debounceTask: Task<Void, Never>? + private var observationTask: Task<Void, Never>? + + /// Testing-only observer fired after each fan-out completes. Receives the + /// name that was just broadcast. Production callers should leave this nil. + var onFanOutForTesting: ((String) -> Void)? + + init( + preferences: PlayerPreferences, + persistence: PersistenceController, + authorIdentity: AuthorIdentity, + enqueuePlayerRecord: @escaping (UUID, String) async -> Void + ) { + self.preferences = preferences + self.persistence = persistence + self.authorIdentity = authorIdentity + self.enqueuePlayerRecord = enqueuePlayerRecord + startObserving() + } + + private func startObserving() { + observationTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in + withObservationTracking { + _ = self.preferences.name + } onChange: { + cont.resume() + } + } + guard !Task.isCancelled else { break } + self.scheduleDebounce() + } + } + } + + private func scheduleDebounce() { + debounceTask?.cancel() + debounceTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(250)) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + await self.fanOut(newName: self.preferences.name) + } + } + + /// Writes the local user's name into the `PlayerEntity` row for every + /// shared or joined game and asks the sync engine to push each one. Called + /// directly on game-share creation/accept so the partner sees a name on + /// the very first sync. + func broadcastName() async { + await fanOut(newName: preferences.name) + } + + private func fanOut(newName: String) async { + guard let authorID = authorIdentity.currentID else { return } + + let ctx = persistence.container.newBackgroundContext() + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let touchedGameIDs = Self.upsertPlayerRecords( + in: ctx, + authorID: authorID, + name: newName + ) + + for gameID in touchedGameIDs { + await enqueuePlayerRecord(gameID, authorID) + } + + onFanOutForTesting?(newName) + } + + /// Background-context work — main-actor isolation does not apply here. + private nonisolated static func upsertPlayerRecords( + in ctx: NSManagedObjectContext, + authorID: String, + name: String + ) -> [UUID] { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "ckShareRecordName != nil OR databaseScope == 1" + ) + let games = (try? ctx.fetch(req)) ?? [] + var ids: [UUID] = [] + let now = Date() + for game in games { + guard let gameID = game.id else { continue } + let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) + let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + lookup.fetchLimit = 1 + + let entity: PlayerEntity + if let existing = try? ctx.fetch(lookup).first { + entity = existing + } else { + entity = PlayerEntity(context: ctx) + entity.game = game + entity.ckRecordName = recordName + entity.authorID = authorID + } + entity.name = name + entity.updatedAt = now + ids.append(gameID) + } + if ctx.hasChanges { try? ctx.save() } + return ids + } + } +} diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift @@ -0,0 +1,162 @@ +import CoreData +import Foundation + +/// Debounced writer for the local player's cursor selection. Updates the +/// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/ +/// `selDir` and asks the sync engine to push the Player record. Cursor edits +/// don't go through `MovesUpdater` because they aren't cell edits — they live +/// on `PlayerEntity` with last-writer-wins semantics. +actor PlayerSelectionPublisher { + private let debounceInterval: Duration + private let persistence: PersistenceController + private let sink: @Sendable (UUID, String) async -> Void + + private var pending: PlayerSelection? + private var lastPublished: PlayerSelection? + private var debounceTask: Task<Void, Never>? + private var gameID: UUID? + private var authorID: String? + /// The local user's display name at session start. Used as a fallback + /// when `write` has to insert a fresh `PlayerEntity` row before + /// `PlayerNamePublisher` has fanned out — without it the row would have no + /// name and the SyncEngine would refuse to build a CKRecord, dropping + /// the cursor on the floor. + private var fallbackName: String = "" + + init( + debounceInterval: Duration = .milliseconds(300), + persistence: PersistenceController, + sink: @escaping @Sendable (UUID, String) async -> Void + ) { + self.debounceInterval = debounceInterval + self.persistence = persistence + self.sink = sink + } + + /// Starts publishing for a new puzzle session. Resets dedupe state so the + /// first selection from the new session always flushes. `currentName` is + /// the local user's display name at the time the puzzle was opened — used + /// only when no PlayerEntity row exists yet for this (game, author). + func begin(gameID: UUID, authorID: String, currentName: String) { + self.gameID = gameID + self.authorID = authorID + fallbackName = currentName + pending = nil + lastPublished = nil + debounceTask?.cancel() + debounceTask = nil + } + + /// Registers a new selection. Coalesces with any prior pending value and + /// schedules a trailing-edge flush. Repeated identical selections are + /// dropped. + func publish(_ selection: PlayerSelection) { + guard gameID != nil, authorID != nil else { return } + if pending == selection || (pending == nil && lastPublished == selection) { + return + } + pending = selection + scheduleDebounce() + } + + /// Records a "no selection" — used on puzzle teardown so the peer's + /// outline disappears promptly instead of waiting for staleness. + func clear() { + guard gameID != nil, authorID != nil else { return } + pending = nil + debounceTask?.cancel() + debounceTask = nil + Task { await self.flushClear() } + } + + /// Flushes any pending selection immediately and cancels the debounce. + func flush() async { + debounceTask?.cancel() + debounceTask = nil + await performFlush() + } + + private func scheduleDebounce() { + debounceTask?.cancel() + let interval = debounceInterval + debounceTask = Task { [weak self] in + try? await Task.sleep(for: interval) + if Task.isCancelled { return } + await self?.debouncedFlush() + } + } + + private func debouncedFlush() async { + debounceTask = nil + await performFlush() + } + + private func performFlush() async { + guard let gameID, let authorID, let selection = pending else { return } + if selection == lastPublished { return } + pending = nil + lastPublished = selection + await write(gameID: gameID, authorID: authorID, selection: selection) + await sink(gameID, authorID) + } + + private func flushClear() async { + guard let gameID, let authorID else { return } + if lastPublished == nil { return } + lastPublished = nil + await write(gameID: gameID, authorID: authorID, selection: nil) + await sink(gameID, authorID) + } + + private func write( + gameID: UUID, + authorID: String, + selection: PlayerSelection? + ) async { + let context = persistence.container.newBackgroundContext() + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let now = Date() + let fallbackName = self.fallbackName + context.performAndWait { + let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + req.fetchLimit = 1 + let entity: PlayerEntity + if let existing = try? context.fetch(req).first { + entity = existing + // Don't overwrite a name that PlayerNamePublisher has set; only + // backfill if it's missing or empty so the outgoing record is + // never `name=""`. + if (entity.name ?? "").isEmpty, !fallbackName.isEmpty { + entity.name = fallbackName + } + } else { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let game = try? context.fetch(gameReq).first else { return } + entity = PlayerEntity(context: context) + entity.game = game + entity.ckRecordName = recordName + entity.authorID = authorID + if !fallbackName.isEmpty { + entity.name = fallbackName + } + } + entity.updatedAt = now + if let selection { + entity.selRow = NSNumber(value: Int64(selection.row)) + entity.selCol = NSNumber(value: Int64(selection.col)) + entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) + } else { + entity.selRow = nil + entity.selCol = nil + entity.selDir = nil + } + if context.hasChanges { + try? context.save() + } + } + } +} diff --git a/Crossmate/Sync/PresencePublisher.swift b/Crossmate/Sync/PresencePublisher.swift @@ -1,162 +0,0 @@ -import CoreData -import Foundation - -/// Debounced writer for the local player's cursor selection. Updates the -/// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/ -/// `selDir` and asks the sync engine to push the Player record. Cursor edits -/// don't go through `MovesUpdater` because they aren't cell edits — they live -/// on `PlayerEntity` with last-writer-wins semantics. -actor PresencePublisher { - private let debounceInterval: Duration - private let persistence: PersistenceController - private let sink: @Sendable (UUID, String) async -> Void - - private var pending: PlayerSelection? - private var lastPublished: PlayerSelection? - private var debounceTask: Task<Void, Never>? - private var gameID: UUID? - private var authorID: String? - /// The local user's display name at session start. Used as a fallback - /// when `write` has to insert a fresh `PlayerEntity` row before - /// `NameBroadcaster` has fanned out — without it the row would have no - /// name and the SyncEngine would refuse to build a CKRecord, dropping - /// the cursor on the floor. - private var fallbackName: String = "" - - init( - debounceInterval: Duration = .milliseconds(300), - persistence: PersistenceController, - sink: @escaping @Sendable (UUID, String) async -> Void - ) { - self.debounceInterval = debounceInterval - self.persistence = persistence - self.sink = sink - } - - /// Starts publishing for a new puzzle session. Resets dedupe state so the - /// first selection from the new session always flushes. `currentName` is - /// the local user's display name at the time the puzzle was opened — used - /// only when no PlayerEntity row exists yet for this (game, author). - func begin(gameID: UUID, authorID: String, currentName: String) { - self.gameID = gameID - self.authorID = authorID - fallbackName = currentName - pending = nil - lastPublished = nil - debounceTask?.cancel() - debounceTask = nil - } - - /// Registers a new selection. Coalesces with any prior pending value and - /// schedules a trailing-edge flush. Repeated identical selections are - /// dropped. - func publish(_ selection: PlayerSelection) { - guard gameID != nil, authorID != nil else { return } - if pending == selection || (pending == nil && lastPublished == selection) { - return - } - pending = selection - scheduleDebounce() - } - - /// Records a "no selection" — used on puzzle teardown so the peer's - /// outline disappears promptly instead of waiting for staleness. - func clear() { - guard gameID != nil, authorID != nil else { return } - pending = nil - debounceTask?.cancel() - debounceTask = nil - Task { await self.flushClear() } - } - - /// Flushes any pending selection immediately and cancels the debounce. - func flush() async { - debounceTask?.cancel() - debounceTask = nil - await performFlush() - } - - private func scheduleDebounce() { - debounceTask?.cancel() - let interval = debounceInterval - debounceTask = Task { [weak self] in - try? await Task.sleep(for: interval) - if Task.isCancelled { return } - await self?.debouncedFlush() - } - } - - private func debouncedFlush() async { - debounceTask = nil - await performFlush() - } - - private func performFlush() async { - guard let gameID, let authorID, let selection = pending else { return } - if selection == lastPublished { return } - pending = nil - lastPublished = selection - await write(gameID: gameID, authorID: authorID, selection: selection) - await sink(gameID, authorID) - } - - private func flushClear() async { - guard let gameID, let authorID else { return } - if lastPublished == nil { return } - lastPublished = nil - await write(gameID: gameID, authorID: authorID, selection: nil) - await sink(gameID, authorID) - } - - private func write( - gameID: UUID, - authorID: String, - selection: PlayerSelection? - ) async { - let context = persistence.container.newBackgroundContext() - context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let now = Date() - let fallbackName = self.fallbackName - context.performAndWait { - let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) - let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) - req.fetchLimit = 1 - let entity: PlayerEntity - if let existing = try? context.fetch(req).first { - entity = existing - // Don't overwrite a name that NameBroadcaster has set; only - // backfill if it's missing or empty so the outgoing record is - // never `name=""`. - if (entity.name ?? "").isEmpty, !fallbackName.isEmpty { - entity.name = fallbackName - } - } else { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - gameReq.fetchLimit = 1 - guard let game = try? context.fetch(gameReq).first else { return } - entity = PlayerEntity(context: context) - entity.game = game - entity.ckRecordName = recordName - entity.authorID = authorID - if !fallbackName.isEmpty { - entity.name = fallbackName - } - } - entity.updatedAt = now - if let selection { - entity.selRow = NSNumber(value: Int64(selection.row)) - entity.selCol = NSNumber(value: Int64(selection.col)) - entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) - } else { - entity.selRow = nil - entity.selCol = nil - entity.selDir = nil - } - if context.hasChanges { - try? context.save() - } - } - } -} diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -401,7 +401,7 @@ actor SyncEngine { ) } - /// Registers a Player record as a pending send. Used by `NameBroadcaster` + /// Registers a Player record as a pending send. Used by `PlayerNamePublisher` /// when the local user renames; one record per (game, authorID), so /// participants only ever write their own slot. func enqueuePlayerRecord(gameID: UUID, authorID: String) { @@ -1061,7 +1061,7 @@ actor SyncEngine { let localUpdatedAt = entity.updatedAt let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true guard incomingIsFresher else { return } - // An empty `name` is what older builds shipped from PresencePublisher + // An empty `name` is what older builds shipped from the selection publisher // before the fix; treat it as "no information" rather than letting it // clobber a previously-resolved name. if !renderedName.isEmpty { diff --git a/Tests/Unit/NameBroadcasterTests.swift b/Tests/Unit/NameBroadcasterTests.swift @@ -1,160 +0,0 @@ -import CoreData -import Foundation -import Testing - -@testable import Crossmate - -@Suite("NameBroadcaster", .serialized) -@MainActor -struct NameBroadcasterTests { - - // MARK: - Helpers - - /// Creates a persistence store with one game that qualifies for fan-out - /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it). - private func makeSharedGame() throws -> (PersistenceController, UUID) { - let p = makeTestPersistence() - let ctx = p.viewContext - let gameID = UUID() - let entity = GameEntity(context: ctx) - entity.id = gameID - entity.title = "Shared Test" - entity.puzzleSource = "" - entity.createdAt = Date() - entity.updatedAt = Date() - entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) - entity.ckShareRecordName = "share-\(UUID().uuidString)" - try ctx.save() - return (p, gameID) - } - - private func makeNonSharedGame() throws -> (PersistenceController, UUID) { - let p = makeTestPersistence() - let ctx = p.viewContext - let gameID = UUID() - let entity = GameEntity(context: ctx) - entity.id = gameID - entity.title = "Solo Test" - entity.puzzleSource = "" - entity.createdAt = Date() - entity.updatedAt = Date() - entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) - // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out. - try ctx.save() - return (p, gameID) - } - - private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? { - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - req.predicate = NSPredicate(format: "authorID == %@", authorID) - req.fetchLimit = 1 - return (try? ctx.fetch(req).first)?.name - } - } - - private func makeBroadcaster( - preferences: PlayerPreferences, - persistence: PersistenceController, - authorID: String - ) -> NameBroadcaster { - NameBroadcaster( - preferences: preferences, - persistence: persistence, - authorIdentity: AuthorIdentity(testing: authorID), - enqueuePlayerRecord: { _, _ in } - ) - } - - // MARK: - Tests - - @Test("broadcastName writes a PlayerEntity for shared/joined games") - func broadcastNameWritesPlayerEntityForSharedGame() async throws { - let (persistence, _) = try makeSharedGame() - let prefs = PlayerPreferences( - local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! - ) - prefs.name = "Alice" - let broadcaster = makeBroadcaster( - preferences: prefs, - persistence: persistence, - authorID: "_local" - ) - - await broadcaster.broadcastName() - - #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice") - } - - @Test("broadcastName is a no-op for non-shared games") - func broadcastNameSkipsNonSharedGames() async throws { - let (persistence, _) = try makeNonSharedGame() - let prefs = PlayerPreferences( - local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! - ) - prefs.name = "Alice" - let broadcaster = makeBroadcaster( - preferences: prefs, - persistence: persistence, - authorID: "_local" - ) - - await broadcaster.broadcastName() - - #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil) - } - - @Test("Debounce coalesces two rapid name changes into one fan-out with the final name") - func debounceCoalescesPair() async throws { - let (persistence, _) = try makeSharedGame() - let prefs = PlayerPreferences( - local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! - ) - let broadcaster = makeBroadcaster( - preferences: prefs, - persistence: persistence, - authorID: "_local" - ) - - let spy = FanOutSpy() - broadcaster.onFanOutForTesting = { name in spy.record(name) } - - // Allow the observation task to make its first withObservationTracking - // registration before we mutate any values. - await Task.yield() - - // First change — debounce timer #1 starts. - prefs.name = "Alice" - await Task.yield() // observation task → scheduleDebounce - - // Second change before timer fires — must cancel #1, start #2. - prefs.name = "Bob" - await Task.yield() // observation task → cancel #1, start #2 - - // Poll until the (single, hopefully) fan-out fires. Loose deadline so - // CI scheduler jitter doesn't fail the test. - let deadline = Date().addingTimeInterval(2.0) - while spy.count == 0 && Date() < deadline { - try await Task.sleep(for: .milliseconds(20)) - } - - // Grace period to catch a stray second fan-out from an uncancelled - // timer — the bug we're guarding against would surface here as count==2. - try await Task.sleep(for: .milliseconds(150)) - - #expect(spy.count == 1, "two rapid changes should debounce into one fan-out") - #expect(spy.names.last == "Bob", "the final name should win") - #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob") - - // Keep broadcaster alive until assertions are done. - withExtendedLifetime(broadcaster) {} - } -} - -@MainActor -private final class FanOutSpy { - private(set) var names: [String] = [] - var count: Int { names.count } - func record(_ name: String) { names.append(name) } -} diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift @@ -0,0 +1,160 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PlayerNamePublisher", .serialized) +@MainActor +struct PlayerNamePublisherTests { + + // MARK: - Helpers + + /// Creates a persistence store with one game that qualifies for fan-out + /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it). + private func makeSharedGame() throws -> (PersistenceController, UUID) { + let p = makeTestPersistence() + let ctx = p.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Shared Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) + entity.ckShareRecordName = "share-\(UUID().uuidString)" + try ctx.save() + return (p, gameID) + } + + private func makeNonSharedGame() throws -> (PersistenceController, UUID) { + let p = makeTestPersistence() + let ctx = p.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Solo Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) + // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out. + try ctx.save() + return (p, gameID) + } + + private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "authorID == %@", authorID) + req.fetchLimit = 1 + return (try? ctx.fetch(req).first)?.name + } + } + + private func makeBroadcaster( + preferences: PlayerPreferences, + persistence: PersistenceController, + authorID: String + ) -> PlayerNamePublisher { + PlayerNamePublisher( + preferences: preferences, + persistence: persistence, + authorIdentity: AuthorIdentity(testing: authorID), + enqueuePlayerRecord: { _, _ in } + ) + } + + // MARK: - Tests + + @Test("broadcastName writes a PlayerEntity for shared/joined games") + func broadcastNameWritesPlayerEntityForSharedGame() async throws { + let (persistence, _) = try makeSharedGame() + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.name = "Alice" + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: persistence, + authorID: "_local" + ) + + await broadcaster.broadcastName() + + #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice") + } + + @Test("broadcastName is a no-op for non-shared games") + func broadcastNameSkipsNonSharedGames() async throws { + let (persistence, _) = try makeNonSharedGame() + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.name = "Alice" + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: persistence, + authorID: "_local" + ) + + await broadcaster.broadcastName() + + #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil) + } + + @Test("Debounce coalesces two rapid name changes into one fan-out with the final name") + func debounceCoalescesPair() async throws { + let (persistence, _) = try makeSharedGame() + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: persistence, + authorID: "_local" + ) + + let spy = FanOutSpy() + broadcaster.onFanOutForTesting = { name in spy.record(name) } + + // Allow the observation task to make its first withObservationTracking + // registration before we mutate any values. + await Task.yield() + + // First change — debounce timer #1 starts. + prefs.name = "Alice" + await Task.yield() // observation task → scheduleDebounce + + // Second change before timer fires — must cancel #1, start #2. + prefs.name = "Bob" + await Task.yield() // observation task → cancel #1, start #2 + + // Poll until the (single, hopefully) fan-out fires. Loose deadline so + // CI scheduler jitter doesn't fail the test. + let deadline = Date().addingTimeInterval(2.0) + while spy.count == 0 && Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + + // Grace period to catch a stray second fan-out from an uncancelled + // timer — the bug we're guarding against would surface here as count==2. + try await Task.sleep(for: .milliseconds(150)) + + #expect(spy.count == 1, "two rapid changes should debounce into one fan-out") + #expect(spy.names.last == "Bob", "the final name should win") + #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob") + + // Keep broadcaster alive until assertions are done. + withExtendedLifetime(broadcaster) {} + } +} + +@MainActor +private final class FanOutSpy { + private(set) var names: [String] = [] + var count: Int { names.count } + func record(_ name: String) { names.append(name) } +} diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift @@ -0,0 +1,277 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PlayerSelectionPublisher", .serialized) +@MainActor +struct PlayerSelectionPublisherTests { + + /// Captures the `(gameID, authorID)` sink fan-outs. + actor Capture { + private(set) var notifications: [(UUID, String)] = [] + var count: Int { notifications.count } + func append(_ gameID: UUID, _ authorID: String) { + notifications.append((gameID, authorID)) + } + } + + private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { + let persistence = makeTestPersistence() + let context = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: context) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + try context.save() + return (persistence, gameID) + } + + @Test("Publish + flush writes the PlayerEntity row and notifies the sink") + func publishWritesEntity() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .seconds(10), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down)) + await publisher.flush() + + let count = await capture.count + #expect(count == 1) + let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) + #expect(values?.selRow == 3) + #expect(values?.selCol == 4) + #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) + #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice") + } + + @Test("Repeated identical selections don't refire the sink") + func dedupesIdenticalSelections() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .milliseconds(40), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + let selection = PlayerSelection(row: 0, col: 0, direction: .across) + await publisher.publish(selection) + try await Task.sleep(for: .milliseconds(120)) + await publisher.publish(selection) + await publisher.publish(selection) + try await Task.sleep(for: .milliseconds(120)) + + let count = await capture.count + #expect(count == 1) + } + + @Test("Rapid distinct selections coalesce into a single trailing flush") + func debounceCoalescesRapidPublishes() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .milliseconds(80), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + for col in 0...4 { + await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across)) + try await Task.sleep(for: .milliseconds(20)) + } + try await Task.sleep(for: .milliseconds(250)) + + let count = await capture.count + #expect(count == 1) + let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) + #expect(values?.selCol == 4) + } + + @Test("Clear nils the selection fields and notifies the sink") + func clearWritesNilSelection() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .seconds(10), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down)) + await publisher.flush() + await publisher.clear() + // clear() spawns a Task internally — give it a moment to land. + try await Task.sleep(for: .milliseconds(50)) + + let count = await capture.count + #expect(count == 2) + let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) + #expect(values != nil) + #expect(values?.selRow == nil) + #expect(values?.selCol == nil) + #expect(values?.selDir == nil) + } + + @Test("Clear is a no-op when nothing has been published yet") + func clearWithoutPriorPublishIsNoOp() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .seconds(10), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + await publisher.clear() + try await Task.sleep(for: .milliseconds(50)) + + let count = await capture.count + #expect(count == 0) + } + + @Test("Publish without begin is silently ignored") + func publishBeforeBeginIsDropped() async throws { + let (persistence, _) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .milliseconds(40), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) + await publisher.flush() + + let count = await capture.count + #expect(count == 0) + } + + @Test("Updates an existing PlayerEntity rather than creating a duplicate") + func updatesExistingEntity() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync). + let context = persistence.viewContext + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + let game = try #require(try context.fetch(gameReq).first) + let preexisting = PlayerEntity(context: context) + preexisting.game = game + preexisting.authorID = "alice" + preexisting.name = "Alice" + preexisting.updatedAt = Date() + preexisting.ckRecordName = "player-\(gameID.uuidString)-alice" + try context.save() + + let publisher = PlayerSelectionPublisher( + debounceInterval: .seconds(10), + persistence: persistence, + sink: { _, _ in } + ) + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across)) + await publisher.flush() + + let rows = fetchAllPlayers(gameID: gameID, persistence: persistence) + #expect(rows.count == 1) + #expect(rows.first?.name == "Alice") // pre-existing field preserved + #expect(rows.first?.selRow == 5) + #expect(rows.first?.selCol == 6) + } + + @Test("Publishing again after clear writes the new selection") + func publishAfterClear() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let publisher = PlayerSelectionPublisher( + debounceInterval: .seconds(10), + persistence: persistence, + sink: { id, author in await capture.append(id, author) } + ) + + await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") + await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across)) + await publisher.flush() + await publisher.clear() + try await Task.sleep(for: .milliseconds(50)) + await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down)) + await publisher.flush() + + let count = await capture.count + #expect(count == 3) + let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) + #expect(values?.selRow == 2) + #expect(values?.selCol == 2) + #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) + } + + // MARK: - Helpers + + struct PlayerValues { + let name: String? + let selRow: Int64? + let selCol: Int64? + let selDir: Int64? + let ckRecordName: String? + } + + private func fetchPlayer( + gameID: UUID, + authorID: String, + persistence: PersistenceController + ) -> PlayerValues? { + let context = persistence.container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + request.predicate = NSPredicate( + format: "game.id == %@ AND authorID == %@", + gameID as CVarArg, + authorID + ) + request.fetchLimit = 1 + guard let entity = try? context.fetch(request).first else { return nil } + return PlayerValues( + name: entity.name, + selRow: entity.selRow?.int64Value, + selCol: entity.selCol?.int64Value, + selDir: entity.selDir?.int64Value, + ckRecordName: entity.ckRecordName + ) + } + } + + private func fetchAllPlayers( + gameID: UUID, + persistence: PersistenceController + ) -> [PlayerValues] { + let context = persistence.container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) + guard let entities = try? context.fetch(request) else { return [] } + return entities.map { + PlayerValues( + name: $0.name, + selRow: $0.selRow?.int64Value, + selCol: $0.selCol?.int64Value, + selDir: $0.selDir?.int64Value, + ckRecordName: $0.ckRecordName + ) + } + } + } +} diff --git a/Tests/Unit/PresencePublisherTests.swift b/Tests/Unit/PresencePublisherTests.swift @@ -1,277 +0,0 @@ -import CoreData -import Foundation -import Testing - -@testable import Crossmate - -@Suite("PresencePublisher", .serialized) -@MainActor -struct PresencePublisherTests { - - /// Captures the `(gameID, authorID)` sink fan-outs. - actor Capture { - private(set) var notifications: [(UUID, String)] = [] - var count: Int { notifications.count } - func append(_ gameID: UUID, _ authorID: String) { - notifications.append((gameID, authorID)) - } - } - - private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { - let persistence = makeTestPersistence() - let context = persistence.viewContext - let gameID = UUID() - let entity = GameEntity(context: context) - entity.id = gameID - entity.title = "Test" - entity.puzzleSource = "" - entity.createdAt = Date() - entity.updatedAt = Date() - entity.ckRecordName = "game-\(gameID.uuidString)" - try context.save() - return (persistence, gameID) - } - - @Test("Publish + flush writes the PlayerEntity row and notifies the sink") - func publishWritesEntity() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down)) - await publisher.flush() - - let count = await capture.count - #expect(count == 1) - let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) - #expect(values?.selRow == 3) - #expect(values?.selCol == 4) - #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) - #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice") - } - - @Test("Repeated identical selections don't refire the sink") - func dedupesIdenticalSelections() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .milliseconds(40), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - let selection = PlayerSelection(row: 0, col: 0, direction: .across) - await publisher.publish(selection) - try await Task.sleep(for: .milliseconds(120)) - await publisher.publish(selection) - await publisher.publish(selection) - try await Task.sleep(for: .milliseconds(120)) - - let count = await capture.count - #expect(count == 1) - } - - @Test("Rapid distinct selections coalesce into a single trailing flush") - func debounceCoalescesRapidPublishes() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .milliseconds(80), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - for col in 0...4 { - await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across)) - try await Task.sleep(for: .milliseconds(20)) - } - try await Task.sleep(for: .milliseconds(250)) - - let count = await capture.count - #expect(count == 1) - let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) - #expect(values?.selCol == 4) - } - - @Test("Clear nils the selection fields and notifies the sink") - func clearWritesNilSelection() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down)) - await publisher.flush() - await publisher.clear() - // clear() spawns a Task internally — give it a moment to land. - try await Task.sleep(for: .milliseconds(50)) - - let count = await capture.count - #expect(count == 2) - let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) - #expect(values != nil) - #expect(values?.selRow == nil) - #expect(values?.selCol == nil) - #expect(values?.selDir == nil) - } - - @Test("Clear is a no-op when nothing has been published yet") - func clearWithoutPriorPublishIsNoOp() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - await publisher.clear() - try await Task.sleep(for: .milliseconds(50)) - - let count = await capture.count - #expect(count == 0) - } - - @Test("Publish without begin is silently ignored") - func publishBeforeBeginIsDropped() async throws { - let (persistence, _) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .milliseconds(40), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across)) - await publisher.flush() - - let count = await capture.count - #expect(count == 0) - } - - @Test("Updates an existing PlayerEntity rather than creating a duplicate") - func updatesExistingEntity() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync). - let context = persistence.viewContext - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - let game = try #require(try context.fetch(gameReq).first) - let preexisting = PlayerEntity(context: context) - preexisting.game = game - preexisting.authorID = "alice" - preexisting.name = "Alice" - preexisting.updatedAt = Date() - preexisting.ckRecordName = "player-\(gameID.uuidString)-alice" - try context.save() - - let publisher = PresencePublisher( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { _, _ in } - ) - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across)) - await publisher.flush() - - let rows = fetchAllPlayers(gameID: gameID, persistence: persistence) - #expect(rows.count == 1) - #expect(rows.first?.name == "Alice") // pre-existing field preserved - #expect(rows.first?.selRow == 5) - #expect(rows.first?.selCol == 6) - } - - @Test("Publishing again after clear writes the new selection") - func publishAfterClear() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let publisher = PresencePublisher( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { id, author in await capture.append(id, author) } - ) - - await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice") - await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across)) - await publisher.flush() - await publisher.clear() - try await Task.sleep(for: .milliseconds(50)) - await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down)) - await publisher.flush() - - let count = await capture.count - #expect(count == 3) - let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence) - #expect(values?.selRow == 2) - #expect(values?.selCol == 2) - #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue)) - } - - // MARK: - Helpers - - struct PlayerValues { - let name: String? - let selRow: Int64? - let selCol: Int64? - let selDir: Int64? - let ckRecordName: String? - } - - private func fetchPlayer( - gameID: UUID, - authorID: String, - persistence: PersistenceController - ) -> PlayerValues? { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - request.predicate = NSPredicate( - format: "game.id == %@ AND authorID == %@", - gameID as CVarArg, - authorID - ) - request.fetchLimit = 1 - guard let entity = try? context.fetch(request).first else { return nil } - return PlayerValues( - name: entity.name, - selRow: entity.selRow?.int64Value, - selCol: entity.selCol?.int64Value, - selDir: entity.selDir?.int64Value, - ckRecordName: entity.ckRecordName - ) - } - } - - private func fetchAllPlayers( - gameID: UUID, - persistence: PersistenceController - ) -> [PlayerValues] { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) - guard let entities = try? context.fetch(request) else { return [] } - return entities.map { - PlayerValues( - name: $0.name, - selRow: $0.selRow?.int64Value, - selCol: $0.selCol?.int64Value, - selDir: $0.selDir?.int64Value, - ckRecordName: $0.ckRecordName - ) - } - } - } -}