mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +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:
@@ -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)
|
||||
#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
|
||||
|
||||
@@ -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