From 6c0dbbbd0d400b58c28a85a688e4ec4d592ce24f Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 09:50:47 +0200 Subject: [PATCH] Make lifecycle delayed read-pass deterministic in tests The coordinator scheduled its delayed owner-level read pass via DispatchQueue.main.asyncAfter, which a busy CI runner's main queue can delay past any reasonable polling deadline. Scheduling is now an injected context member (scheduleOnMainAfter); the ChatViewModel witness keeps the exact asyncAfter behavior while the test mock runs the work synchronously, eliminating the wall-clock poll entirely. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatLifecycleCoordinator.swift | 17 +++++++++++++---- .../ChatLifecycleCoordinatorContextTests.swift | 14 ++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) 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