crossmate

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

commit f632593477be4a6b5db3dfa0f0819a4f2b996910
parent 33deff922246beaccbd490b5ad83496bf8f89156
Author: Michael Camilleri <[email protected]>
Date:   Sat, 25 Jul 2026 23:18:36 +0900

Fix a number of recent bugs

This commit fixes a number of recently introduced bugs:

- A provisional Chronicle that rewrote itself on every reconciliation
  pass has been fixed.
- An invite failure that could be suppressed and never shown have been
  fixed.
- The Chronicle journal comparison is limited to a complete Chronicle,
  where it is the only thing that can have changed. A waiting Chronicle
  is rewritten only when its missing count, completeness or format
  moves. The decision now lives in chronicleNeedsWrite.
- An out-of-range missing count degrades to the terminal no-replay
  state instead of throwing and failing the whole payload.
- The invite-failure banner always posts; the queued and sent delivery
  updates retract it. The presentation count it was gated on could leak
  and mute every later failure for that game.
- ShareErrorInfo.title and the unreachable title row it fed are gone.
- The no-op catch in fetchAcceptedSharedGameDirect is gone.
- The PingOutboxError descriptions are localised.
- Join-failure banner copy comes from CloudFailureCopy.joinFailure
  rather than nested ternaries at three call sites.
- usesArchivedReplay folds into isGameArchived, which gains an
  entity-taking overload so the foreground refresh check stops
  re-fetching a row it already holds.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 37++++++++++++++++++-------------------
