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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 09:50:47 +02:00
co-authored by Claude Fable 5
parent 09087b74cc
commit 6c0dbbbd0d
2 changed files with 23 additions and 8 deletions
@@ -30,6 +30,9 @@ protocol ChatLifecycleContext: AnyObject {
func markPrivateMessagesAsRead(from peerID: PeerID) func markPrivateMessagesAsRead(from peerID: PeerID)
/// Marks the chat read in the private chat manager (sends pending mesh READ acks). /// Marks the chat read in the private chat manager (sends pending mesh READ acks).
func markChatAsRead(from peerID: PeerID) 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 synchronizePrivateConversationStore()
func addSystemMessage(_ content: String) func addSystemMessage(_ content: String)
@@ -83,6 +86,14 @@ extension ChatViewModel: ChatLifecycleContext {
privateChatManager.markAsRead(from: peerID) privateChatManager.markAsRead(from: peerID)
} }
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Task { @MainActor in
work()
}
}
}
func stopMeshServices() { func stopMeshServices() {
meshService.stopServices() meshService.stopServices()
} }
@@ -119,10 +130,8 @@ final class ChatLifecycleCoordinator {
markPrivateMessagesAsRead(from: peerID) markPrivateMessagesAsRead(from: peerID)
let context = self.context let context = self.context
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak context] in context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
Task { @MainActor in context?.markPrivateMessagesAsRead(from: peerID)
context?.markPrivateMessagesAsRead(from: peerID)
}
} }
} }
@@ -54,6 +54,13 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
managerReadMarks.append(peerID) 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 synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) } func addSystemMessage(_ content: String) { systemMessages.append(content) }
@@ -299,10 +306,9 @@ struct ChatLifecycleCoordinatorContextTests {
#expect(context.refreshBluetoothStateCount == 2) #expect(context.refreshBluetoothStateCount == 2)
#expect(context.managerReadMarks == [peerID]) #expect(context.managerReadMarks == [peerID])
let deadline = Date().addingTimeInterval(2) // The mock executes scheduled work synchronously, so the delayed
while context.ownerLevelReadPasses.isEmpty && Date() < deadline { // owner-level pass has already run - no wall-clock polling.
try? await Task.sleep(nanoseconds: 20_000_000) #expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds])
}
#expect(context.ownerLevelReadPasses == [peerID]) #expect(context.ownerLevelReadPasses == [peerID])
} }
@Test @MainActor @Test @MainActor