mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:05:20 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,44 +1,105 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
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
|
@MainActor
|
||||||
final class ChatComposerCoordinator {
|
final class ChatComposerCoordinator {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatComposerContext
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatComposerContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
let peerCandidates = autocompleteCandidates()
|
let peerCandidates = autocompleteCandidates()
|
||||||
let (suggestions, range) = viewModel.autocompleteService.getSuggestions(
|
let (suggestions, range) = context.autocompleteQuery(
|
||||||
for: text,
|
for: text,
|
||||||
peers: peerCandidates,
|
peers: peerCandidates,
|
||||||
cursorPosition: cursorPosition
|
cursorPosition: cursorPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
if !suggestions.isEmpty {
|
if !suggestions.isEmpty {
|
||||||
viewModel.autocompleteSuggestions = suggestions
|
context.autocompleteSuggestions = suggestions
|
||||||
viewModel.autocompleteRange = range
|
context.autocompleteRange = range
|
||||||
viewModel.showAutocomplete = true
|
context.showAutocomplete = true
|
||||||
viewModel.selectedAutocompleteIndex = 0
|
context.selectedAutocompleteIndex = 0
|
||||||
} else {
|
} else {
|
||||||
viewModel.autocompleteSuggestions = []
|
context.autocompleteSuggestions = []
|
||||||
viewModel.autocompleteRange = nil
|
context.autocompleteRange = nil
|
||||||
viewModel.showAutocomplete = false
|
context.showAutocomplete = false
|
||||||
viewModel.selectedAutocompleteIndex = 0
|
context.selectedAutocompleteIndex = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
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
|
context.showAutocomplete = false
|
||||||
viewModel.autocompleteSuggestions = []
|
context.autocompleteSuggestions = []
|
||||||
viewModel.autocompleteRange = nil
|
context.autocompleteRange = nil
|
||||||
viewModel.selectedAutocompleteIndex = 0
|
context.selectedAutocompleteIndex = 0
|
||||||
|
|
||||||
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
|
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
|
||||||
}
|
}
|
||||||
@@ -52,10 +113,10 @@ final class ChatComposerCoordinator {
|
|||||||
range: NSRange(location: 0, length: nsContent.length)
|
range: NSRange(location: 0, length: nsContent.length)
|
||||||
)
|
)
|
||||||
|
|
||||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
let peerNicknames = context.meshPeerNicknames()
|
||||||
var validTokens = Set(peerNicknames.values)
|
var validTokens = Set(peerNicknames.values)
|
||||||
validTokens.insert(viewModel.nickname)
|
validTokens.insert(context.nickname)
|
||||||
validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4)))
|
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
|
||||||
|
|
||||||
var mentions: [String] = []
|
var mentions: [String] = []
|
||||||
for match in matches {
|
for match in matches {
|
||||||
@@ -72,18 +133,18 @@ final class ChatComposerCoordinator {
|
|||||||
|
|
||||||
private extension ChatComposerCoordinator {
|
private extension ChatComposerCoordinator {
|
||||||
func autocompleteCandidates() -> [String] {
|
func autocompleteCandidates() -> [String] {
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
let values = viewModel.meshService.getPeerNicknames().values
|
let values = context.meshPeerNicknames().values
|
||||||
return Array(values.filter { $0 != viewModel.meshService.myNickname })
|
return Array(values.filter { $0 != context.meshNickname })
|
||||||
|
|
||||||
case .location(let channel):
|
case .location(let channel):
|
||||||
var tokens = Set<String>()
|
var tokens = Set<String>()
|
||||||
for (pubkey, nick) in viewModel.geoNicknames {
|
for (pubkey, nick) in context.geoNicknames {
|
||||||
tokens.insert("\(nick)#\(pubkey.suffix(4))")
|
tokens.insert("\(nick)#\(pubkey.suffix(4))")
|
||||||
}
|
}
|
||||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
||||||
tokens.remove(myToken)
|
tokens.remove(myToken)
|
||||||
}
|
}
|
||||||
return Array(tokens)
|
return Array(tokens)
|
||||||
|
|||||||
@@ -2,34 +2,96 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
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
|
@MainActor
|
||||||
final class ChatOutgoingCoordinator {
|
final class ChatOutgoingCoordinator {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatOutgoingContext
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatOutgoingContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(_ content: String) {
|
func sendMessage(_ content: String) {
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
|
|
||||||
if content.hasPrefix("/") {
|
if content.hasPrefix("/") {
|
||||||
Task { @MainActor [weak viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
viewModel?.handleCommand(content)
|
context?.handleCommand(content)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
if context.selectedPrivateChatPeer != nil {
|
||||||
viewModel.updatePrivateChatPeerIfNeeded()
|
context.updatePrivateChatPeerIfNeeded()
|
||||||
|
|
||||||
if let selectedPeer = viewModel.selectedPrivateChatPeer {
|
if let selectedPeer = context.selectedPrivateChatPeer {
|
||||||
viewModel.sendPrivateMessage(content, to: selectedPeer)
|
context.sendPrivateMessage(content, to: selectedPeer)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let mentions = viewModel.parseMentions(from: content)
|
let mentions = context.parseMentions(from: content)
|
||||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
||||||
guard let preparedMessage else { return }
|
guard let preparedMessage else { return }
|
||||||
|
|
||||||
@@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator {
|
|||||||
mentions: [String]
|
mentions: [String]
|
||||||
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
||||||
var geoContext: ChatViewModel.GeoOutgoingContext?
|
var geoContext: ChatViewModel.GeoOutgoingContext?
|
||||||
var displaySender = viewModel.nickname
|
var displaySender = context.nickname
|
||||||
var localSenderPeerID = viewModel.meshService.myPeerID
|
var localSenderPeerID = context.myPeerID
|
||||||
var messageID: String?
|
var messageID: String?
|
||||||
var messageTimestamp = Date()
|
var messageTimestamp = Date()
|
||||||
|
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
break
|
break
|
||||||
|
|
||||||
case .location(let channel):
|
case .location(let channel):
|
||||||
do {
|
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))
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||||
displaySender = viewModel.nickname + "#" + suffix
|
displaySender = context.nickname + "#" + suffix
|
||||||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
|
|
||||||
let teleported = viewModel.locationManager.teleported
|
let teleported = context.isTeleported
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: viewModel.nickname,
|
nickname: context.nickname,
|
||||||
teleported: teleported
|
teleported: teleported
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator {
|
|||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
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")
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
@@ -107,12 +169,12 @@ private extension ChatOutgoingCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendLocalEcho(_ message: BitchatMessage) {
|
func appendLocalEcho(_ message: BitchatMessage) {
|
||||||
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
|
context.appendTimelineMessage(message, to: context.activeChannel)
|
||||||
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
|
context.refreshVisibleMessages(from: context.activeChannel)
|
||||||
|
|
||||||
let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content)
|
let contentKey = context.normalizedContentKey(message.content)
|
||||||
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp)
|
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||||
viewModel.trimMessagesIfNeeded()
|
context.trimMessagesIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func routePublicMessage(
|
func routePublicMessage(
|
||||||
@@ -122,10 +184,10 @@ private extension ChatOutgoingCoordinator {
|
|||||||
messageID: String,
|
messageID: String,
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
) {
|
) {
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
viewModel.lastPublicActivityAt["mesh"] = Date()
|
context.recordPublicActivity(forChannelKey: "mesh")
|
||||||
viewModel.meshService.sendMessage(
|
context.sendMeshMessage(
|
||||||
originalContent,
|
originalContent,
|
||||||
mentions: mentions,
|
mentions: mentions,
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
@@ -133,18 +195,18 @@ private extension ChatOutgoingCoordinator {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .location(let channel):
|
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 {
|
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
|
||||||
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
|
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")
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task { @MainActor [weak viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
viewModel?.sendGeohash(context: geoContext)
|
context?.sendGeohash(context: geoContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,64 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
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<PeerID> { 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 {
|
final class ChatPeerListCoordinator: @unchecked Sendable {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatPeerListContext
|
||||||
private var recentlySeenPeers: Set<PeerID> = []
|
private var recentlySeenPeers: Set<PeerID> = []
|
||||||
private var lastNetworkNotificationTime = Date.distantPast
|
private var lastNetworkNotificationTime = Date.distantPast
|
||||||
private var networkResetTimer: Timer?
|
private var networkResetTimer: Timer?
|
||||||
private var networkEmptyTimer: Timer?
|
private var networkEmptyTimer: Timer?
|
||||||
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
|
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatPeerListContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -29,23 +77,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
|||||||
private extension ChatPeerListCoordinator {
|
private extension ChatPeerListCoordinator {
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePeerListUpdate(_ peers: [PeerID]) {
|
func handlePeerListUpdate(_ peers: [PeerID]) {
|
||||||
viewModel.isConnected = !peers.isEmpty
|
context.isConnected = !peers.isEmpty
|
||||||
cleanupStaleUnreadPeerIDs()
|
cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
let meshPeers = peers.filter { peerID in
|
let meshPeers = peers.filter { peerID in
|
||||||
viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID)
|
context.isPeerConnected(peerID) || context.isPeerReachable(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNetworkAvailability(meshPeers)
|
handleNetworkAvailability(meshPeers)
|
||||||
|
|
||||||
for peerID in peers {
|
for peerID in peers {
|
||||||
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.updateEncryptionStatusForPeers()
|
context.updateEncryptionStatusForPeers()
|
||||||
|
|
||||||
if viewModel.hasTrackedPrivateChatSelection {
|
if context.hasTrackedPrivateChatSelection {
|
||||||
viewModel.updatePrivateChatPeerIfNeeded()
|
context.updatePrivateChatPeerIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,34 +127,34 @@ private extension ChatPeerListCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cleanupStaleUnreadPeerIDs() {
|
func cleanupStaleUnreadPeerIDs() {
|
||||||
let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID))
|
let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID))
|
||||||
let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs)
|
let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs)
|
||||||
|
|
||||||
guard !staleIDs.isEmpty else {
|
guard !staleIDs.isEmpty else {
|
||||||
viewModel.cleanupOldReadReceipts()
|
context.cleanupOldReadReceipts()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var idsToRemove: [PeerID] = []
|
var idsToRemove: [PeerID] = []
|
||||||
|
|
||||||
for staleID in staleIDs {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
|
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
idsToRemove.append(staleID)
|
idsToRemove.append(staleID)
|
||||||
viewModel.unreadPrivateMessages.remove(staleID)
|
context.unreadPrivateMessages.remove(staleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !idsToRemove.isEmpty {
|
if !idsToRemove.isEmpty {
|
||||||
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
|
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.cleanupOldReadReceipts()
|
context.cleanupOldReadReceipts()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -121,18 +169,14 @@ private extension ChatPeerListCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNetworkResetTimerFired() {
|
func handleNetworkResetTimerFired() {
|
||||||
let activeMeshPeers = viewModel.meshService
|
let activeMeshPeerCount = context.activeMeshPeerCount()
|
||||||
.currentPeerSnapshots()
|
|
||||||
.filter { snapshot in
|
|
||||||
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if activeMeshPeers.isEmpty {
|
if activeMeshPeerCount == 0 {
|
||||||
recentlySeenPeers.removeAll()
|
recentlySeenPeers.removeAll()
|
||||||
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
|
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers",
|
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,18 +209,14 @@ private extension ChatPeerListCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNetworkEmptyTimerFired() {
|
func handleNetworkEmptyTimerFired() {
|
||||||
let activeMeshPeers = viewModel.meshService
|
let activeMeshPeerCount = context.activeMeshPeerCount()
|
||||||
.currentPeerSnapshots()
|
|
||||||
.filter { snapshot in
|
|
||||||
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if activeMeshPeers.isEmpty {
|
if activeMeshPeerCount == 0 {
|
||||||
recentlySeenPeers.removeAll()
|
recentlySeenPeers.removeAll()
|
||||||
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
|
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again",
|
"⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,14 +146,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
let unifiedPeerService: UnifiedPeerService
|
let unifiedPeerService: UnifiedPeerService
|
||||||
let autocompleteService: AutocompleteService
|
let autocompleteService: AutocompleteService
|
||||||
let deduplicationService: MessageDeduplicationService // internal for test access
|
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 lifecycleCoordinator = ChatLifecycleCoordinator(context: self)
|
||||||
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(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)
|
private lazy var messageFormatter = ChatMessageFormatter(viewModel: self)
|
||||||
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self)
|
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self)
|
||||||
lazy var deliveryCoordinator = ChatDeliveryCoordinator(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 publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
|
||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
|
|||||||
@@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
func configureGeoChannels() {
|
func configureGeoChannels() {
|
||||||
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
|
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
|
||||||
locationManager: viewModel.locationManager,
|
locationManager: viewModel.locationManager,
|
||||||
onChannelSwitch: { [weak viewModel] channel in
|
context: viewModel
|
||||||
viewModel?.switchLocationChannel(to: channel)
|
|
||||||
},
|
|
||||||
beginSampling: { [weak viewModel] geohashes in
|
|
||||||
viewModel?.beginGeohashSampling(for: geohashes)
|
|
||||||
},
|
|
||||||
endSampling: { [weak viewModel] in
|
|
||||||
viewModel?.endGeohashSampling()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,32 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
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
|
@MainActor
|
||||||
final class GeoChannelCoordinator {
|
final class GeoChannelCoordinator {
|
||||||
private let locationManager: LocationChannelManager
|
private let locationManager: LocationChannelManager
|
||||||
private let bookmarksStore: GeohashBookmarksStore
|
private let bookmarksStore: GeohashBookmarksStore
|
||||||
private let torManager: TorManager
|
private let torManager: TorManager
|
||||||
|
|
||||||
private let onChannelSwitch: (ChannelID) -> Void
|
private weak var context: (any GeoChannelContext)?
|
||||||
private let beginSampling: ([String]) -> Void
|
|
||||||
private let endSampling: () -> Void
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var regionalGeohashes: [String] = []
|
private var regionalGeohashes: [String] = []
|
||||||
@@ -27,16 +44,12 @@ final class GeoChannelCoordinator {
|
|||||||
locationManager: LocationChannelManager? = nil,
|
locationManager: LocationChannelManager? = nil,
|
||||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||||
torManager: TorManager? = nil,
|
torManager: TorManager? = nil,
|
||||||
onChannelSwitch: @escaping (ChannelID) -> Void,
|
context: any GeoChannelContext
|
||||||
beginSampling: @escaping ([String]) -> Void,
|
|
||||||
endSampling: @escaping () -> Void
|
|
||||||
) {
|
) {
|
||||||
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
||||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
||||||
self.torManager = torManager ?? Self.defaultTorManager()
|
self.torManager = torManager ?? Self.defaultTorManager()
|
||||||
self.onChannelSwitch = onChannelSwitch
|
self.context = context
|
||||||
self.beginSampling = beginSampling
|
|
||||||
self.endSampling = endSampling
|
|
||||||
|
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
@@ -50,7 +63,7 @@ final class GeoChannelCoordinator {
|
|||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.onChannelSwitch(channel)
|
self.context?.switchLocationChannel(to: channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -84,7 +97,7 @@ final class GeoChannelCoordinator {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.onChannelSwitch(self.locationManager.selectedChannel)
|
self.context?.switchLocationChannel(to: self.locationManager.selectedChannel)
|
||||||
}
|
}
|
||||||
updateSampling()
|
updateSampling()
|
||||||
}
|
}
|
||||||
@@ -93,13 +106,13 @@ final class GeoChannelCoordinator {
|
|||||||
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard !union.isEmpty else {
|
guard !union.isEmpty else {
|
||||||
endSampling()
|
context?.endGeohashSampling()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if torManager.isForeground() {
|
if torManager.isForeground() {
|
||||||
beginSampling(union)
|
context?.beginGeohashSampling(for: union)
|
||||||
} else {
|
} else {
|
||||||
endSampling()
|
context?.endGeohashSampling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PeerID> = []
|
||||||
|
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<PeerID> = []
|
||||||
|
var reachableMeshPeers: Set<PeerID> = []
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,9 +418,11 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
try await Task.sleep(nanoseconds: 150_000_000)
|
try await Task.sleep(nanoseconds: 150_000_000)
|
||||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
|
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
|
||||||
|
|
||||||
|
// Generous deadline: the detached verification task can be starved
|
||||||
|
// under parallel test load.
|
||||||
viewModel.handleNostrMessage(giftWrap)
|
viewModel.handleNostrMessage(giftWrap)
|
||||||
var recorded = false
|
var recorded = false
|
||||||
for _ in 0..<200 {
|
for _ in 0..<600 {
|
||||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||||
recorded = true
|
recorded = true
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user