From 6091ee83ad1dc492996bf993538557a9ccae4a8d Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 22:13:09 +0200 Subject: [PATCH] Finish coordinator migration: zero ChatViewModel back-references remain ChatPeerListCoordinator, ChatComposerCoordinator, ChatOutgoingCoordinator, and GeoChannelCoordinator complete the migration; every coordinator now depends on a narrow @MainActor context protocol. GeoChannelCoordinator's three injected closures collapse into a weak context. New intent op recordPublicActivity(forChannelKey:) keeps lastPublicActivityAt single-writer. 15 new mock-context tests; flaky-poll deadline in the gift-wrap dedup test raised for parallel load. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatComposerCoordinator.swift | 115 ++++++--- .../ViewModels/ChatOutgoingCoordinator.swift | 122 +++++++--- .../ViewModels/ChatPeerListCoordinator.swift | 100 +++++--- bitchat/ViewModels/ChatViewModel.swift | 6 +- .../ChatViewModelBootstrapper.swift | 10 +- .../ViewModels/GeoChannelCoordinator.swift | 41 ++-- .../ChatComposerCoordinatorContextTests.swift | 157 +++++++++++++ .../ChatOutgoingCoordinatorContextTests.swift | 221 ++++++++++++++++++ .../ChatPeerListCoordinatorContextTests.swift | 165 +++++++++++++ .../ChatViewModelExtensionsTests.swift | 4 +- .../GeoChannelCoordinatorContextTests.swift | 196 ++++++++++++++++ 11 files changed, 1023 insertions(+), 114 deletions(-) create mode 100644 bitchatTests/ChatComposerCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatOutgoingCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatPeerListCoordinatorContextTests.swift create mode 100644 bitchatTests/GeoChannelCoordinatorContextTests.swift diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift index 133e3f1c..c554a9e3 100644 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -1,44 +1,105 @@ import BitFoundation import Foundation +/// The narrow surface `ChatComposerCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatComposerContext: AnyObject { + // MARK: Autocomplete UI state + var autocompleteSuggestions: [String] { get set } + var autocompleteRange: NSRange? { get set } + var showAutocomplete: Bool { get set } + var selectedAutocompleteIndex: Int { get set } + /// Computes mention suggestions for the text up to the cursor. + func autocompleteQuery( + for text: String, + peers: [String], + cursorPosition: Int + ) -> (suggestions: [String], range: NSRange?) + /// Replaces the matched range in `text` with the chosen suggestion. + func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String + + // MARK: Identity & channel state + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + /// The transport's own nickname (excluded from autocomplete candidates). + var meshNickname: String { get } + func meshPeerNicknames() -> [PeerID: String] + + // MARK: Geohash identity (shared with the other contexts) + var geoNicknames: [String: String] { get } + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity +} + +extension ChatViewModel: ChatComposerContext { + // `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`, + // `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`, + // `geoNicknames`, `meshPeerNicknames()`, and + // `deriveNostrIdentity(forGeohash:)` are shared requirements with the + // other contexts or satisfied by existing `ChatViewModel` members. The + // members below flatten nested service accesses into intent-named calls. + + func autocompleteQuery( + for text: String, + peers: [String], + cursorPosition: Int + ) -> (suggestions: [String], range: NSRange?) { + autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition) + } + + func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { + autocompleteService.applySuggestion(suggestion, to: text, range: range) + } + + var meshNickname: String { + meshService.myNickname + } +} + @MainActor final class ChatComposerCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatComposerContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatComposerContext) { + self.context = context } func updateAutocomplete(for text: String, cursorPosition: Int) { let peerCandidates = autocompleteCandidates() - let (suggestions, range) = viewModel.autocompleteService.getSuggestions( + let (suggestions, range) = context.autocompleteQuery( for: text, peers: peerCandidates, cursorPosition: cursorPosition ) if !suggestions.isEmpty { - viewModel.autocompleteSuggestions = suggestions - viewModel.autocompleteRange = range - viewModel.showAutocomplete = true - viewModel.selectedAutocompleteIndex = 0 + context.autocompleteSuggestions = suggestions + context.autocompleteRange = range + context.showAutocomplete = true + context.selectedAutocompleteIndex = 0 } else { - viewModel.autocompleteSuggestions = [] - viewModel.autocompleteRange = nil - viewModel.showAutocomplete = false - viewModel.selectedAutocompleteIndex = 0 + context.autocompleteSuggestions = [] + context.autocompleteRange = nil + context.showAutocomplete = false + context.selectedAutocompleteIndex = 0 } } func completeNickname(_ nickname: String, in text: inout String) -> Int { - guard let range = viewModel.autocompleteRange else { return text.count } + guard let range = context.autocompleteRange else { return text.count } - text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range) + text = context.applyAutocompleteSuggestion(nickname, to: text, range: range) - viewModel.showAutocomplete = false - viewModel.autocompleteSuggestions = [] - viewModel.autocompleteRange = nil - viewModel.selectedAutocompleteIndex = 0 + context.showAutocomplete = false + context.autocompleteSuggestions = [] + context.autocompleteRange = nil + context.selectedAutocompleteIndex = 0 return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) } @@ -52,10 +113,10 @@ final class ChatComposerCoordinator { range: NSRange(location: 0, length: nsContent.length) ) - let peerNicknames = viewModel.meshService.getPeerNicknames() + let peerNicknames = context.meshPeerNicknames() var validTokens = Set(peerNicknames.values) - validTokens.insert(viewModel.nickname) - validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4))) + validTokens.insert(context.nickname) + validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4))) var mentions: [String] = [] for match in matches { @@ -72,18 +133,18 @@ final class ChatComposerCoordinator { private extension ChatComposerCoordinator { func autocompleteCandidates() -> [String] { - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: - let values = viewModel.meshService.getPeerNicknames().values - return Array(values.filter { $0 != viewModel.meshService.myNickname }) + let values = context.meshPeerNicknames().values + return Array(values.filter { $0 != context.meshNickname }) case .location(let channel): var tokens = Set() - for (pubkey, nick) in viewModel.geoNicknames { + for (pubkey, nick) in context.geoNicknames { tokens.insert("\(nick)#\(pubkey.suffix(4))") } - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4)) + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) tokens.remove(myToken) } return Array(tokens) diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 555f9fc5..5cf5d87b 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -2,34 +2,96 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatOutgoingCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatOutgoingContext: AnyObject { + // MARK: Identity & channel state + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + var selectedPrivateChatPeer: PeerID? { get } + var isTeleported: Bool { get } + + // MARK: Commands & private messages + func handleCommand(_ command: String) + func updatePrivateChatPeerIfNeeded() + func sendPrivateMessage(_ content: String, to peerID: PeerID) + + // MARK: Public timeline (local echo) + func parseMentions(from content: String) -> [String] + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func trimMessagesIfNeeded() + func addSystemMessage(_ content: String) + + // MARK: Content dedup + func normalizedContentKey(_ content: String) -> String + func recordContentKey(_ key: String, timestamp: Date) + + // MARK: Outbound routing + /// Stamps "now" as the channel's last public activity (background nudges). + /// (Single mutation path for the owner's `lastPublicActivityAt`; this + /// coordinator never reads it.) + func recordPublicActivity(forChannelKey key: String) + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) + func sendGeohash(context: ChatViewModel.GeoOutgoingContext) + + // MARK: Geohash identity (shared with the other contexts) + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity +} + +extension ChatViewModel: ChatOutgoingContext { + // `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`, + // `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`, + // `sendPrivateMessage(_:to:)`, `parseMentions(from:)`, + // `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`, + // `trimMessagesIfNeeded()`, `addSystemMessage(_:)`, + // `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`, + // `sendMeshMessage(_:mentions:messageID:timestamp:)`, + // `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are + // shared requirements with the other contexts or satisfied by existing + // `ChatViewModel` members. The single-writer intent op below lives next to + // its backing state's owner. + + func recordPublicActivity(forChannelKey key: String) { + lastPublicActivityAt[key] = Date() + } +} + @MainActor final class ChatOutgoingCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatOutgoingContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatOutgoingContext) { + self.context = context } func sendMessage(_ content: String) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } if content.hasPrefix("/") { - Task { @MainActor [weak viewModel] in - viewModel?.handleCommand(content) + Task { @MainActor [weak context = self.context] in + context?.handleCommand(content) } return } - if viewModel.selectedPrivateChatPeer != nil { - viewModel.updatePrivateChatPeerIfNeeded() + if context.selectedPrivateChatPeer != nil { + context.updatePrivateChatPeerIfNeeded() - if let selectedPeer = viewModel.selectedPrivateChatPeer { - viewModel.sendPrivateMessage(content, to: selectedPeer) + if let selectedPeer = context.selectedPrivateChatPeer { + context.sendPrivateMessage(content, to: selectedPeer) } return } - let mentions = viewModel.parseMentions(from: content) + let mentions = context.parseMentions(from: content) let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions) guard let preparedMessage else { return } @@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator { mentions: [String] ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { var geoContext: ChatViewModel.GeoOutgoingContext? - var displaySender = viewModel.nickname - var localSenderPeerID = viewModel.meshService.myPeerID + var displaySender = context.nickname + var localSenderPeerID = context.myPeerID var messageID: String? var messageTimestamp = Date() - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: break case .location(let channel): do { - let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = viewModel.nickname + "#" + suffix + displaySender = context.nickname + "#" + suffix localSenderPeerID = PeerID(nostr: identity.publicKeyHex) - let teleported = viewModel.locationManager.teleported + let teleported = context.isTeleported let event = try NostrProtocol.createEphemeralGeohashEvent( content: trimmed, geohash: channel.geohash, senderIdentity: identity, - nickname: viewModel.nickname, + nickname: context.nickname, teleported: teleported ) @@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator { ) } catch { SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return nil @@ -107,12 +169,12 @@ private extension ChatOutgoingCoordinator { } func appendLocalEcho(_ message: BitchatMessage) { - viewModel.timelineStore.append(message, to: viewModel.activeChannel) - viewModel.refreshVisibleMessages(from: viewModel.activeChannel) + context.appendTimelineMessage(message, to: context.activeChannel) + context.refreshVisibleMessages(from: context.activeChannel) - let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content) - viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp) - viewModel.trimMessagesIfNeeded() + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + context.trimMessagesIfNeeded() } func routePublicMessage( @@ -122,10 +184,10 @@ private extension ChatOutgoingCoordinator { messageID: String, timestamp: Date ) { - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: - viewModel.lastPublicActivityAt["mesh"] = Date() - viewModel.meshService.sendMessage( + context.recordPublicActivity(forChannelKey: "mesh") + context.sendMeshMessage( originalContent, mentions: mentions, messageID: messageID, @@ -133,18 +195,18 @@ private extension ChatOutgoingCoordinator { ) case .location(let channel): - viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date() + context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") guard let geoContext, geoContext.channel.geohash == channel.geohash else { SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session) - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return } - Task { @MainActor [weak viewModel] in - viewModel?.sendGeohash(context: geoContext) + Task { @MainActor [weak context = self.context] in + context?.sendGeohash(context: geoContext) } } } diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 80a24f2b..f2fff0af 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -2,16 +2,64 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatPeerListCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatPeerListCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatPeerListContext: AnyObject { + // MARK: Connection & chat state + var isConnected: Bool { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + var unreadPrivateMessages: Set { get set } + var hasTrackedPrivateChatSelection: Bool { get } + func updatePrivateChatPeerIfNeeded() + func cleanupOldReadReceipts() + + // MARK: Peers & sessions + var unifiedPeers: [BitchatPeer] { get } + func isPeerConnected(_ peerID: PeerID) -> Bool + func isPeerReachable(_ peerID: PeerID) -> Bool + /// Number of mesh peers currently connected or reachable, from the + /// transport's live peer snapshots. + func activeMeshPeerCount() -> Int + func registerEphemeralSession(peerID: PeerID) + func updateEncryptionStatusForPeers() +} + +extension ChatViewModel: ChatPeerListContext { + // `isConnected`, `privateChats`, `unreadPrivateMessages`, + // `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`, + // `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`, + // `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and + // `updateEncryptionStatusForPeers()` are shared requirements with the + // other contexts or satisfied by existing `ChatViewModel` members. The + // member below flattens the nested transport access into an intent-named + // call. + + func activeMeshPeerCount() -> Int { + meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + } + .count + } +} + final class ChatPeerListCoordinator: @unchecked Sendable { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatPeerListContext private var recentlySeenPeers: Set = [] private var lastNetworkNotificationTime = Date.distantPast private var networkResetTimer: Timer? private var networkEmptyTimer: Timer? private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatPeerListContext) { + self.context = context } deinit { @@ -29,23 +77,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable { private extension ChatPeerListCoordinator { @MainActor func handlePeerListUpdate(_ peers: [PeerID]) { - viewModel.isConnected = !peers.isEmpty + context.isConnected = !peers.isEmpty cleanupStaleUnreadPeerIDs() let meshPeers = peers.filter { peerID in - viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID) + context.isPeerConnected(peerID) || context.isPeerReachable(peerID) } handleNetworkAvailability(meshPeers) for peerID in peers { - viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + context.registerEphemeralSession(peerID: peerID) } - viewModel.updateEncryptionStatusForPeers() + context.updateEncryptionStatusForPeers() - if viewModel.hasTrackedPrivateChatSelection { - viewModel.updatePrivateChatPeerIfNeeded() + if context.hasTrackedPrivateChatSelection { + context.updatePrivateChatPeerIfNeeded() } } @@ -79,34 +127,34 @@ private extension ChatPeerListCoordinator { @MainActor func cleanupStaleUnreadPeerIDs() { - let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID)) - let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs) + let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID)) + let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs) guard !staleIDs.isEmpty else { - viewModel.cleanupOldReadReceipts() + context.cleanupOldReadReceipts() return } var idsToRemove: [PeerID] = [] for staleID in staleIDs { - if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty { + if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty { continue } - if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty { + if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty { continue } idsToRemove.append(staleID) - viewModel.unreadPrivateMessages.remove(staleID) + context.unreadPrivateMessages.remove(staleID) } if !idsToRemove.isEmpty { SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) } - viewModel.cleanupOldReadReceipts() + context.cleanupOldReadReceipts() } @MainActor @@ -121,18 +169,14 @@ private extension ChatPeerListCoordinator { @MainActor func handleNetworkResetTimerFired() { - let activeMeshPeers = viewModel.meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID) - } + let activeMeshPeerCount = context.activeMeshPeerCount() - if activeMeshPeers.isEmpty { + if activeMeshPeerCount == 0 { recentlySeenPeers.removeAll() SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) } else { SecureLogger.debug( - "⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", + "⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers", category: .session ) } @@ -165,18 +209,14 @@ private extension ChatPeerListCoordinator { @MainActor func handleNetworkEmptyTimerFired() { - let activeMeshPeers = viewModel.meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID) - } + let activeMeshPeerCount = context.activeMeshPeerCount() - if activeMeshPeers.isEmpty { + if activeMeshPeerCount == 0 { recentlySeenPeers.removeAll() SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) } else { SecureLogger.debug( - "⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", + "⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again", category: .session ) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index e5e3baa6..4318ce1e 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -146,14 +146,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let unifiedPeerService: UnifiedPeerService let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access - private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self) + private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self) private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) - private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) + private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) - lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self) + lazy var composerCoordinator = ChatComposerCoordinator(context: self) lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index fa23bb52..3fec30e0 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper { func configureGeoChannels() { viewModel.geoChannelCoordinator = GeoChannelCoordinator( locationManager: viewModel.locationManager, - onChannelSwitch: { [weak viewModel] channel in - viewModel?.switchLocationChannel(to: channel) - }, - beginSampling: { [weak viewModel] geohashes in - viewModel?.beginGeohashSampling(for: geohashes) - }, - endSampling: { [weak viewModel] in - viewModel?.endGeohashSampling() - } + context: viewModel ) } diff --git a/bitchat/ViewModels/GeoChannelCoordinator.swift b/bitchat/ViewModels/GeoChannelCoordinator.swift index 60879334..51af663a 100644 --- a/bitchat/ViewModels/GeoChannelCoordinator.swift +++ b/bitchat/ViewModels/GeoChannelCoordinator.swift @@ -9,15 +9,32 @@ import Combine import Foundation import Tor +/// The narrow surface `GeoChannelCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of capturing `ChatViewModel` in +/// per-callback closures. This keeps the coordinator independently testable +/// (see `GeoChannelCoordinatorContextTests`) and makes its true dependencies +/// explicit. Held `weak` — the owner retains the coordinator, and every +/// callback was previously a `[weak viewModel]` capture. +@MainActor +protocol GeoChannelContext: AnyObject { + func switchLocationChannel(to channel: ChannelID) + func beginGeohashSampling(for geohashes: [String]) + func endGeohashSampling() +} + +// `switchLocationChannel(to:)`, `beginGeohashSampling(for:)`, and +// `endGeohashSampling()` are satisfied by existing `ChatViewModel` members. +extension ChatViewModel: GeoChannelContext {} + @MainActor final class GeoChannelCoordinator { private let locationManager: LocationChannelManager private let bookmarksStore: GeohashBookmarksStore private let torManager: TorManager - private let onChannelSwitch: (ChannelID) -> Void - private let beginSampling: ([String]) -> Void - private let endSampling: () -> Void + private weak var context: (any GeoChannelContext)? private var cancellables = Set() private var regionalGeohashes: [String] = [] @@ -27,16 +44,12 @@ final class GeoChannelCoordinator { locationManager: LocationChannelManager? = nil, bookmarksStore: GeohashBookmarksStore? = nil, torManager: TorManager? = nil, - onChannelSwitch: @escaping (ChannelID) -> Void, - beginSampling: @escaping ([String]) -> Void, - endSampling: @escaping () -> Void + context: any GeoChannelContext ) { self.locationManager = locationManager ?? Self.defaultLocationManager() self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared self.torManager = torManager ?? Self.defaultTorManager() - self.onChannelSwitch = onChannelSwitch - self.beginSampling = beginSampling - self.endSampling = endSampling + self.context = context start() } @@ -50,7 +63,7 @@ final class GeoChannelCoordinator { .sink { [weak self] channel in guard let self else { return } Task { @MainActor in - self.onChannelSwitch(channel) + self.context?.switchLocationChannel(to: channel) } } .store(in: &cancellables) @@ -84,7 +97,7 @@ final class GeoChannelCoordinator { .store(in: &cancellables) Task { @MainActor in - self.onChannelSwitch(self.locationManager.selectedChannel) + self.context?.switchLocationChannel(to: self.locationManager.selectedChannel) } updateSampling() } @@ -93,13 +106,13 @@ final class GeoChannelCoordinator { let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes)) Task { @MainActor in guard !union.isEmpty else { - endSampling() + context?.endGeohashSampling() return } if torManager.isForeground() { - beginSampling(union) + context?.beginGeohashSampling(for: union) } else { - endSampling() + context?.endGeohashSampling() } } } diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift new file mode 100644 index 00000000..49891a1e --- /dev/null +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -0,0 +1,157 @@ +// +// ChatComposerCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatComposerCoordinator` against a mock `ChatComposerContext` — +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` exemplar. +// +// Scope note: mention parsing uses the shared, precompiled +// `ChatViewModel.Patterns.mention` regex (a static, stateless singleton); +// everything else flows through the mock context. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatComposerContext` proving that +/// `ChatComposerCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatComposerContext: ChatComposerContext { + // Autocomplete UI state + var autocompleteSuggestions: [String] = [] + var autocompleteRange: NSRange? + var showAutocomplete = false + var selectedAutocompleteIndex = -1 + var queryResult: (suggestions: [String], range: NSRange?) = ([], nil) + private(set) var queriedPeerCandidates: [[String]] = [] + private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = [] + + func autocompleteQuery( + for text: String, + peers: [String], + cursorPosition: Int + ) -> (suggestions: [String], range: NSRange?) { + queriedPeerCandidates.append(peers.sorted()) + return queryResult + } + + func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { + appliedSuggestions.append((suggestion, text, range)) + guard let textRange = Range(range, in: text) else { return text } + return text.replacingCharacters(in: textRange, with: suggestion) + } + + // Identity & channel state + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var meshNickname = "me" + var meshNicknamesByPeerID: [PeerID: String] = [:] + + func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID } + + // Geohash identity + var geoNicknames: [String: String] = [:] + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + Self.dummyIdentity + } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatComposerCoordinator` against `MockChatComposerContext` with +/// no `ChatViewModel`. +struct ChatComposerCoordinatorContextTests { + + @Test @MainActor + func updateAutocomplete_onMesh_excludesOwnNicknameAndPublishesSuggestions() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [ + PeerID(str: "1111111111111111"): "alice", + PeerID(str: "2222222222222222"): "bob", + PeerID(str: "3333333333333333"): "me", + ] + + // Matching query: suggestions and range are published, index resets. + context.queryResult = (["@alice"], NSRange(location: 0, length: 3)) + coordinator.updateAutocomplete(for: "@al", cursorPosition: 3) + #expect(context.queriedPeerCandidates == [["alice", "bob"]]) + #expect(context.autocompleteSuggestions == ["@alice"]) + #expect(context.autocompleteRange == NSRange(location: 0, length: 3)) + #expect(context.showAutocomplete) + #expect(context.selectedAutocompleteIndex == 0) + + // No match: all autocomplete state is cleared. + context.queryResult = ([], nil) + context.selectedAutocompleteIndex = 3 + coordinator.updateAutocomplete(for: "plain text", cursorPosition: 5) + #expect(context.autocompleteSuggestions.isEmpty) + #expect(context.autocompleteRange == nil) + #expect(!context.showAutocomplete) + #expect(context.selectedAutocompleteIndex == 0) + } + + @Test @MainActor + func updateAutocomplete_onLocationChannel_buildsGeoTokensWithoutOwnToken() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq")) + context.geoNicknames = [ + "aaaabbbbccccdddd": "carol", + // Own token (nickname#last-4-of-pubkey) must be removed; the dummy + // identity's public key hex ends in "2222". + "ffffeeeeddddcccc2222": "me", + ] + + coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3) + #expect(context.queriedPeerCandidates == [["carol#dddd"]]) + } + + @Test @MainActor + func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + + // Without an active range the text is untouched. + var text = "hello @al" + #expect(coordinator.completeNickname("@alice", in: &text) == text.count) + #expect(context.appliedSuggestions.isEmpty) + + // With a range the suggestion is applied and state cleared. + context.autocompleteRange = NSRange(location: 6, length: 3) + context.autocompleteSuggestions = ["@alice"] + context.showAutocomplete = true + let cursor = coordinator.completeNickname("@alice", in: &text) + #expect(text == "hello @alice") + #expect(cursor == 6 + "@alice".count + 1) + #expect(!context.showAutocomplete) + #expect(context.autocompleteSuggestions.isEmpty) + #expect(context.autocompleteRange == nil) + #expect(context.selectedAutocompleteIndex == 0) + } + + @Test @MainActor + func parseMentions_acceptsKnownPeersOwnNicknameAndHashSuffix() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"] + + let mentions = coordinator.parseMentions( + from: "hi @alice and @me and @me#0011 but not @stranger" + ) + #expect(Set(mentions) == ["alice", "me", "me#0011"]) + } +} diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift new file mode 100644 index 00000000..fcb61d8f --- /dev/null +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -0,0 +1,221 @@ +// +// ChatOutgoingCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatOutgoingCoordinator` against a mock `ChatOutgoingContext` — +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` exemplar. +// +// Scope note: the geohash path builds and signs a real Nostr event via +// `NostrProtocol.createEphemeralGeohashEvent` (pure crypto, no shared state); +// everything else flows through the mock context. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatOutgoingContext` proving that +/// `ChatOutgoingCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatOutgoingContext: ChatOutgoingContext { + // Identity & channel state + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var selectedPrivateChatPeer: PeerID? + var isTeleported = false + + // Commands & private messages + var selectedPeerAfterUpdate: PeerID?? + private(set) var handledCommands: [String] = [] + private(set) var updatePrivateChatPeerIfNeededCount = 0 + private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = [] + + func handleCommand(_ command: String) { handledCommands.append(command) } + + func updatePrivateChatPeerIfNeeded() { + updatePrivateChatPeerIfNeededCount += 1 + if let selectedPeerAfterUpdate { + selectedPrivateChatPeer = selectedPeerAfterUpdate + } + } + + func sendPrivateMessage(_ content: String, to peerID: PeerID) { + sentPrivateMessages.append((content, peerID)) + } + + // Public timeline (local echo) + private(set) var appendedTimelineMessages: [(message: BitchatMessage, channel: ChannelID)] = [] + private(set) var refreshedChannels: [ChannelID?] = [] + private(set) var trimMessagesIfNeededCount = 0 + private(set) var systemMessages: [String] = [] + + func parseMentions(from content: String) -> [String] { + content.contains("@bob") ? ["bob"] : [] + } + + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { + appendedTimelineMessages.append((message, channel)) + } + + func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } + func trimMessagesIfNeeded() { trimMessagesIfNeededCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + + // Content dedup + private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = [] + + func normalizedContentKey(_ content: String) -> String { "key:\(content)" } + func recordContentKey(_ key: String, timestamp: Date) { + recordedContentKeys.append((key, timestamp)) + } + + // Outbound routing + private(set) var recordedActivityKeys: [String] = [] + private(set) var sentMeshMessages: [(content: String, mentions: [String], messageID: String, timestamp: Date)] = [] + private(set) var sentGeohashContexts: [ChatViewModel.GeoOutgoingContext] = [] + + func recordPublicActivity(forChannelKey key: String) { recordedActivityKeys.append(key) } + + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + sentMeshMessages.append((content, mentions, messageID, timestamp)) + } + + func sendGeohash(context: ChatViewModel.GeoOutgoingContext) { + sentGeohashContexts.append(context) + } + + // Geohash identity + struct IdentityUnavailable: Error {} + var deriveNostrIdentityError: Error? + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + if let deriveNostrIdentityError { throw deriveNostrIdentityError } + return Self.dummyIdentity + } +} + +// MARK: - Helpers + +/// Lets the coordinator's internal `Task { @MainActor … }` hops run. +@MainActor +private func drainMainActorTasks() async { + for _ in 0..<10 { await Task.yield() } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatOutgoingCoordinator` against `MockChatOutgoingContext` with +/// no `ChatViewModel`. +struct ChatOutgoingCoordinatorContextTests { + + @Test @MainActor + func sendMessage_routesSlashCommandsAndDropsEmptyContent() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + + coordinator.sendMessage(" ") + coordinator.sendMessage("/who all") + await drainMainActorTasks() + + #expect(context.handledCommands == ["/who all"]) + #expect(context.appendedTimelineMessages.isEmpty) + #expect(context.sentMeshMessages.isEmpty) + } + + @Test @MainActor + func sendMessage_inPrivateChat_reResolvesPeerBeforeSending() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let shortPeer = PeerID(str: "1111111111111111") + let stablePeer = PeerID(str: String(repeating: "ab", count: 32)) + + // The selected peer is refreshed (short → stable) before sending. + context.selectedPrivateChatPeer = shortPeer + context.selectedPeerAfterUpdate = stablePeer + coordinator.sendMessage("hi there") + #expect(context.updatePrivateChatPeerIfNeededCount == 1) + #expect(context.sentPrivateMessages.map(\.peerID) == [stablePeer]) + #expect(context.sentPrivateMessages.map(\.content) == ["hi there"]) + + // If the refresh clears the selection, nothing is sent. + context.selectedPeerAfterUpdate = PeerID??.some(nil) + coordinator.sendMessage("dropped") + await drainMainActorTasks() + #expect(context.sentPrivateMessages.count == 1) + #expect(context.appendedTimelineMessages.isEmpty) + } + + @Test @MainActor + func sendMessage_onMesh_appendsLocalEchoRecordsActivityAndSends() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + + coordinator.sendMessage(" hello @bob ") + await drainMainActorTasks() + + // Local echo uses the trimmed content, own nickname/peer ID, mentions. + #expect(context.appendedTimelineMessages.count == 1) + let echo = context.appendedTimelineMessages[0] + #expect(echo.message.content == "hello @bob") + #expect(echo.message.sender == "me") + #expect(echo.message.senderPeerID == context.myPeerID) + #expect(echo.message.mentions == ["bob"]) + #expect(echo.channel == .mesh) + #expect(context.refreshedChannels == [.mesh]) + #expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"]) + #expect(context.trimMessagesIfNeededCount == 1) + + // The mesh send carries the original (untrimmed) content and reuses + // the echo's message ID and timestamp; activity is stamped for "mesh". + #expect(context.recordedActivityKeys == ["mesh"]) + #expect(context.sentMeshMessages.count == 1) + let sent = context.sentMeshMessages[0] + #expect(sent.content == " hello @bob ") + #expect(sent.mentions == ["bob"]) + #expect(sent.messageID == echo.message.id) + #expect(sent.timestamp == echo.message.timestamp) + } + + @Test @MainActor + func sendMessage_onLocationChannel_sendsGeohashEventOrFailsWithSystemMessage() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let channel = GeohashChannel(level: .city, geohash: "u4pruydq") + context.activeChannel = .location(channel) + context.isTeleported = true + + coordinator.sendMessage("hello geo") + await drainMainActorTasks() + + // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and + // the signed event's ID; the send context targets the same channel. + #expect(context.appendedTimelineMessages.count == 1) + let echo = context.appendedTimelineMessages[0].message + #expect(echo.sender == "me#2222") + #expect(context.recordedActivityKeys == ["geo:u4pruydq"]) + #expect(context.sentGeohashContexts.count == 1) + let geoContext = context.sentGeohashContexts[0] + #expect(geoContext.channel == channel) + #expect(geoContext.teleported) + #expect(geoContext.event.id == echo.id) + + // Identity derivation failure: system message, no echo, no send. + context.deriveNostrIdentityError = MockChatOutgoingContext.IdentityUnavailable() + coordinator.sendMessage("doomed") + await drainMainActorTasks() + #expect(context.systemMessages.count == 1) + #expect(context.appendedTimelineMessages.count == 1) + #expect(context.sentGeohashContexts.count == 1) + } +} diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift new file mode 100644 index 00000000..85726978 --- /dev/null +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -0,0 +1,165 @@ +// +// ChatPeerListCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext` — +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` / +// `ChatTransportEventCoordinatorContextTests` exemplars. +// +// Scope note: the network-availability notification path posts through +// `NotificationService.shared` (a singleton) and arms wall-clock timers. These +// tests keep every peer mesh-inactive (`isPeerConnected`/`isPeerReachable` +// both false) so that path is never reached; the timer-driven reset flows are +// covered by integration-level tests. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatPeerListContext` proving that +/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatPeerListContext: ChatPeerListContext { + // Connection & chat state + var isConnected = false + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var hasTrackedPrivateChatSelection = false + private(set) var updatePrivateChatPeerIfNeededCount = 0 + private(set) var cleanupOldReadReceiptsCount = 0 + + func updatePrivateChatPeerIfNeeded() { + updatePrivateChatPeerIfNeededCount += 1 + } + + func cleanupOldReadReceipts() { + cleanupOldReadReceiptsCount += 1 + } + + // Peers & sessions + var unifiedPeers: [BitchatPeer] = [] + var connectedMeshPeers: Set = [] + var reachableMeshPeers: Set = [] + var activeMeshPeerCountValue = 0 + private(set) var registeredEphemeralSessions: [PeerID] = [] + private(set) var updateEncryptionStatusForPeersCount = 0 + + func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) } + func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) } + func activeMeshPeerCount() -> Int { activeMeshPeerCountValue } + func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } + func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 } +} + +// MARK: - Helpers + +/// Lets the coordinator's internal `Task { @MainActor … }` hops run. +@MainActor +private func drainMainActorTasks() async { + for _ in 0..<10 { await Task.yield() } +} + +private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "alice", + content: "hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with +/// no `ChatViewModel`. +struct ChatPeerListCoordinatorContextTests { + + @Test @MainActor + func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerA = PeerID(str: "0011223344556677") + let peerB = PeerID(str: "8899aabbccddeeff") + context.isConnected = true + + // Empty list: disconnected, read-receipt hygiene still runs, no sessions. + coordinator.didUpdatePeerList([]) + await drainMainActorTasks() + #expect(!context.isConnected) + #expect(context.cleanupOldReadReceiptsCount == 1) + #expect(context.registeredEphemeralSessions.isEmpty) + #expect(context.updateEncryptionStatusForPeersCount == 1) + + // Non-empty list: connected, every peer gets an ephemeral session. + coordinator.didUpdatePeerList([peerA, peerB]) + await drainMainActorTasks() + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerA, peerB]) + #expect(context.updateEncryptionStatusForPeersCount == 2) + #expect(context.cleanupOldReadReceiptsCount == 2) + } + + @Test @MainActor + func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerID = PeerID(str: "0011223344556677") + + coordinator.didUpdatePeerList([peerID]) + await drainMainActorTasks() + #expect(context.updatePrivateChatPeerIfNeededCount == 0) + + context.hasTrackedPrivateChatSelection = true + coordinator.didUpdatePeerList([peerID]) + await drainMainActorTasks() + #expect(context.updatePrivateChatPeerIfNeededCount == 1) + } + + @Test @MainActor + func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + + let currentPeer = PeerID(str: "0011223344556677") + let staleShortPeer = PeerID(str: "8899aabbccddeeff") + let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8)) + let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8)) + let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32)) + + context.unifiedPeers = [ + BitchatPeer( + peerID: currentPeer, + noisePublicKey: Data(repeating: 0x01, count: 32), + nickname: "alice" + ), + ] + context.unreadPrivateMessages = [ + currentPeer, + staleShortPeer, + geoDMWithMessages, + geoDMWithoutMessages, + noiseKeyWithMessages, + ] + context.privateChats = [ + geoDMWithMessages: [makeMessage(id: "geo-1")], + noiseKeyWithMessages: [makeMessage(id: "noise-1")], + ] + + coordinator.didUpdatePeerList([currentPeer]) + await drainMainActorTasks() + + // Stale IDs without a backing conversation are dropped; geo-DM and + // Noise-key IDs with stored messages survive, as does the live peer. + #expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages]) + #expect(context.cleanupOldReadReceiptsCount == 1) + } +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 666745f2..c4875321 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -418,9 +418,11 @@ struct ChatViewModelNostrExtensionTests { try await Task.sleep(nanoseconds: 150_000_000) #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) + // Generous deadline: the detached verification task can be starved + // under parallel test load. viewModel.handleNostrMessage(giftWrap) var recorded = false - for _ in 0..<200 { + for _ in 0..<600 { if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { recorded = true break diff --git a/bitchatTests/GeoChannelCoordinatorContextTests.swift b/bitchatTests/GeoChannelCoordinatorContextTests.swift new file mode 100644 index 00000000..9345b341 --- /dev/null +++ b/bitchatTests/GeoChannelCoordinatorContextTests.swift @@ -0,0 +1,196 @@ +// +// GeoChannelCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `GeoChannelCoordinator` against a mock `GeoChannelContext` — +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` exemplar. +// +// Scope note: the location/bookmark managers are real `LocationStateManager` +// instances backed by throwaway `UserDefaults` suites and mocked CoreLocation +// seams. `TorManager` has no test seam (private init singleton); sampling +// tests pin `TorManager.shared` to foreground (its default) so the +// begin-sampling branch is deterministic. +// + +import Testing +import Foundation +import CoreLocation +import Tor +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `GeoChannelContext` proving that +/// `GeoChannelCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockGeoChannelContext: GeoChannelContext { + private(set) var switchedChannels: [ChannelID] = [] + private(set) var beginSamplingCalls: [[String]] = [] + private(set) var endSamplingCount = 0 + + func switchLocationChannel(to channel: ChannelID) { switchedChannels.append(channel) } + func beginGeohashSampling(for geohashes: [String]) { beginSamplingCalls.append(geohashes.sorted()) } + func endGeohashSampling() { endSamplingCount += 1 } +} + +// MARK: - CoreLocation Seams + +private final class StubLocationManaging: LocationStateManaging { + weak var delegate: CLLocationManagerDelegate? + var desiredAccuracy: CLLocationAccuracy = 0 + var distanceFilter: CLLocationDistance = 0 + var authorizationStatus: CLAuthorizationStatus = .denied + + func requestWhenInUseAuthorization() {} + func requestLocation() {} + func startUpdatingLocation() {} + func stopUpdatingLocation() {} +} + +private final class StubLocationGeocoder: LocationStateGeocoding { + func cancelGeocode() {} + func reverseGeocodeLocation( + _ location: CLLocation, + completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void + ) { + completionHandler(nil, nil) + } +} + +// MARK: - Helpers + +@MainActor +private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager { + let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" + let defaults = storage ?? UserDefaults(suiteName: suiteName)! + if storage == nil { + defaults.removePersistentDomain(forName: suiteName) + } + return LocationStateManager( + storage: defaults, + locationManager: StubLocationManaging(), + geocoder: StubLocationGeocoder(), + shouldInitializeCoreLocation: false + ) +} + +/// Polls until `condition` holds, letting main-actor tasks and main-queue +/// Combine hops drain in between. +@MainActor +private func waitUntil(_ condition: () -> Bool) async -> Bool { + for _ in 0..<100 { + if condition() { return true } + await Task.yield() + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `GeoChannelCoordinator` against `MockGeoChannelContext` with no +/// `ChatViewModel`. +struct GeoChannelCoordinatorContextTests { + + @Test @MainActor + func start_publishesPersistedChannelAndEndsSamplingWithoutGeohashes() async throws { + let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + let persisted = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru")) + storage.set(try JSONEncoder().encode(persisted), forKey: "locationChannel.selected") + + let locationManager = makeLocationManager(storage: storage) + let context = MockGeoChannelContext() + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context + ) + defer { withExtendedLifetime(coordinator) {} } + + // The persisted selection is announced and, with no regional or + // bookmarked geohashes, sampling ends rather than begins. + #expect(await waitUntil { !context.switchedChannels.isEmpty && context.endSamplingCount > 0 }) + #expect(context.switchedChannels.allSatisfy { $0 == persisted }) + #expect(context.beginSamplingCalls.isEmpty) + } + + @Test @MainActor + func selectingChannel_propagatesSwitchToContext() async { + let locationManager = makeLocationManager() + let context = MockGeoChannelContext() + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context + ) + defer { withExtendedLifetime(coordinator) {} } + + #expect(await waitUntil { context.switchedChannels.contains(.mesh) }) + + let target = ChannelID.location(GeohashChannel(level: .neighborhood, geohash: "u4pruydq")) + locationManager.select(target) + #expect(await waitUntil { context.switchedChannels.last == target }) + } + + @Test @MainActor + func bookmarkChanges_beginAndEndGeohashSampling() async { + TorManager.shared.setAppForeground(true) + let locationManager = makeLocationManager() + let context = MockGeoChannelContext() + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context + ) + + // No geohashes yet: only end-sampling has run. + #expect(await waitUntil { context.endSamplingCount > 0 }) + #expect(context.beginSamplingCalls.isEmpty) + + // Bookmarking a geohash starts sampling it. + locationManager.toggleBookmark("u4pruydq") + #expect(await waitUntil { context.beginSamplingCalls.last == ["u4pruydq"] }) + + // Removing the last bookmark ends sampling again, and a manual + // refresh keeps reporting the empty state. + let endCountBeforeRemoval = context.endSamplingCount + locationManager.toggleBookmark("u4pruydq") + #expect(await waitUntil { context.endSamplingCount > endCountBeforeRemoval }) + + let endCountBeforeRefresh = context.endSamplingCount + coordinator.refreshSampling() + #expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh }) + } + + @Test @MainActor + func releasedContext_isHeldWeaklyAndSafelyIgnored() async { + let locationManager = makeLocationManager() + var context: MockGeoChannelContext? = MockGeoChannelContext() + weak var weakContext = context + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context! + ) + + #expect(await waitUntil { context?.switchedChannels.isEmpty == false }) + + // The coordinator must not keep the owner alive (it is owned by it). + context = nil + #expect(weakContext == nil) + + // Events after the owner is gone are safely dropped. + locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru"))) + locationManager.toggleBookmark("u4pruydq") + coordinator.refreshSampling() + for _ in 0..<10 { await Task.yield() } + try? await Task.sleep(nanoseconds: 50_000_000) + } +}