[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:
jack
2026-05-30 14:13:26 +02:00
committed by GitHub
co-authored by jack
parent a6cb8872fb
commit 764f016d17
53 changed files with 10717 additions and 6192 deletions
+699
View File
@@ -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
+481 -3
View File
@@ -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
}
}
+33 -20
View File
@@ -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)
}
+208 -24
View File
@@ -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"))