mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:05:19 +00:00
[codex] Refactor app runtime and ownership architecture (#1104)
* Refactor app runtime and view model architecture * Move app ownership into stores and coordinators * Fix smoke test environment injection * Stabilize fragmentation package tests * Fix coordinator build warnings * Clean up chat view model warnings * Fix Nostr relay startup coalescing --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private func makeArchitectureViewModel(
|
||||
locationManager: LocationChannelManager? = nil
|
||||
) -> ChatViewModel {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let locationManager = locationManager ?? makeArchitectureLocationManager()
|
||||
|
||||
return ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: MockTransport(),
|
||||
locationManager: locationManager
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeArchitectureLocationManager() -> LocationChannelManager {
|
||||
let suiteName = "AppArchitectureTests.\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName) ?? .standard
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
return LocationChannelManager(storage: storage)
|
||||
}
|
||||
|
||||
private func makeArchitectureSnapshot(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
connected: Bool,
|
||||
noisePublicKey: Data
|
||||
) -> TransportPeerSnapshot {
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: connected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitUntil(
|
||||
timeoutNanoseconds: UInt64 = 3_000_000_000,
|
||||
pollNanoseconds: UInt64 = 20_000_000,
|
||||
_ condition: @escaping @MainActor () -> Bool
|
||||
) async {
|
||||
let timeout = Double(timeoutNanoseconds) / 1_000_000_000
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
while !condition(), Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: pollNanoseconds)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("App Architecture Tests", .serialized)
|
||||
struct AppArchitectureTests {
|
||||
|
||||
@Test("PeerIdentityStore owns fingerprint, mapping, and verification state")
|
||||
@MainActor
|
||||
func peerIdentityStoreOwnsIdentityState() {
|
||||
let store = PeerIdentityStore()
|
||||
let shortPeerID = PeerID(str: "peer-short")
|
||||
let stablePeerID = PeerID(str: "peer-stable")
|
||||
let canonicalPeerID = PeerID(str: "peer-canonical")
|
||||
|
||||
store.setStablePeerID(stablePeerID, forShortID: shortPeerID)
|
||||
store.setFingerprint("fp-1", for: shortPeerID)
|
||||
store.setCachedEncryptionStatus(.noiseHandshaking, for: shortPeerID)
|
||||
store.setEncryptionStatus(.noiseSecured, for: shortPeerID)
|
||||
store.setVerified("fp-1", verified: true)
|
||||
|
||||
let migratedFingerprint = store.migrateFingerprintMapping(
|
||||
from: shortPeerID,
|
||||
to: canonicalPeerID
|
||||
)
|
||||
|
||||
#expect(store.stablePeerID(forShortID: shortPeerID) == stablePeerID)
|
||||
#expect(store.shortPeerID(forStablePeerID: stablePeerID) == shortPeerID)
|
||||
#expect(migratedFingerprint == "fp-1")
|
||||
#expect(store.fingerprint(for: shortPeerID) == nil)
|
||||
#expect(store.fingerprint(for: canonicalPeerID) == "fp-1")
|
||||
#expect(store.selectedPrivateChatFingerprint == "fp-1")
|
||||
#expect(store.encryptionStatus(for: shortPeerID) == .noiseSecured)
|
||||
#expect(store.cachedEncryptionStatus(for: shortPeerID) == nil)
|
||||
#expect(store.isVerified("fp-1"))
|
||||
|
||||
store.clearAll()
|
||||
|
||||
#expect(store.encryptionStatuses.isEmpty)
|
||||
#expect(store.verifiedFingerprints.isEmpty)
|
||||
#expect(store.peerFingerprintsByPeerID.isEmpty)
|
||||
#expect(store.selectedPrivateChatFingerprint == nil)
|
||||
#expect(store.stablePeerID(forShortID: shortPeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("LocationPresenceStore normalizes and resets geohash presence state")
|
||||
@MainActor
|
||||
func locationPresenceStoreNormalizesPresenceState() {
|
||||
let store = LocationPresenceStore()
|
||||
|
||||
store.setCurrentGeohash("U4PRUY")
|
||||
store.replaceGeoNicknames([
|
||||
"ABCDEF": "alice",
|
||||
"123456": "bob"
|
||||
])
|
||||
store.markTeleported("ABCDEF")
|
||||
store.replaceTeleportedGeo(Set(["FEDCBA", "123456"]))
|
||||
|
||||
#expect(store.currentGeohash == "u4pruy")
|
||||
#expect(store.geoNicknames["abcdef"] == "alice")
|
||||
#expect(store.geoNicknames["123456"] == "bob")
|
||||
#expect(store.teleportedGeo == Set(["fedcba", "123456"]))
|
||||
|
||||
store.reset()
|
||||
|
||||
#expect(store.currentGeohash == nil)
|
||||
#expect(store.geoNicknames.isEmpty)
|
||||
#expect(store.teleportedGeo.isEmpty)
|
||||
}
|
||||
|
||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||
let first = PeerHandle(
|
||||
id: "noise:abc123",
|
||||
routingPeerID: PeerID(str: "peer-a"),
|
||||
displayName: "alice",
|
||||
noisePublicKeyHex: "abc123",
|
||||
nostrPublicKey: nil
|
||||
)
|
||||
let second = PeerHandle(
|
||||
id: "noise:abc123",
|
||||
routingPeerID: PeerID(str: "peer-b"),
|
||||
displayName: "alice-renamed",
|
||||
noisePublicKeyHex: nil,
|
||||
nostrPublicKey: "npub123"
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
#expect(Set([first, second]).count == 1)
|
||||
}
|
||||
|
||||
@Test("ConversationStore normalizes timeline ordering and duplicates")
|
||||
@MainActor
|
||||
func conversationStoreNormalizesMessages() {
|
||||
let store = ConversationStore()
|
||||
let older = BitchatMessage(
|
||||
id: "m1",
|
||||
sender: "alice",
|
||||
content: "first",
|
||||
timestamp: Date(timeIntervalSince1970: 1),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "peer-a")
|
||||
)
|
||||
let newer = BitchatMessage(
|
||||
id: "m2",
|
||||
sender: "alice",
|
||||
content: "second",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "peer-a")
|
||||
)
|
||||
let replacement = BitchatMessage(
|
||||
id: "m2",
|
||||
sender: "alice",
|
||||
content: "second-updated",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "peer-a")
|
||||
)
|
||||
|
||||
store.replaceMessages([newer, older, replacement], for: ConversationID.mesh)
|
||||
|
||||
let messages = store.messages(for: ConversationID.mesh)
|
||||
#expect(messages.map(\.id) == ["m1", "m2"])
|
||||
#expect(messages.last?.content == "second-updated")
|
||||
}
|
||||
|
||||
@Test("ConversationStore tracks unread direct conversations with canonical IDs")
|
||||
@MainActor
|
||||
func conversationStoreTracksUnreadDirectConversations() {
|
||||
let store = ConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let peerID = PeerID(str: "peer-1")
|
||||
let message = BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "alice",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "bob",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
store.synchronizePrivateChats(
|
||||
[peerID: [message]],
|
||||
unreadPeerIDs: Set([peerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
let conversationID = ConversationID.direct(
|
||||
resolver.canonicalHandle(for: peerID, displayName: "alice")
|
||||
)
|
||||
|
||||
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"])
|
||||
#expect(store.unreadConversations.contains(conversationID))
|
||||
|
||||
store.markRead(conversationID)
|
||||
#expect(!store.unreadConversations.contains(conversationID))
|
||||
}
|
||||
|
||||
@Test("ConversationStore tracks the selected app conversation context")
|
||||
@MainActor
|
||||
func conversationStoreTracksSelectedConversationContext() {
|
||||
let store = ConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let shortPeerID = PeerID(str: "0011223344556677")
|
||||
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
||||
let peer = BitchatPeer(
|
||||
peerID: shortPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
|
||||
resolver.register(peers: [peer])
|
||||
store.synchronizeSelection(
|
||||
activeChannel: geohashChannel,
|
||||
selectedPeerID: shortPeerID,
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
let expectedConversationID = ConversationID.direct(
|
||||
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
|
||||
)
|
||||
|
||||
#expect(store.activeChannel == geohashChannel)
|
||||
#expect(store.selectedPrivatePeerID == shortPeerID)
|
||||
#expect(store.selectedConversationID == expectedConversationID)
|
||||
|
||||
store.synchronizeSelection(
|
||||
activeChannel: ChannelID.mesh,
|
||||
selectedPeerID: nil,
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
#expect(store.activeChannel == ChannelID.mesh)
|
||||
#expect(store.selectedPrivatePeerID == nil)
|
||||
#expect(store.selectedConversationID == ConversationID.mesh)
|
||||
}
|
||||
|
||||
@Test("ConversationStore exposes direct conversations by the latest routing peer ID")
|
||||
@MainActor
|
||||
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
|
||||
let store = ConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let shortPeerID = PeerID(str: "0011223344556677")
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let firstMessage = BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "alice",
|
||||
content: "short id",
|
||||
timestamp: Date(timeIntervalSince1970: 1),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "builder",
|
||||
senderPeerID: shortPeerID
|
||||
)
|
||||
let secondMessage = BitchatMessage(
|
||||
id: "dm-2",
|
||||
sender: "alice",
|
||||
content: "full id",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "builder",
|
||||
senderPeerID: fullPeerID
|
||||
)
|
||||
|
||||
resolver.register(
|
||||
peer: BitchatPeer(
|
||||
peerID: shortPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
)
|
||||
store.synchronizePrivateChats(
|
||||
[shortPeerID: [firstMessage]],
|
||||
unreadPeerIDs: Set([shortPeerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
resolver.register(
|
||||
peer: BitchatPeer(
|
||||
peerID: fullPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
)
|
||||
store.synchronizePrivateChats(
|
||||
[fullPeerID: [secondMessage]],
|
||||
unreadPeerIDs: Set([fullPeerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID]))
|
||||
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"])
|
||||
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
|
||||
}
|
||||
|
||||
@Test("PrivateInboxModel mirrors direct message state from ConversationStore")
|
||||
@MainActor
|
||||
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
|
||||
let store = ConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let inboxModel = PrivateInboxModel(conversationStore: store)
|
||||
let messagePeerID = PeerID(str: "peer-1")
|
||||
let unreadOnlyPeerID = PeerID(str: "peer-2")
|
||||
let selectedOnlyPeerID = PeerID(str: "peer-3")
|
||||
let message = BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "alice",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "builder",
|
||||
senderPeerID: messagePeerID
|
||||
)
|
||||
|
||||
store.synchronizePrivateChats(
|
||||
[messagePeerID: [message]],
|
||||
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
store.synchronizeSelection(
|
||||
activeChannel: ChannelID.mesh,
|
||||
selectedPeerID: selectedOnlyPeerID,
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
await waitUntil {
|
||||
inboxModel.selectedPeerID == selectedOnlyPeerID &&
|
||||
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
|
||||
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
|
||||
}
|
||||
|
||||
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
|
||||
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
|
||||
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
|
||||
#expect(inboxModel.messages(for: unreadOnlyPeerID).isEmpty)
|
||||
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
|
||||
}
|
||||
|
||||
@Test("AppChromeModel mirrors nickname and unread state through focused models")
|
||||
@MainActor
|
||||
func appChromeModelMirrorsNicknameAndUnreadState() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let conversationStore = ConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||
|
||||
chromeModel.setNickname("builder")
|
||||
await waitUntil {
|
||||
viewModel.nickname == "builder" && chromeModel.nickname == "builder"
|
||||
}
|
||||
|
||||
#expect(viewModel.nickname == "builder")
|
||||
#expect(chromeModel.nickname == "builder")
|
||||
#expect(!chromeModel.hasUnreadPrivateMessages)
|
||||
|
||||
let peerID = PeerID(str: "peer-1")
|
||||
conversationStore.synchronizePrivateChats(
|
||||
[:],
|
||||
unreadPeerIDs: Set([peerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
await waitUntil {
|
||||
chromeModel.hasUnreadPrivateMessages
|
||||
}
|
||||
|
||||
#expect(chromeModel.hasUnreadPrivateMessages)
|
||||
}
|
||||
|
||||
@Test("AppChromeModel owns fingerprint and screenshot presentation state")
|
||||
@MainActor
|
||||
func appChromeModelOwnsPresentationState() {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let conversationStore = ConversationStore()
|
||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||
let peerID = PeerID(str: "peer-2")
|
||||
|
||||
chromeModel.showFingerprint(for: peerID)
|
||||
chromeModel.presentAppInfo()
|
||||
chromeModel.isLocationChannelsSheetPresented = true
|
||||
chromeModel.triggerScreenshotPrivacyWarning()
|
||||
|
||||
#expect(chromeModel.showingFingerprintFor == peerID)
|
||||
#expect(chromeModel.isAppInfoPresented)
|
||||
#expect(chromeModel.shouldSuppressScreenshotNotification)
|
||||
#expect(chromeModel.showScreenshotPrivacyWarning)
|
||||
|
||||
chromeModel.clearFingerprint()
|
||||
#expect(chromeModel.showingFingerprintFor == nil)
|
||||
}
|
||||
|
||||
@Test("PrivateConversationModel resolves canonical header state for the selected DM")
|
||||
@MainActor
|
||||
func privateConversationModelResolvesSelectedHeaderState() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
guard let transport = viewModel.meshService as? MockTransport else {
|
||||
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
||||
return
|
||||
}
|
||||
let conversationStore = viewModel.conversationStore
|
||||
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||
let conversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let shortPeerID = PeerID(str: "0011223344556677")
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
transport.peerNicknames[shortPeerID] = "alice"
|
||||
transport.reachablePeers.insert(shortPeerID)
|
||||
viewModel.allPeers = [
|
||||
BitchatPeer(
|
||||
peerID: shortPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: false,
|
||||
isReachable: true
|
||||
)
|
||||
]
|
||||
|
||||
conversationModel.startConversation(with: fullPeerID)
|
||||
await waitUntil {
|
||||
conversationModel.selectedPeerID == fullPeerID
|
||||
}
|
||||
|
||||
#expect(conversationModel.selectedPeerID == fullPeerID)
|
||||
#expect(conversationModel.selectedHeaderState?.headerPeerID == shortPeerID)
|
||||
#expect(conversationModel.selectedHeaderState?.displayName == "alice")
|
||||
#expect(conversationModel.selectedHeaderState?.availability == .meshReachable)
|
||||
#expect(conversationModel.selectedHeaderState?.encryptionStatus == .noHandshake)
|
||||
|
||||
conversationModel.endConversation()
|
||||
await waitUntil {
|
||||
conversationModel.selectedPeerID == nil
|
||||
}
|
||||
#expect(conversationModel.selectedPeerID == nil)
|
||||
#expect(conversationModel.selectedHeaderState == nil)
|
||||
}
|
||||
|
||||
@Test("ConversationUIModel mirrors composer state and forwards sends")
|
||||
@MainActor
|
||||
func conversationUIModelMirrorsComposerStateAndForwardsSends() async {
|
||||
let locationManager = makeArchitectureLocationManager()
|
||||
let viewModel = makeArchitectureViewModel(locationManager: locationManager)
|
||||
guard let transport = viewModel.meshService as? MockTransport else {
|
||||
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
||||
return
|
||||
}
|
||||
|
||||
let conversationStore = viewModel.conversationStore
|
||||
locationManager.select(.mesh)
|
||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
let uiModel = ConversationUIModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel,
|
||||
conversationStore: conversationStore
|
||||
)
|
||||
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
||||
defer {
|
||||
locationManager.select(.mesh)
|
||||
}
|
||||
viewModel.nickname = "builder"
|
||||
viewModel.autocompleteSuggestions = ["alice"]
|
||||
viewModel.showAutocomplete = true
|
||||
locationChannelsModel.select(geohashChannel)
|
||||
|
||||
await waitUntil {
|
||||
viewModel.activeChannel == geohashChannel &&
|
||||
uiModel.currentNickname == "builder" &&
|
||||
uiModel.showAutocomplete &&
|
||||
uiModel.autocompleteSuggestions == ["alice"] &&
|
||||
!uiModel.canSendMediaInCurrentContext
|
||||
}
|
||||
|
||||
#expect(viewModel.activeChannel == geohashChannel)
|
||||
#expect(uiModel.currentNickname == "builder")
|
||||
#expect(uiModel.showAutocomplete)
|
||||
#expect(uiModel.autocompleteSuggestions == ["alice"])
|
||||
#expect(!uiModel.canSendMediaInCurrentContext)
|
||||
|
||||
locationChannelsModel.select(ChannelID.mesh)
|
||||
await waitUntil {
|
||||
viewModel.activeChannel == ChannelID.mesh &&
|
||||
uiModel.canSendMediaInCurrentContext
|
||||
}
|
||||
|
||||
#expect(viewModel.activeChannel == ChannelID.mesh)
|
||||
#expect(uiModel.canSendMediaInCurrentContext)
|
||||
|
||||
uiModel.sendMessage("hello mesh")
|
||||
|
||||
await waitUntil {
|
||||
transport.sentMessages.last?.content == "hello mesh"
|
||||
}
|
||||
|
||||
#expect(transport.sentMessages.last?.content == "hello mesh")
|
||||
}
|
||||
|
||||
@Test("VerificationModel bridges selected conversation and fingerprint actions")
|
||||
@MainActor
|
||||
func verificationModelBridgesSelectedConversationAndFingerprintActions() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
guard let transport = viewModel.meshService as? MockTransport else {
|
||||
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
||||
return
|
||||
}
|
||||
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let fingerprint = "verified-fingerprint"
|
||||
let conversationStore = viewModel.conversationStore
|
||||
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
let verificationModel = VerificationModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel
|
||||
)
|
||||
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
transport.peerNicknames[peerID] = "alice"
|
||||
viewModel.allPeers = [
|
||||
BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data((0..<32).map(UInt8.init)),
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
]
|
||||
|
||||
privateConversationModel.startConversation(with: peerID)
|
||||
await waitUntil {
|
||||
verificationModel.selectedPeerID == peerID
|
||||
}
|
||||
|
||||
let presentation = verificationModel.fingerprintPresentation(for: peerID)
|
||||
#expect(verificationModel.selectedPeerID == peerID)
|
||||
#expect(presentation.peerNickname == "alice")
|
||||
#expect(presentation.theirFingerprint == fingerprint)
|
||||
#expect(!presentation.myFingerprint.isEmpty)
|
||||
#expect(!verificationModel.isVerified(peerID: peerID))
|
||||
|
||||
verificationModel.verifyFingerprint(for: peerID)
|
||||
await waitUntil {
|
||||
verificationModel.isVerified(peerID: peerID)
|
||||
}
|
||||
#expect(verificationModel.isVerified(peerID: peerID))
|
||||
|
||||
verificationModel.unverifyFingerprint(for: peerID)
|
||||
await waitUntil {
|
||||
!verificationModel.isVerified(peerID: peerID)
|
||||
}
|
||||
#expect(!verificationModel.isVerified(peerID: peerID))
|
||||
}
|
||||
|
||||
@Test("PeerListModel publishes mesh and geohash directory state")
|
||||
@MainActor
|
||||
func peerListModelPublishesDirectoryState() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
guard let transport = viewModel.meshService as? MockTransport else {
|
||||
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
||||
return
|
||||
}
|
||||
|
||||
let myPeerID = PeerID(str: "me-peer")
|
||||
let otherPeerID = PeerID(str: "0011223344556677")
|
||||
let geohash = "9q8yy"
|
||||
let remoteGeoID = String(repeating: "b", count: 64)
|
||||
let locationManager = makeArchitectureLocationManager()
|
||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
let otherNoiseKey = Data((0..<32).map(UInt8.init))
|
||||
let verifiedFingerprint = otherNoiseKey.sha256Fingerprint()
|
||||
|
||||
transport.myPeerID = myPeerID
|
||||
transport.peerFingerprints[otherPeerID] = verifiedFingerprint
|
||||
transport.peerNicknames[otherPeerID] = "alice"
|
||||
transport.reachablePeers.insert(otherPeerID)
|
||||
viewModel.nickname = "builder"
|
||||
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
||||
viewModel.unreadPrivateMessages = Set([otherPeerID])
|
||||
transport.updatePeerSnapshots([
|
||||
makeArchitectureSnapshot(
|
||||
peerID: myPeerID,
|
||||
nickname: "builder",
|
||||
connected: true,
|
||||
noisePublicKey: Data(repeating: 0, count: 32)
|
||||
),
|
||||
makeArchitectureSnapshot(
|
||||
peerID: otherPeerID,
|
||||
nickname: "alice",
|
||||
connected: false,
|
||||
noisePublicKey: otherNoiseKey
|
||||
)
|
||||
])
|
||||
|
||||
locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
await waitUntil {
|
||||
if case .location(let channel) = locationManager.selectedChannel {
|
||||
return channel.geohash == geohash && !viewModel.allPeers.isEmpty
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
viewModel.participantTracker.setActiveGeohash(geohash)
|
||||
viewModel.teleportedGeo = Set([remoteGeoID])
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash)
|
||||
if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() {
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash)
|
||||
}
|
||||
|
||||
let peerListModel = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: viewModel.conversationStore,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
await waitUntil {
|
||||
peerListModel.reachableMeshPeerCount == 1 &&
|
||||
peerListModel.connectedMeshPeerCount == 0 &&
|
||||
peerListModel.meshRows.contains(where: { $0.peerID == otherPeerID && $0.hasUnread }) &&
|
||||
peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported })
|
||||
}
|
||||
|
||||
let meshRow = peerListModel.meshRows.first(where: { $0.peerID == otherPeerID })
|
||||
#expect(peerListModel.reachableMeshPeerCount == 1)
|
||||
#expect(peerListModel.connectedMeshPeerCount == 0)
|
||||
#expect(meshRow?.displayName == "alice")
|
||||
#expect(meshRow?.showsVerifiedBadgeWhenOffline == true)
|
||||
#expect(meshRow?.hasUnread == true)
|
||||
#expect(peerListModel.visibleGeohashPeerCount >= 1)
|
||||
#expect(peerListModel.participantCount(for: geohash) >= 1)
|
||||
#expect(peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported }))
|
||||
|
||||
viewModel.participantTracker.clear()
|
||||
viewModel.teleportedGeo = []
|
||||
locationManager.markTeleported(for: geohash, false)
|
||||
locationManager.select(ChannelID.mesh)
|
||||
await waitUntil {
|
||||
if case ChannelID.mesh = locationManager.selectedChannel {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,31 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060709")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "keep-receipt",
|
||||
sender: viewModel.nickname,
|
||||
content: "Keep me",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
||||
viewModel.isStartupPhase = false
|
||||
|
||||
viewModel.cleanupOldReadReceipts()
|
||||
|
||||
#expect(viewModel.sentReadReceipts == ["keep-receipt"])
|
||||
}
|
||||
|
||||
// MARK: - Public Timeline Status Tests
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -67,6 +67,135 @@ struct ChatViewModelInitializationTests {
|
||||
// Nickname should be set during init
|
||||
#expect(!transport.myNickname.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func initialization_bindsPeerSnapshotsIntoAllPeers() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "00000000000000a1")
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let updated = await TestHelpers.waitUntil({
|
||||
viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(updated)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Tests
|
||||
|
||||
struct ChatViewModelIdentityTests {
|
||||
|
||||
@Test @MainActor
|
||||
func updatePrivateChatPeerIfNeeded_migratesSelectionHistoryByFingerprint() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let oldPeerID = PeerID(str: "00000000000000d1")
|
||||
let newPeerID = PeerID(str: "00000000000000d2")
|
||||
let sharedFingerprint = "fingerprint-shared"
|
||||
let sharedNoiseKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
transport.peerFingerprints[oldPeerID] = sharedFingerprint
|
||||
transport.peerFingerprints[newPeerID] = sharedFingerprint
|
||||
transport.peerNicknames[oldPeerID] = "Alice"
|
||||
transport.peerNicknames[newPeerID] = "Alice"
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: oldPeerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: sharedNoiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let oldPeerBound = await TestHelpers.waitUntil({
|
||||
viewModel.connectedPeers.contains(oldPeerID)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(oldPeerBound)
|
||||
|
||||
let existingMessage = BitchatMessage(
|
||||
id: "pm-migrate-1",
|
||||
sender: "Alice",
|
||||
content: "Still here",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: oldPeerID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.privateChats[oldPeerID] = [existingMessage]
|
||||
viewModel.startPrivateChat(with: oldPeerID)
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
|
||||
#expect(viewModel.hasTrackedPrivateChatSelection)
|
||||
#expect(viewModel.selectedPrivateChatFingerprint == viewModel.getFingerprint(for: oldPeerID))
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: newPeerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: sharedNoiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let newPeerBound = await TestHelpers.waitUntil({
|
||||
viewModel.connectedPeers.contains(newPeerID) && !viewModel.connectedPeers.contains(oldPeerID)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(newPeerBound)
|
||||
|
||||
viewModel.updatePrivateChatPeerIfNeeded()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == newPeerID)
|
||||
#expect(viewModel.privateChats[oldPeerID] == nil)
|
||||
#expect(viewModel.privateChats[newPeerID]?.contains(where: { $0.id == "pm-migrate-1" }) == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resolveNickname_prefersLocalPetnameOverClaimedNickname() async {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
let peerID = PeerID(str: "00000000000000d3")
|
||||
let fingerprint = "fingerprint-petname"
|
||||
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
identityManager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: "Buddy",
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
|
||||
#expect(viewModel.getFingerprint(for: peerID) == fingerprint)
|
||||
#expect(viewModel.resolveNickname(for: peerID) == "Buddy")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Sending Tests
|
||||
@@ -135,6 +264,116 @@ struct ChatViewModelCommandTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Composer Tests
|
||||
|
||||
struct ChatViewModelComposerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func updateAutocomplete_suggestsKnownPeersAndExcludesSelf() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let alice = PeerID(str: "00000000000000a1")
|
||||
let selfAlias = PeerID(str: "00000000000000a2")
|
||||
|
||||
transport.simulateConnect(alice, nickname: "Alice")
|
||||
transport.simulateConnect(selfAlias, nickname: viewModel.nickname)
|
||||
|
||||
viewModel.updateAutocomplete(for: "hello @Al", cursorPosition: 9)
|
||||
|
||||
#expect(viewModel.showAutocomplete)
|
||||
#expect(viewModel.autocompleteSuggestions == ["@Alice"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func completeNickname_replacesTokenAndClearsAutocompleteState() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
var text = "hello @Al"
|
||||
|
||||
viewModel.autocompleteSuggestions = ["@Alice"]
|
||||
viewModel.autocompleteRange = NSRange(location: 6, length: 3)
|
||||
viewModel.showAutocomplete = true
|
||||
viewModel.selectedAutocompleteIndex = 0
|
||||
|
||||
_ = viewModel.completeNickname("@Alice", in: &text)
|
||||
|
||||
#expect(text == "hello @Alice")
|
||||
#expect(viewModel.autocompleteSuggestions.isEmpty)
|
||||
#expect(viewModel.autocompleteRange == nil)
|
||||
#expect(!viewModel.showAutocomplete)
|
||||
#expect(viewModel.selectedAutocompleteIndex == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func parseMentions_filtersToKnownPeerAndSelfTokens() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let alice = PeerID(str: "00000000000000b1")
|
||||
|
||||
transport.simulateConnect(alice, nickname: "Alice")
|
||||
|
||||
let mentions = Set(
|
||||
viewModel.parseMentions(
|
||||
from: "hi @Alice @nobody @\(viewModel.nickname)"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(mentions.contains("Alice"))
|
||||
#expect(mentions.contains(viewModel.nickname))
|
||||
#expect(!mentions.contains("nobody"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle Tests
|
||||
|
||||
struct ChatViewModelServiceLifecycleTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handleDidBecomeActive_marksVisiblePrivateMessagesAsRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "read-1",
|
||||
sender: "Alice",
|
||||
content: "Hello from Alice",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.unreadPrivateMessages.insert(peerID)
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleDidBecomeActive()
|
||||
|
||||
let sentReadReceipt = await TestHelpers.waitUntil({
|
||||
transport.sentReadReceipts.contains {
|
||||
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
|
||||
}
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(sentReadReceipt)
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.handleScreenshotCaptured()
|
||||
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Cap Tests
|
||||
|
||||
struct ChatViewModelTimelineCapTests {
|
||||
@@ -159,7 +398,7 @@ struct ChatViewModelReceivingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveMessage_callsDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "msg-001",
|
||||
@@ -181,7 +420,7 @@ struct ChatViewModelReceivingTests {
|
||||
|
||||
// Message may or may not appear due to rate limiting/pipeline batching
|
||||
// The important thing is no crash and delegate was called
|
||||
#expect(transport.delegate != nil)
|
||||
#expect(transport.delegate === viewModel)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -204,6 +443,176 @@ struct ChatViewModelReceivingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Payload Tests
|
||||
|
||||
struct ChatViewModelNoisePayloadTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveNoisePayload_privateMessageStoresMessageAndSendsDeliveryAck() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
|
||||
let payload = PrivateMessagePacket(messageID: "pm-noise-1", content: "Secret hello").encode()
|
||||
#expect(payload != nil)
|
||||
guard let payload else { return }
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: peerID,
|
||||
type: .privateMessage,
|
||||
payload: payload,
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
let stored = await TestHelpers.waitUntil({
|
||||
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
let acked = await TestHelpers.waitUntil({
|
||||
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(stored)
|
||||
#expect(acked)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveNoisePayload_deliveredUpdatesExistingPrivateMessageStatus() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000004")
|
||||
transport.simulateConnect(peerID, nickname: "Bob")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "pm-delivered-1",
|
||||
sender: viewModel.nickname,
|
||||
content: "Waiting on ack",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Bob",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: peerID,
|
||||
type: .delivered,
|
||||
payload: Data("pm-delivered-1".utf8),
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
let delivered = await TestHelpers.waitUntil({
|
||||
guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false }
|
||||
if case .delivered(let name, _) = status {
|
||||
return name == "Bob"
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(delivered)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting Tests
|
||||
|
||||
struct ChatViewModelFormattingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func formatMessageAsText_formatsSenderContentAndTimestamp() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let message = BitchatMessage(
|
||||
id: "fmt-1",
|
||||
sender: "Alice#a1b2",
|
||||
content: "hello #mesh",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_010_000),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(str: "00000000000000b1")
|
||||
)
|
||||
|
||||
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
|
||||
|
||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func formatMessageHeader_formatsSenderHeader() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let message = BitchatMessage(
|
||||
id: "fmt-2",
|
||||
sender: "Alice#a1b2",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(str: "00000000000000b2")
|
||||
)
|
||||
|
||||
let header = viewModel.formatMessageHeader(message, colorScheme: .dark)
|
||||
|
||||
#expect(String(header.characters) == "<@Alice#a1b2> ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification Tests
|
||||
|
||||
struct ChatViewModelVerificationTests {
|
||||
|
||||
@Test @MainActor
|
||||
func beginQRVerification_unknownNoiseKeyReturnsFalse() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let qr = VerificationService.VerificationQR(
|
||||
v: 1,
|
||||
noiseKeyHex: String(repeating: "a", count: 64),
|
||||
signKeyHex: String(repeating: "b", count: 64),
|
||||
npub: nil,
|
||||
nickname: "Alice",
|
||||
ts: 1_700_000_000,
|
||||
nonceB64: "nonce",
|
||||
sigHex: "sig"
|
||||
)
|
||||
|
||||
#expect(!viewModel.beginQRVerification(with: qr))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func beginQRVerification_knownPeerTriggersHandshakeWhenSessionNotEstablished() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "00000000000000c1")
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let bound = await TestHelpers.waitUntil({
|
||||
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(bound)
|
||||
|
||||
let qr = VerificationService.VerificationQR(
|
||||
v: 1,
|
||||
noiseKeyHex: noiseKey.hexEncodedString(),
|
||||
signKeyHex: String(repeating: "c", count: 64),
|
||||
npub: nil,
|
||||
nickname: "Alice",
|
||||
ts: 1_700_000_000,
|
||||
nonceB64: "nonce",
|
||||
sigHex: "sig"
|
||||
)
|
||||
|
||||
#expect(viewModel.beginQRVerification(with: qr))
|
||||
#expect(transport.triggeredHandshakes == [peerID])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting Tests
|
||||
|
||||
struct ChatViewModelRateLimitingTests {
|
||||
@@ -238,6 +647,35 @@ struct ChatViewModelRateLimitingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Conversation Tests
|
||||
|
||||
struct ChatViewModelPublicConversationTests {
|
||||
|
||||
@Test @MainActor
|
||||
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.addPublicSystemMessage("system refresh test")
|
||||
viewModel.messages.removeAll()
|
||||
viewModel.refreshVisibleMessages(from: .mesh)
|
||||
|
||||
#expect(viewModel.messages.last?.content == "system refresh test")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearCurrentPublicTimeline_clearsVisibleAndBackedMessages() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.addPublicSystemMessage("system clear test")
|
||||
#expect(!viewModel.messages.isEmpty)
|
||||
|
||||
viewModel.clearCurrentPublicTimeline()
|
||||
viewModel.refreshVisibleMessages(from: .mesh)
|
||||
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer Connection Tests
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
@@ -275,6 +713,46 @@ struct ChatViewModelPeerTests {
|
||||
|
||||
#expect(transport.isPeerConnected(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let stalePeer = PeerID(str: "00000000000000a2")
|
||||
viewModel.unreadPrivateMessages = [stalePeer]
|
||||
|
||||
viewModel.didUpdatePeerList([])
|
||||
|
||||
let cleaned = await TestHelpers.waitUntil({
|
||||
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(cleaned)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_preservesStableUnreadPeerWhenMessagesExist() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let stablePeer = PeerID(str: String(repeating: "a", count: 64))
|
||||
let message = BitchatMessage(
|
||||
id: "stable-noise-1",
|
||||
sender: "Alice",
|
||||
content: "Offline hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: stablePeer,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.privateChats[stablePeer] = [message]
|
||||
viewModel.unreadPrivateMessages = [stablePeer]
|
||||
|
||||
viewModel.didUpdatePeerList([])
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(viewModel.unreadPrivateMessages.contains(stablePeer))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deduplication Integration Tests
|
||||
@@ -328,7 +806,7 @@ struct ChatViewModelPrivateChatTests {
|
||||
|
||||
// The message routing depends on connection state and other factors
|
||||
// At minimum, it should not crash
|
||||
#expect(true) // If we get here without crash, the test passes
|
||||
#expect(Bool(true)) // If we get here without crash, the test passes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ struct CommandProcessorTests {
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/msg @alice hello there")
|
||||
}
|
||||
|
||||
@@ -72,14 +72,15 @@ struct CommandProcessorTests {
|
||||
@MainActor
|
||||
@Test func whoInMeshListsSortedPeerNicknames() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let transport = MockTransport()
|
||||
transport.peerNicknames = [
|
||||
PeerID(str: "b"): "bob",
|
||||
PeerID(str: "a"): "alice"
|
||||
]
|
||||
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/who")
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ struct CommandProcessorTests {
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
let result = await withSelectedChannel(channel) {
|
||||
let result = await withSelectedChannel(channel, context: context) {
|
||||
processor.process("/who")
|
||||
}
|
||||
|
||||
@@ -130,7 +131,7 @@ struct CommandProcessorTests {
|
||||
]
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/clear")
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ struct CommandProcessorTests {
|
||||
let context = MockCommandContextProvider()
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/clear")
|
||||
}
|
||||
|
||||
@@ -174,7 +175,7 @@ struct CommandProcessorTests {
|
||||
transport.peerNicknames[peerID] = "Bob"
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/hug @bob")
|
||||
}
|
||||
|
||||
@@ -198,7 +199,7 @@ struct CommandProcessorTests {
|
||||
context.nicknameToPeerID["bob"] = peerID
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/slap @bob")
|
||||
}
|
||||
|
||||
@@ -227,7 +228,7 @@ struct CommandProcessorTests {
|
||||
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let result = await withSelectedChannel(.mesh) {
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/block")
|
||||
}
|
||||
|
||||
@@ -249,7 +250,7 @@ struct CommandProcessorTests {
|
||||
context.nicknameToPeerID["bob"] = peerID
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||
|
||||
let blockResult = await withSelectedChannel(.mesh) {
|
||||
let blockResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/block @bob")
|
||||
}
|
||||
switch blockResult {
|
||||
@@ -260,7 +261,7 @@ struct CommandProcessorTests {
|
||||
}
|
||||
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
|
||||
|
||||
let unblockResult = await withSelectedChannel(.mesh) {
|
||||
let unblockResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/unblock bob")
|
||||
}
|
||||
switch unblockResult {
|
||||
@@ -279,7 +280,7 @@ struct CommandProcessorTests {
|
||||
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
|
||||
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||
|
||||
let blockResult = await withSelectedChannel(.mesh) {
|
||||
let blockResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/block carol")
|
||||
}
|
||||
switch blockResult {
|
||||
@@ -290,7 +291,7 @@ struct CommandProcessorTests {
|
||||
}
|
||||
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
|
||||
let unblockResult = await withSelectedChannel(.mesh) {
|
||||
let unblockResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/unblock @carol")
|
||||
}
|
||||
switch unblockResult {
|
||||
@@ -305,14 +306,15 @@ struct CommandProcessorTests {
|
||||
@MainActor
|
||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let processor = CommandProcessor(
|
||||
contextProvider: MockCommandContextProvider(),
|
||||
contextProvider: context,
|
||||
meshService: MockTransport(),
|
||||
identityManager: identityManager
|
||||
)
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
||||
|
||||
let result = await withSelectedChannel(channel) {
|
||||
let result = await withSelectedChannel(channel, context: context) {
|
||||
processor.process("/fav alice")
|
||||
}
|
||||
|
||||
@@ -325,21 +327,31 @@ struct CommandProcessorTests {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
|
||||
private func withSelectedChannel<T>(
|
||||
_ channel: ChannelID,
|
||||
context: MockCommandContextProvider? = nil,
|
||||
perform work: @escaping () throws -> T
|
||||
) async rethrows -> T {
|
||||
let originalChannel = LocationChannelManager.shared.selectedChannel
|
||||
await setSelectedChannel(channel)
|
||||
let originalContextChannel = context?.activeChannel
|
||||
await setSelectedChannel(channel, context: context)
|
||||
do {
|
||||
let result = try work()
|
||||
await setSelectedChannel(originalChannel)
|
||||
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
|
||||
return result
|
||||
} catch {
|
||||
await setSelectedChannel(originalChannel)
|
||||
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setSelectedChannel(_ channel: ChannelID) async {
|
||||
private func setSelectedChannel(
|
||||
_ channel: ChannelID,
|
||||
context: MockCommandContextProvider? = nil,
|
||||
explicitChannel: ChannelID? = nil
|
||||
) async {
|
||||
context?.activeChannel = explicitChannel ?? channel
|
||||
LocationChannelManager.shared.select(channel)
|
||||
for _ in 0..<40 {
|
||||
if LocationChannelManager.shared.selectedChannel == channel {
|
||||
@@ -363,6 +375,7 @@ struct CommandProcessorTests {
|
||||
@MainActor
|
||||
private final class MockCommandContextProvider: CommandContextProvider {
|
||||
var nickname: String
|
||||
var activeChannel: ChannelID = .mesh
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var blockedUsers: Set<String> = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
@@ -12,26 +12,12 @@ import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Fragmentation Tests", .serialized)
|
||||
struct FragmentationTests {
|
||||
|
||||
private let mockKeychain: MockKeychain
|
||||
private let mockIdentityManager: MockIdentityManager
|
||||
private let idBridge: NostrIdentityBridge
|
||||
|
||||
init() {
|
||||
mockKeychain = MockKeychain()
|
||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
}
|
||||
|
||||
|
||||
@Test("Reassembly from fragments delivers a public message")
|
||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let ble = makeBLEService()
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
@@ -54,7 +40,7 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||
@@ -62,12 +48,7 @@ struct FragmentationTests {
|
||||
|
||||
@Test("Duplicate fragment does not break reassembly")
|
||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let ble = makeBLEService()
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
@@ -89,7 +70,7 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||
@@ -97,12 +78,7 @@ struct FragmentationTests {
|
||||
|
||||
@Test("Max-sized file transfer survives reassembly")
|
||||
func maxSizedFileTransferSurvivesReassembly() async throws {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let ble = makeBLEService()
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
@@ -131,14 +107,13 @@ struct FragmentationTests {
|
||||
#expect(!fragments.isEmpty)
|
||||
|
||||
for (i, fragment) in fragments.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
||||
}
|
||||
|
||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
|
||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5))
|
||||
|
||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||
#expect(message.content.hasPrefix("[file]"))
|
||||
@@ -154,12 +129,7 @@ struct FragmentationTests {
|
||||
|
||||
@Test("Invalid fragment header is ignored")
|
||||
func invalidFragmentHeaderIsIgnored() async throws {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let ble = makeBLEService()
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
@@ -184,11 +154,10 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
for (i, fragment) in corrupted.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
@@ -200,6 +169,19 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
extension FragmentationTests {
|
||||
private func makeBLEService() -> BLEService {
|
||||
let mockKeychain = MockKeychain()
|
||||
let mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
|
||||
return BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Thread-safe delegate that supports awaiting message delivery
|
||||
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
|
||||
@@ -4,6 +4,8 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class NostrRelayManagerTests: XCTestCase {
|
||||
private let expectedDefaultRelayCount = 4
|
||||
|
||||
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
|
||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||
|
||||
@@ -13,7 +15,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
context.manager.connect()
|
||||
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == 5 &&
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
@@ -27,7 +29,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
context.permissionSubject.send(.authorized)
|
||||
|
||||
let defaultRelaysConnected = await waitUntil {
|
||||
context.manager.getRelayStatuses().count == 5 &&
|
||||
context.manager.getRelayStatuses().count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(defaultRelaysConnected)
|
||||
@@ -38,7 +40,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
context.manager.getRelayStatuses().isEmpty
|
||||
}
|
||||
XCTAssertTrue(defaultRelaysRemoved)
|
||||
XCTAssertEqual(context.sessionFactory.allConnections.count, 5)
|
||||
XCTAssertEqual(context.sessionFactory.allConnections.count, expectedDefaultRelayCount)
|
||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
||||
}
|
||||
|
||||
@@ -52,7 +54,26 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connectedAfterTorReady = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == 5 &&
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connectedAfterTorReady)
|
||||
}
|
||||
|
||||
func test_connect_coalescesRepeatedCallsWhileWaitingForTor() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.connect()
|
||||
context.manager.connect()
|
||||
context.manager.connect()
|
||||
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connectedAfterTorReady = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connectedAfterTorReady)
|
||||
@@ -87,6 +108,24 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(sentAfterTorReady)
|
||||
}
|
||||
|
||||
func test_sendEvent_queuesUntilRelayIsMarkedConnected() async throws {
|
||||
let relayURL = "wss://connect-before-send.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let event = try makeSignedEvent(content: "wait for connected")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
|
||||
let sentAfterConnected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
|
||||
}
|
||||
XCTAssertTrue(sentAfterConnected)
|
||||
}
|
||||
|
||||
func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {
|
||||
let relayURL = "wss://queue-flush.example"
|
||||
let context = makeContext(
|
||||
@@ -179,6 +218,26 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])
|
||||
}
|
||||
|
||||
func test_ensureConnections_coalescesTargetsWhileWaitingForTor() async {
|
||||
let relayOne = "wss://tor-one.example"
|
||||
let relayTwo = "wss://tor-two.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.ensureConnections(to: [relayOne])
|
||||
context.manager.ensureConnections(to: [relayTwo, relayOne])
|
||||
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connected = await waitUntil {
|
||||
Set(context.sessionFactory.requestedURLs) == Set([relayOne, relayTwo]) &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
}
|
||||
|
||||
func test_subscribe_coalescesRapidDuplicateRequests() async {
|
||||
let relayURL = "wss://subscribe.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -197,6 +256,63 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||
}
|
||||
|
||||
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "tor-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "tor-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-sub")
|
||||
let eoseCompleted = await waitUntil { eoseCount == 1 }
|
||||
XCTAssertTrue(eoseCompleted)
|
||||
}
|
||||
|
||||
func test_subscribe_sameActiveRequestDoesNotRequeue() async {
|
||||
let relayURL = "wss://active-subscribe.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let filter = makeFilter()
|
||||
|
||||
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||
|
||||
let firstSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(firstSent)
|
||||
|
||||
context.clock.now = context.clock.now.addingTimeInterval(2.0)
|
||||
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
}
|
||||
|
||||
func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
|
||||
let relayURL = "wss://tor-subscribe.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -239,7 +355,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
|
||||
context.permissionSubject.send(.authorized)
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.allConnections.count == 5 &&
|
||||
context.sessionFactory.allConnections.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
@@ -810,6 +926,7 @@ private final class MutableBool {
|
||||
|
||||
private final class MockTorWaiter {
|
||||
private var completions: [(Bool) -> Void] = []
|
||||
private(set) var awaitCallCount = 0
|
||||
var isReady: Bool
|
||||
|
||||
init(isReady: Bool) {
|
||||
@@ -817,6 +934,7 @@ private final class MockTorWaiter {
|
||||
}
|
||||
|
||||
func await(completion: @escaping (Bool) -> Void) {
|
||||
awaitCallCount += 1
|
||||
completions.append(completion)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,151 @@ private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockT
|
||||
return (viewModel, transport, identityManager)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct SmokeFeatureModels {
|
||||
let publicChatModel: PublicChatModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let privateInboxModel: PrivateInboxModel
|
||||
let privateConversationModel: PrivateConversationModel
|
||||
let verificationModel: VerificationModel
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let peerListModel: PeerListModel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeSmokeLocationManager() -> LocationChannelManager {
|
||||
let suiteName = "ViewSmokeTests.\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName) ?? .standard
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
return LocationChannelManager(storage: storage)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
|
||||
let locationManager = makeSmokeLocationManager()
|
||||
let conversationStore = viewModel.conversationStore
|
||||
let publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
let appChromeModel = AppChromeModel(
|
||||
chatViewModel: viewModel,
|
||||
privateInboxModel: privateInboxModel
|
||||
)
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
let verificationModel = VerificationModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel
|
||||
)
|
||||
let conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel,
|
||||
conversationStore: conversationStore
|
||||
)
|
||||
let peerListModel = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
return SmokeFeatureModels(
|
||||
publicChatModel: publicChatModel,
|
||||
appChromeModel: appChromeModel,
|
||||
locationChannelsModel: locationChannelsModel,
|
||||
privateInboxModel: privateInboxModel,
|
||||
privateConversationModel: privateConversationModel,
|
||||
verificationModel: verificationModel,
|
||||
conversationUIModel: conversationUIModel,
|
||||
peerListModel: peerListModel
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func installSmokeEnvironment<V: View>(
|
||||
_ view: V,
|
||||
featureModels: SmokeFeatureModels
|
||||
) -> some View {
|
||||
view
|
||||
.environmentObject(featureModels.publicChatModel)
|
||||
.environmentObject(featureModels.appChromeModel)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
.environmentObject(featureModels.privateInboxModel)
|
||||
.environmentObject(featureModels.privateConversationModel)
|
||||
.environmentObject(featureModels.verificationModel)
|
||||
.environmentObject(featureModels.conversationUIModel)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ContentPeopleSheetHarness: View {
|
||||
@State private var showSidebar = true
|
||||
@State private var messageText = ""
|
||||
@State private var selectedMessageSender: String?
|
||||
@State private var selectedMessageSenderID: PeerID?
|
||||
@State private var imagePreviewURL: URL?
|
||||
@State private var windowCountPublic = 300
|
||||
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
||||
@State private var isAtBottomPrivate = true
|
||||
@State private var autocompleteDebounceTimer: Timer?
|
||||
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
#if os(iOS)
|
||||
@State private var showImagePicker = false
|
||||
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .photoLibrary
|
||||
#else
|
||||
@State private var showMacImagePicker = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
ContentPeopleSheetView(
|
||||
showSidebar: $showSidebar,
|
||||
messageText: $messageText,
|
||||
selectedMessageSender: $selectedMessageSender,
|
||||
selectedMessageSenderID: $selectedMessageSenderID,
|
||||
imagePreviewURL: $imagePreviewURL,
|
||||
windowCountPublic: $windowCountPublic,
|
||||
windowCountPrivate: $windowCountPrivate,
|
||||
isAtBottomPrivate: $isAtBottomPrivate,
|
||||
isTextFieldFocused: $isTextFieldFocused,
|
||||
voiceRecordingVM: voiceRecordingVM,
|
||||
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
||||
backgroundColor: .black,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
headerHeight: 44,
|
||||
onSendMessage: {},
|
||||
showImagePicker: $showImagePicker,
|
||||
imagePickerSourceType: $imagePickerSourceType
|
||||
)
|
||||
#else
|
||||
ContentPeopleSheetView(
|
||||
showSidebar: $showSidebar,
|
||||
messageText: $messageText,
|
||||
selectedMessageSender: $selectedMessageSender,
|
||||
selectedMessageSenderID: $selectedMessageSenderID,
|
||||
imagePreviewURL: $imagePreviewURL,
|
||||
windowCountPublic: $windowCountPublic,
|
||||
windowCountPrivate: $windowCountPrivate,
|
||||
isAtBottomPrivate: $isAtBottomPrivate,
|
||||
isTextFieldFocused: $isTextFieldFocused,
|
||||
voiceRecordingVM: voiceRecordingVM,
|
||||
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
||||
backgroundColor: .black,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
headerHeight: 44,
|
||||
onSendMessage: {},
|
||||
showMacImagePicker: $showMacImagePicker
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func mount<V: View>(_ view: V) -> AnyObject {
|
||||
@@ -112,11 +257,13 @@ private func makeTemporaryImageURL() throws -> URL {
|
||||
return url
|
||||
}
|
||||
|
||||
@Suite("View Smoke Tests", .serialized)
|
||||
@MainActor
|
||||
struct ViewSmokeTests {
|
||||
@Test
|
||||
func fingerprintView_renders_verifiedAndPendingStates() async {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
let verifiedPeer = PeerID(str: "0102030405060708")
|
||||
let pendingPeer = PeerID(str: "1112131415161718")
|
||||
let verifiedFingerprint = String(repeating: "ab", count: 32)
|
||||
@@ -131,11 +278,11 @@ struct ViewSmokeTests {
|
||||
|
||||
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
||||
|
||||
let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer)
|
||||
let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer)
|
||||
let verifiedView = FingerprintView(peerID: verifiedPeer)
|
||||
.environmentObject(featureModels.verificationModel)
|
||||
let pendingView = FingerprintView(peerID: pendingPeer)
|
||||
.environmentObject(featureModels.verificationModel)
|
||||
|
||||
_ = verifiedView.body
|
||||
_ = pendingView.body
|
||||
_ = mount(verifiedView)
|
||||
_ = mount(pendingView)
|
||||
|
||||
@@ -145,13 +292,14 @@ struct ViewSmokeTests {
|
||||
@Test
|
||||
func verificationViews_renderCoreBranches() throws {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
let peerID = PeerID(str: "2122232425262728")
|
||||
let fingerprint = String(repeating: "cd", count: 32)
|
||||
var isPresented = true
|
||||
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)])
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
featureModels.privateConversationModel.startConversation(with: peerID)
|
||||
viewModel.verifiedFingerprints.insert(fingerprint)
|
||||
|
||||
let image = try makeCGImage()
|
||||
@@ -173,35 +321,36 @@ struct ViewSmokeTests {
|
||||
set: { isPresented = $0 }
|
||||
)
|
||||
)
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(featureModels.verificationModel)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func meshPeerList_renders_emptyAndPopulatedStates() async {
|
||||
let (viewModel, transport, identityManager) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
let connectedPeer = PeerID(str: "3132333435363738")
|
||||
let blockedPeer = PeerID(str: "4142434445464748")
|
||||
let blockedFingerprint = String(repeating: "ef", count: 32)
|
||||
|
||||
_ = mount(
|
||||
MeshPeerList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPeer: { _ in },
|
||||
onToggleFavorite: { _ in },
|
||||
onShowFingerprint: { _ in }
|
||||
)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
)
|
||||
_ = MeshPeerList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPeer: { _ in },
|
||||
onToggleFavorite: { _ in },
|
||||
onShowFingerprint: { _ in }
|
||||
).body
|
||||
)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
|
||||
transport.peerFingerprints[blockedPeer] = blockedFingerprint
|
||||
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
|
||||
@@ -214,13 +363,13 @@ struct ViewSmokeTests {
|
||||
|
||||
_ = mount(
|
||||
MeshPeerList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPeer: { _ in },
|
||||
onToggleFavorite: { _ in },
|
||||
onShowFingerprint: { _ in }
|
||||
)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
)
|
||||
|
||||
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
|
||||
@@ -229,10 +378,11 @@ struct ViewSmokeTests {
|
||||
@Test
|
||||
func commandSuggestionsAndLocationViews_render() {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
|
||||
var messageText = "/f"
|
||||
|
||||
LocationChannelManager.shared.select(.location(channel))
|
||||
featureModels.locationChannelsModel.select(.location(channel))
|
||||
|
||||
_ = mount(
|
||||
CommandSuggestionsView(
|
||||
@@ -244,22 +394,25 @@ struct ViewSmokeTests {
|
||||
backgroundColor: .black,
|
||||
secondaryTextColor: .gray
|
||||
)
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(featureModels.privateConversationModel)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
|
||||
_ = mount(
|
||||
LocationChannelsSheet(isPresented: .constant(true))
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
)
|
||||
|
||||
#expect(messageText == "/f")
|
||||
LocationChannelManager.shared.select(.mesh)
|
||||
LocationChannelManager.shared.endLiveRefresh()
|
||||
featureModels.locationChannelsModel.select(.mesh)
|
||||
featureModels.locationChannelsModel.endLiveRefresh()
|
||||
}
|
||||
|
||||
@Test
|
||||
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
|
||||
let noRelayManager = LocationNotesManager(
|
||||
geohash: "u4pruydq",
|
||||
@@ -302,12 +455,20 @@ struct ViewSmokeTests {
|
||||
eose?()
|
||||
|
||||
_ = mount(
|
||||
LocationNotesView(geohash: "u4pruydq", manager: noRelayManager)
|
||||
.environmentObject(viewModel)
|
||||
LocationNotesView(
|
||||
geohash: "u4pruydq",
|
||||
senderNickname: viewModel.nickname,
|
||||
manager: noRelayManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
_ = mount(
|
||||
LocationNotesView(geohash: "u4pruydq", manager: loadedManager)
|
||||
.environmentObject(viewModel)
|
||||
LocationNotesView(
|
||||
geohash: "u4pruydq",
|
||||
senderNickname: viewModel.nickname,
|
||||
manager: loadedManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
|
||||
#expect(loadedManager.notes.count == 1)
|
||||
@@ -350,11 +511,34 @@ struct ViewSmokeTests {
|
||||
#expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning")
|
||||
}
|
||||
|
||||
@Test
|
||||
func contentShellViews_renderPublicAndPrivateBranches() async {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
let peerID = PeerID(str: "5152535455565758")
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
makeSnapshot(peerID: peerID, nickname: "Alice", noiseByte: 0x66)
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
_ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels))
|
||||
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
|
||||
|
||||
featureModels.privateConversationModel.startConversation(with: peerID)
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
|
||||
|
||||
#expect(featureModels.privateConversationModel.selectedPeerID == peerID)
|
||||
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func geohashAndTextMessageViews_renderCoreBranches() {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||
let geohashPeopleList = GeohashPeopleList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPerson: {}
|
||||
@@ -377,10 +561,10 @@ struct ViewSmokeTests {
|
||||
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
|
||||
)
|
||||
|
||||
_ = geohashPeopleList.body
|
||||
_ = mount(geohashPeopleList)
|
||||
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel))
|
||||
_ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel))
|
||||
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
|
||||
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
|
||||
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(featureModels.conversationUIModel))
|
||||
_ = mount(TextMessageView(message: paymentMessage).environmentObject(featureModels.conversationUIModel))
|
||||
|
||||
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
|
||||
#expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu"))
|
||||
|
||||
Reference in New Issue
Block a user