diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index ddbea7ab..43d29459 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -30,6 +30,9 @@ protocol ChatLifecycleContext: AnyObject { func markPrivateMessagesAsRead(from peerID: PeerID) /// Marks the chat read in the private chat manager (sends pending mesh READ acks). func markChatAsRead(from peerID: PeerID) + /// Schedules main-actor work after a UI-timing delay. Injected so tests + /// can run the work synchronously instead of polling wall-clock queues. + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) func synchronizePrivateConversationStore() func addSystemMessage(_ content: String) @@ -83,6 +86,14 @@ extension ChatViewModel: ChatLifecycleContext { privateChatManager.markAsRead(from: peerID) } + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + Task { @MainActor in + work() + } + } + } + func stopMeshServices() { meshService.stopServices() } @@ -119,10 +130,8 @@ final class ChatLifecycleCoordinator { markPrivateMessagesAsRead(from: peerID) let context = self.context - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak context] in - Task { @MainActor in - context?.markPrivateMessagesAsRead(from: peerID) - } + context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in + context?.markPrivateMessagesAsRead(from: peerID) } } diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index ca3afee8..45303bbc 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -54,6 +54,13 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { managerReadMarks.append(peerID) } + // Scheduled work runs synchronously so tests never poll wall-clock queues. + private(set) var scheduledDelays: [TimeInterval] = [] + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { + scheduledDelays.append(delay) + work() + } + func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } func addSystemMessage(_ content: String) { systemMessages.append(content) } @@ -299,10 +306,9 @@ struct ChatLifecycleCoordinatorContextTests { #expect(context.refreshBluetoothStateCount == 2) #expect(context.managerReadMarks == [peerID]) - let deadline = Date().addingTimeInterval(2) - while context.ownerLevelReadPasses.isEmpty && Date() < deadline { - try? await Task.sleep(nanoseconds: 20_000_000) - } + // The mock executes scheduled work synchronously, so the delayed + // owner-level pass has already run - no wall-clock polling. + #expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds]) #expect(context.ownerLevelReadPasses == [peerID]) } @Test @MainActor