MCrossmate/Persistence/GameStore.swift | 16++++++++++------
MCrossmate/Services/AppActions.swift | 8--------
MCrossmate/Services/AppServices.swift | 73++++++++++++++++++++++++++++++++++++++++++-------------------------------
MCrossmate/Services/CloudService.swift | 16++++++++++++++++
MCrossmate/Services/InviteDeliveryStore.swift | 18------------------
MCrossmate/Sync/Archive.swift | 13+++++++++----
MCrossmate/Sync/CloudQuery.swift | 50++++++++++++++++++++++++--------------------------
MCrossmate/Sync/GameArchiver.swift | 44++++++++++++++++++++++++++++++++++++++------
MCrossmate/Sync/SyncEngine.swift | 4++--
MCrossmate/Views/Friends/FriendPickerView.swift | 6------
MCrossmate/Views/GameList/GameListView.swift | 10+++-------
MCrossmate/Views/GameList/GameShareItem.swift | 14--------------
MTests/Unit/ArchiveTests.swift | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MTests/Unit/Sync/PendingChangeReapTests.swift | 32++++++++++++++++++++------------
15 files changed, 277 insertions(+), 160 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -698,34 +698,33 @@ struct RootView: View { // List rather than bouncing the user back in silence. guard !Task.isCancelled else { return } withAnimation { pendingJoin = nil } - let acceptedError = error as? AcceptedShareError let code = CloudService.cloudErrorCode(error) - let gone = acceptedError?.kind == .removed + let gone = (error as? AcceptedShareError)?.kind == .removed || code == .unknownItem || code == .zoneNotFound let versionMismatch = (error as? CloudServiceError) == .containerVersionMismatch + // Both of these are the user's world being different to + // what the link assumed, not something broken: warn and + // explain rather than reporting a failure. + let expected = gone || versionMismatch + let copy: (title: String, body: String) + if versionMismatch { + copy = ("Update Needed", error.localizedDescription) + } else if gone { + copy = ("Puzzle Removed", "This puzzle was removed.") + } else { + copy = CloudFailureCopy.joinFailure(for: error) + } services.eventLog.note( "share link join failed: \(error.localizedDescription)", - level: (gone || versionMismatch) ? "info" : "error" + level: expected ? "info" : "error" ) services.announcements.post(Announcement( id: "share-link-join-failed", scope: .global, - severity: (gone || versionMismatch) ? .warning : .error, - title: versionMismatch - ? "Update Needed" - : (gone - ? "Puzzle Removed" - : (acceptedError?.isQuotaExceeded == true - ? CloudFailureCopy.quotaJoinTitle - : (acceptedError == nil - ? "Accepting Failed" - : "Puzzle Unavailable"))), - body: gone - ? "This puzzle was removed." - : (acceptedError?.isQuotaExceeded == true - ? CloudFailureCopy.quotaJoinBody - : error.localizedDescription), + severity: expected ? .warning : .error, + title: copy.title, + body: copy.body, dismissal: .manual )) } @@ -907,7 +906,7 @@ private struct PuzzleDisplayView: View { // an incomplete local timeline. let entries = store.localJournalEntries(for: gameID) if !store.isGameShared(gameID: gameID), - !store.usesArchivedReplay(gameID: gameID) { + !store.isGameArchived(gameID: gameID) { services.syncMonitor.note( "replay[\(short)]: local-only path " + "(unshared game), localEntries=\(entries.count)" diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -2271,19 +2271,23 @@ final class GameStore { } /// Whether `gameID` is the local read-only projection of a Chronicle. + /// + /// This is also what routes replay: a materialised Chronicle carries its + /// history as cached Journal rows regardless of whether the original game + /// was shared, so it must use the merged replay loader rather than the live + /// game's local-journal shortcut. func isGameArchived(gameID: UUID) -> Bool { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) request.fetchLimit = 1 guard let entity = try? context.fetch(request).first else { return false } - return isMaterializedArchive(entity) + return isGameArchived(entity) } - /// A materialised Chronicle carries its replay as cached Journal rows, - /// regardless of whether the original game was shared. It must use the - /// merged replay loader rather than the live game's local-journal shortcut. - func usesArchivedReplay(gameID: UUID) -> Bool { - isGameArchived(gameID: gameID) + /// Entity-taking form, for callers that already hold the row and would + /// otherwise re-fetch it by ID. + func isGameArchived(_ entity: GameEntity) -> Bool { + isMaterializedArchive(entity) } /// Reads, mutates, and re-persists the local author's `Player.timeLog`, diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift @@ -44,14 +44,6 @@ final class AppActions { ) } - func beginInvitationPresentation(gameID: UUID) { - services.inviteDeliveries.beginPresentation(for: gameID) - } - - func endInvitationPresentation(gameID: UUID) { - services.inviteDeliveries.endPresentation(for: gameID) - } - func acceptInvite(shareURL: String, pingRecordName: String) async throws { _ = try await services.invites.acceptInvite( shareURL: shareURL, diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1137,12 +1137,20 @@ final class AppServices { gameID: update.gameID, friendAuthorID: update.addressee ) + self.dismissInviteFailureAnnouncement( + gameID: update.gameID, + friendAuthorID: update.addressee + ) case .sent: self.inviteDeliveries.markSent( recordName: update.recordName, gameID: update.gameID, friendAuthorID: update.addressee ) + self.dismissInviteFailureAnnouncement( + gameID: update.gameID, + friendAuthorID: update.addressee + ) case .failed: let failure = update.failure ?? .other self.inviteDeliveries.markFailed( @@ -1151,28 +1159,25 @@ final class AppServices { friendAuthorID: update.addressee, failure: failure ) - // An open invite sheet shows failures inline, so suppress the - // global banner while one is presented. Known edge case: if a - // confirmed-send wait first times out (`.deliveryPending`, which - // clears the sheet's local failure state) and CloudKit *then* - // rejects the Ping while the sheet is still open, the row - // re-enables via its `.failed` phase but neither this banner nor - // the inline callout reappears — the user must retry. Narrow - // window; closing it means having the callout observe the - // delivery store per-friend instead of local @State. - if !self.inviteDeliveries.hasActivePresentation(for: update.gameID) { - self.announcements.post(Announcement( - id: InviteDeliveryStore.failureAnnouncementID( - gameID: update.gameID, - friendAuthorID: update.addressee - ), - scope: .global, - severity: .error, - title: String(localized: failure.title), - body: String(localized: failure.body), - dismissal: .manual - )) - } + // Always post, even with an invite sheet open. The sheet shows + // the same failure inline but only for the send it issued + // itself, so gating on "a sheet is presented" could swallow a + // failure entirely — including the one that arrives after a + // confirmed-send wait has already timed out. The banner is + // scoped to the Game List behind the sheet, and the `.queued` + // and `.sent` cases above retract it, so a retry that succeeds + // never leaves a stale one behind. + self.announcements.post(Announcement( + id: InviteDeliveryStore.failureAnnouncementID( + gameID: update.gameID, + friendAuthorID: update.addressee + ), + scope: .global, + severity: .error, + title: String(localized: failure.title), + body: String(localized: failure.body), + dismissal: .manual + )) if update.rollbackParticipantOnFailure { Task { @MainActor [weak self] in await self?.invites.rollbackUndeliveredInvite( @@ -2149,10 +2154,21 @@ final class AppServices { return true } + /// Retracts a friend's invite-failure banner once that invitation is back + /// in flight, so a successful retry never leaves the old failure standing. + private func dismissInviteFailureAnnouncement(gameID: UUID, friendAuthorID: String) { + announcements.dismiss( + id: InviteDeliveryStore.failureAnnouncementID( + gameID: gameID, + friendAuthorID: friendAuthorID + ) + ) + } + private func activePuzzleGridTarget() -> (UUID, CKDatabase.Scope)? { guard let entity = store.currentEntity, let gameID = entity.id, - !store.isGameArchived(gameID: gameID) + !store.isGameArchived(entity) else { return nil } switch entity.databaseScope { case 0: @@ -2478,18 +2494,13 @@ final class AppServices { // the accepted-but-unavailable distinction here and keep // draining the queue. if error is AcceptedShareError { - let quotaExceeded = (error as? AcceptedShareError)? - .isQuotaExceeded == true + let copy = CloudFailureCopy.joinFailure(for: error) announcements.post(Announcement( id: "os-share-joined-unavailable", scope: .global, severity: .error, - title: quotaExceeded - ? CloudFailureCopy.quotaJoinTitle - : "Puzzle Unavailable", - body: quotaExceeded - ? CloudFailureCopy.quotaJoinBody - : error.localizedDescription, + title: copy.title, + body: copy.body, dismissal: .manual )) } diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -4,6 +4,22 @@ enum CloudFailureCopy { static let quotaJoinTitle = "Puzzle Unavailable" static let quotaJoinBody = "The puzzle could not be joined properly. This could be because your friend's iCloud storage is full." + + /// Banner copy for a failed join, shared by the three surfaces that can + /// raise one (share link, invite row, OS-delivered acceptance) so they can't + /// drift apart. An `AcceptedShareError` means the share itself went + /// through and only its puzzle is unusable — a different thing to tell the + /// user than failing to accept at all — and a quota failure names the + /// likely cause instead of echoing CloudKit's wording. + static func joinFailure(for error: Error) -> (title: String, body: String) { + guard let accepted = error as? AcceptedShareError else { + return ("Accepting Failed", error.localizedDescription) + } + guard accepted.isQuotaExceeded else { + return ("Puzzle Unavailable", error.localizedDescription) + } + return (quotaJoinTitle, quotaJoinBody) + } } extension Notification.Name { diff --git a/Crossmate/Services/InviteDeliveryStore.swift b/Crossmate/Services/InviteDeliveryStore.swift @@ -45,29 +45,11 @@ final class InviteDeliveryStore { private var deliveries: [Key: InviteDelivery] = [:] private var keyByRecordName: [String: Key] = [:] - private var activePresentationCounts: [UUID: Int] = [:] static func failureAnnouncementID(gameID: UUID, friendAuthorID: String) -> String { "invite-delivery-failed-\(gameID.uuidString)-\(friendAuthorID)" } - func beginPresentation(for gameID: UUID) { - activePresentationCounts[gameID, default: 0] += 1 - } - - func endPresentation(for gameID: UUID) { - let next = max(0, (activePresentationCounts[gameID] ?? 0) - 1) - if next == 0 { - activePresentationCounts.removeValue(forKey: gameID) - } else { - activePresentationCounts[gameID] = next - } - } - - func hasActivePresentation(for gameID: UUID) -> Bool { - (activePresentationCounts[gameID] ?? 0) > 0 - } - func delivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery { let key = Key(gameID: gameID, friendAuthorID: friendAuthorID) if let existing = deliveries[key] { return existing } diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -719,11 +719,16 @@ enum Archive { let replayState: ReplayState if blob.replayAvailable { replayState = .available - } else if let missing = blob.replayMissingDeviceCount { - guard (1...maxJournalDeviceCount).contains(missing) else { - throw PayloadError.identityMismatch - } + } else if let missing = blob.replayMissingDeviceCount, + (1...maxJournalDeviceCount).contains(missing) { replayState = .waiting(missing: missing) + } else if blob.replayMissingDeviceCount != nil { + // An out-of-range count is a corrupt or hostile payload, but the + // rest of the Chronicle is still verified and playable. Degrade + // to the terminal no-replay state rather than rejecting the + // whole archive over a count we only use to word a progress + // message. + replayState = .unavailable } else { replayState = .unavailable } diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -784,32 +784,30 @@ extension SyncEngine { zoneID: zoneID ) - let gameResults: [CKRecord.ID: Result<CKRecord, Error>] - let moves: [CKRecord] - let players: [CKRecord] - do { - async let gameResultsTask = database.records( - for: [gameRecordID], - desiredKeys: RecordSerializer.gameDesiredKeys - ) - async let movesTask = onlyGame ? [] : queryLiveRecords( - type: "Moves", - database: database, - zoneID: zoneID, - since: nil, - desiredKeys: RecordSerializer.movesDesiredKeys - ) - async let playersTask = onlyGame ? [] : queryLiveRecords( - type: "Player", - database: database, - zoneID: zoneID, - since: nil, - desiredKeys: RecordSerializer.playerDesiredKeys - ) - (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) - } catch { - throw error - } + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: RecordSerializer.gameDesiredKeys + ) + async let movesTask = onlyGame ? [] : queryLiveRecords( + type: "Moves", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: RecordSerializer.movesDesiredKeys + ) + async let playersTask = onlyGame ? [] : queryLiveRecords( + type: "Player", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: RecordSerializer.playerDesiredKeys + ) + // Every failure here is the caller's to interpret: a join distinguishes + // a removed puzzle from a transient outage by the CloudKit code, so + // nothing is swallowed on the way out. + let (gameResults, moves, players) = try await ( + gameResultsTask, movesTask, playersTask + ) guard let game = try Self.acceptedGameRecord( from: gameResults, diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -160,6 +160,32 @@ final class GameArchiver { } } + /// Whether the Chronicle in CloudKit still says what this pass concluded. + /// + /// The journal comparison is deliberately limited to `.available`: only a + /// complete Chronicle embeds journals, so a provisional one is always + /// stored with an empty journal. Comparing that against the merged local + /// history would report "missing" on every pass and re-upload the whole + /// payload — a fetch, a replay query, and a compressed asset — for the + /// entire multi-day waiting window, without ever changing what's stored. + nonisolated static func chronicleNeedsWrite( + stored: ( + isLegacy: Bool, + formatVersion: Int, + replayState: Archive.ReplayState, + journalKeys: Set<JournalDeviceKey> + )?, + replayState: Archive.ReplayState, + presentJournalKeys: Set<JournalDeviceKey> + ) -> Bool { + guard let stored else { return true } + if stored.isLegacy { return true } + if stored.formatVersion != Archive.currentPayloadFormatVersion { return true } + if stored.replayState != replayState { return true } + return replayState == .available + && !presentJournalKeys.isSubset(of: stored.journalKeys) + } + nonisolated static func hasArchiveRetryExpired( completedAt: Date, graceStart: Date = .distantPast, @@ -253,12 +279,18 @@ final class GameArchiver { replayState = .waiting(missing: fetchedMissing ?? 1) } - let storedKeys = Set(stored?.payload.journal.map(\.key) ?? []) - let needsWrite = stored == nil - || stored?.isLegacy == true - || stored?.payload.formatVersion != Archive.currentPayloadFormatVersion - || stored?.payload.replayState != replayState - || !present.isSubset(of: storedKeys) + let needsWrite = Self.chronicleNeedsWrite( + stored: stored.map { + ( + isLegacy: $0.isLegacy, + formatVersion: $0.payload.formatVersion, + replayState: $0.payload.replayState, + journalKeys: Set($0.payload.journal.map(\.key)) + ) + }, + replayState: replayState, + presentJournalKeys: present + ) if needsWrite { guard await write(snapshot, replayState: replayState) else { return nil } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -101,14 +101,14 @@ actor SyncEngine { var errorDescription: String? { switch self { case .syncEngineUnavailable: - return "The invitation could not be queued for syncing." + return String(localized: "The invitation could not be queued for syncing.") case .deliveryFailed(let code) where code == CKError.quotaExceeded.rawValue: return String(localized: InviteDeliveryFailure.quotaExceeded.body) case .deliveryFailed: return String(localized: InviteDeliveryFailure.other.body) case .deliveryPending: - return "The invitation is queued and will keep trying." + return String(localized: "The invitation is queued and will keep trying.") } } diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -71,12 +71,6 @@ struct FriendPickerView: View { } .navigationTitle("Invite a Crossmate") .navigationBarTitleDisplayMode(.inline) - .onAppear { - appActions?.beginInvitationPresentation(gameID: gameID) - } - .onDisappear { - appActions?.endInvitationPresentation(gameID: gameID) - } .task { // Reflect friends already on the share so re-opening the picker // shows their checkmark instead of an un-invited glyph. diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -874,17 +874,13 @@ struct GameListView: View { try await appActions.acceptInvite(shareURL: url, pingRecordName: ping) } } catch { - let acceptedError = error as? AcceptedShareError + let copy = CloudFailureCopy.joinFailure(for: error) announcements.post(Announcement( id: Self.inviteErrorID, scope: .global, severity: .error, - title: acceptedError?.isQuotaExceeded == true - ? CloudFailureCopy.quotaJoinTitle - : (acceptedError == nil ? "Accepting Failed" : "Puzzle Unavailable"), - body: acceptedError?.isQuotaExceeded == true - ? CloudFailureCopy.quotaJoinBody - : error.localizedDescription, + title: copy.title, + body: copy.body, dismissal: .manual )) } diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -212,11 +212,6 @@ struct GameShareSheet: View { if let shareError { Section { - if let errorTitle = shareError.title { - Text(errorTitle) - .font(.headline) - .foregroundStyle(.red) - } Text(shareError.message) .font(.footnote) .foregroundStyle(.red) @@ -242,12 +237,6 @@ struct GameShareSheet: View { } .navigationTitle("Invite Players") .navigationBarTitleDisplayMode(.inline) - .onAppear { - appActions?.beginInvitationPresentation(gameID: gameID) - } - .onDisappear { - appActions?.endInvitationPresentation(gameID: gameID) - } .toolbar { ToolbarItem(placement: .cancellationAction) { Button { @@ -430,7 +419,6 @@ struct GameShareSheet: View { /// is shown on screen; the diagnostic is what the Copy/Report buttons carry /// so a report still contains the underlying CloudKit detail. private struct ShareErrorInfo { - let title: String? let message: String let detail: String /// True for unexpected failures (CloudKit, network) worth reporting. @@ -440,11 +428,9 @@ struct GameShareSheet: View { init(_ error: Error, diagnostic: String) { if error is ShareController.ShareError { - title = nil message = error.localizedDescription isReportable = false } else { - title = nil message = "Something went wrong sharing this puzzle. If it keeps happening, report the error so it can be fixed." isReportable = true } diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -640,7 +640,7 @@ struct ArchiveTests { #expect(players.allSatisfy { ($0.name ?? "").isEmpty }) #expect(GameSummary(entity: game)?.isShared == true) #expect(store.isGameArchived(gameID: game.id!)) - #expect(store.usesArchivedReplay(gameID: game.id!)) + #expect(store.isGameArchived(game)) #expect(await store.cachedRemoteJournals(forGameID: game.id!)?.count == 2) } @@ -717,6 +717,100 @@ struct ArchiveTests { )) } + @Test("a settled provisional Chronicle is not rewritten on every pass") + func provisionalChronicleWritesOnlyOnChange() { + let alice = JournalDeviceKey(authorID: "alice", deviceID: "phone") + let bob = JournalDeviceKey(authorID: "bob", deviceID: "pad") + // A waiting Chronicle is always stored with an empty journal, so its + // stored keys can never cover the merged local history. That must not + // by itself force a re-upload. + let waiting = ( + isLegacy: false, + formatVersion: Archive.currentPayloadFormatVersion, + replayState: Archive.ReplayState.waiting(missing: 1), + journalKeys: Set<JournalDeviceKey>() + ) + #expect(!GameArchiver.chronicleNeedsWrite( + stored: waiting, + replayState: .waiting(missing: 1), + presentJournalKeys: [alice] + )) + // A changed missing count is real news and still writes. + #expect(GameArchiver.chronicleNeedsWrite( + stored: waiting, + replayState: .waiting(missing: 2), + presentJournalKeys: [alice] + )) + // So is completing, and so is a complete Chronicle that is genuinely + // missing a journal this pass merged in. + #expect(GameArchiver.chronicleNeedsWrite( + stored: waiting, + replayState: .available, + presentJournalKeys: [alice] + )) + #expect(GameArchiver.chronicleNeedsWrite( + stored: ( + isLegacy: false, + formatVersion: Archive.currentPayloadFormatVersion, + replayState: .available, + journalKeys: [alice] + ), + replayState: .available, + presentJournalKeys: [alice, bob] + )) + #expect(!GameArchiver.chronicleNeedsWrite( + stored: ( + isLegacy: false, + formatVersion: Archive.currentPayloadFormatVersion, + replayState: .available, + journalKeys: [alice, bob] + ), + replayState: .available, + presentJournalKeys: [alice, bob] + )) + // No Chronicle, a legacy one, and a stale format all still write. + #expect(GameArchiver.chronicleNeedsWrite( + stored: nil, + replayState: .waiting(missing: 1), + presentJournalKeys: [alice] + )) + #expect(GameArchiver.chronicleNeedsWrite( + stored: ( + isLegacy: true, + formatVersion: Archive.currentPayloadFormatVersion, + replayState: .waiting(missing: 1), + journalKeys: [] + ), + replayState: .waiting(missing: 1), + presentJournalKeys: [alice] + )) + #expect(GameArchiver.chronicleNeedsWrite( + stored: ( + isLegacy: false, + formatVersion: Archive.currentPayloadFormatVersion - 1, + replayState: .waiting(missing: 1), + journalKeys: [] + ), + replayState: .waiting(missing: 1), + presentJournalKeys: [alice] + )) + } + + @Test("an out-of-range missing count degrades instead of failing the Chronicle") + func outOfRangeMissingCountDegradesToUnavailable() throws { + let snapshot = sampleSnapshot(originalGameID: UUID()) + let package = try Archive.recordPackage( + from: snapshot, + replayState: .waiting(missing: Archive.maxJournalDeviceCount + 1) + ) + defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } } + // The count only words a progress message; the rest of the payload is + // still verified and playable, so it must not take the archive down. + let payload = try #require(Archive.payload(from: package.record)) + #expect(payload.replayState == .unavailable) + #expect(payload.journal.isEmpty) + } + @Test("migration grace gives old completions 14 days from v1.1.0 adoption") func archiveRetryWindowUsesLaterMigrationStart() { let completedAt = Date(timeIntervalSince1970: 1_600_000_000) diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -261,23 +261,13 @@ struct PendingChangeReapTests { #expect(delivery.phase == .sent) } - @Test("Invitation presentation and failure detail remain scoped to one game") - func invitationPresentationAndFailureState() { + @Test("Invitation failure detail remains scoped to one game and friend") + func invitationFailureState() { let deliveries = InviteDeliveryStore() let gameID = UUID() let otherGameID = UUID() let friendAuthorID = "_friend" - deliveries.beginPresentation(for: gameID) - deliveries.beginPresentation(for: gameID) - #expect(deliveries.hasActivePresentation(for: gameID)) - #expect(!deliveries.hasActivePresentation(for: otherGameID)) - - deliveries.endPresentation(for: gameID) - #expect(deliveries.hasActivePresentation(for: gameID)) - deliveries.endPresentation(for: gameID) - #expect(!deliveries.hasActivePresentation(for: gameID)) - deliveries.markFailed( recordName: "ping-failed", gameID: gameID, @@ -290,6 +280,24 @@ struct PendingChangeReapTests { ) #expect(delivery.phase == .failed) #expect(delivery.failure == .quotaExceeded) + + // The same friend on a different game is a separate invitation, and the + // announcement each one retracts is keyed the same way. + let other = deliveries.delivery( + gameID: otherGameID, + friendAuthorID: friendAuthorID + ) + #expect(other.phase == .idle) + #expect(other.failure == nil) + #expect( + InviteDeliveryStore.failureAnnouncementID( + gameID: gameID, + friendAuthorID: friendAuthorID + ) != InviteDeliveryStore.failureAnnouncementID( + gameID: otherGameID, + friendAuthorID: friendAuthorID + ) + ) } @Test("Confirmation timeout leaves the invite queued and non-destructive")