Files
bitchat/bitchatTests/ViewSmokeTests.swift
T
jackandClaude Fable 5 879d8cba12 Cut private message path over to ConversationStore
All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.

ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).

pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:19:11 +02:00

647 lines
24 KiB
Swift

import Testing
import Foundation
import SwiftUI
import CoreGraphics
import AVFoundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import BitFoundation
@testable import bitchat
@MainActor
private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identityManager: MockIdentityManager) {
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
)
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 {
#if os(iOS)
let host = UIHostingController(rootView: view)
_ = host.view
host.view.setNeedsLayout()
host.view.layoutIfNeeded()
return host
#else
let host = NSHostingView(rootView: view)
host.layoutSubtreeIfNeeded()
_ = host.fittingSize
return host
#endif
}
private func makeSnapshot(
peerID: PeerID,
nickname: String,
connected: Bool = true,
noiseByte: UInt8
) -> TransportPeerSnapshot {
TransportPeerSnapshot(
peerID: peerID,
nickname: nickname,
isConnected: connected,
noisePublicKey: Data(repeating: noiseByte, count: 32),
lastSeen: Date()
)
}
private func makeCGImage() throws -> CGImage {
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
let context = try #require(
CGContext(
data: nil,
width: 8,
height: 8,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
)
context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.2, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
return try #require(context.makeImage())
}
private func makeTemporaryAudioURL() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("caf")
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))
let frameCount: AVAudioFrameCount = 1_600
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount))
buffer.frameLength = frameCount
let channel = try #require(buffer.floatChannelData?[0])
for index in 0..<Int(frameCount) {
channel[index] = sinf(Float(index) * 0.2) * 0.5
}
let file = try AVAudioFile(forWriting: url, settings: format.settings)
try file.write(from: buffer)
return url
}
private func makeTemporaryImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("png")
let image = try makeCGImage()
#if os(iOS)
let data = try #require(UIImage(cgImage: image).pngData())
#else
let rep = NSBitmapImageRep(cgImage: image)
let data = try #require(rep.representation(using: .png, properties: [:]))
#endif
try data.write(to: 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)
transport.peerFingerprints[verifiedPeer] = verifiedFingerprint
transport.peerFingerprints[pendingPeer] = nil
transport.updatePeerSnapshots([
makeSnapshot(peerID: verifiedPeer, nickname: "Alice", noiseByte: 0x11),
makeSnapshot(peerID: pendingPeer, nickname: "Bob", noiseByte: 0x22)
])
try? await Task.sleep(nanoseconds: 50_000_000)
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
let verifiedView = FingerprintView(peerID: verifiedPeer)
.environmentObject(featureModels.verificationModel)
let pendingView = FingerprintView(peerID: pendingPeer)
.environmentObject(featureModels.verificationModel)
_ = mount(verifiedView)
_ = mount(pendingView)
#expect(viewModel.verifiedFingerprints.contains(verifiedFingerprint))
}
@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)])
featureModels.privateConversationModel.startConversation(with: peerID)
viewModel.verifiedFingerprints.insert(fingerprint)
let image = try makeCGImage()
let myQR = MyQRView(qrString: "bitchat://verify?name=alice&npub=npub1test")
let qrCode = QRCodeImage(data: "bitchat://verify?hello=world", size: 96)
let imageWrapper = ImageWrapper(image: image)
_ = myQR.body
_ = qrCode.body
_ = imageWrapper.body
_ = mount(myQR)
_ = mount(qrCode)
_ = mount(imageWrapper)
_ = mount(
VerificationSheetView(
isPresented: Binding(
get: { isPresented },
set: { isPresented = $0 }
)
)
.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(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
)
_ = MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
transport.peerFingerprints[blockedPeer] = blockedFingerprint
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
transport.updatePeerSnapshots([
makeSnapshot(peerID: connectedPeer, nickname: "Alice", noiseByte: 0x44),
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
])
try? await Task.sleep(nanoseconds: 50_000_000)
viewModel.markPrivateChatUnread(blockedPeer)
_ = mount(
MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
)
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
}
@Test
func commandSuggestionsAndLocationViews_render() {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
var messageText = "/f"
featureModels.locationChannelsModel.select(.location(channel))
_ = mount(
CommandSuggestionsView(
messageText: Binding(
get: { messageText },
set: { messageText = $0 }
),
textColor: .green,
backgroundColor: .black,
secondaryTextColor: .gray
)
.environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
LocationChannelsSheet(isPresented: .constant(true))
.environmentObject(featureModels.locationChannelsModel)
.environmentObject(featureModels.peerListModel)
)
#expect(messageText == "/f")
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",
dependencies: LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in try NostrIdentity.generate() },
now: { Date() }
)
)
var noteHandler: ((NostrEvent) -> Void)?
var eose: (() -> Void)?
let loadedManager = LocationNotesManager(
geohash: "u4pruydq",
dependencies: LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, handler, onEOSE in
noteHandler = handler
eose = onEOSE
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in try NostrIdentity.generate() },
now: { Date() }
)
)
let identity = try NostrIdentity.generate()
let event = try NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: [["g", "u4pruydq"], ["n", "Builder"]],
content: "hello from a note"
).sign(with: identity.schnorrSigningKey())
noteHandler?(event)
eose?()
_ = mount(
LocationNotesView(
geohash: "u4pruydq",
senderNickname: viewModel.nickname,
manager: noRelayManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
LocationNotesView(
geohash: "u4pruydq",
senderNickname: viewModel.nickname,
manager: loadedManager
)
.environmentObject(featureModels.locationChannelsModel)
)
#expect(loadedManager.notes.count == 1)
#expect(noRelayManager.state == .noRelays)
}
@Test
func appInfoAndComponentViews_render() {
let feature = AppInfoFeatureInfo(
icon: "lock.fill",
title: "app_info.privacy.title",
description: "app_info.features.encryption.description"
)
let appInfo = AppInfoView()
let header = SectionHeader("app_info.features.title")
let featureRow = FeatureRow(info: feature)
let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token"))
let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test"))
_ = appInfo.body
_ = header.body
_ = featureRow.body
_ = paymentCashu.body
_ = paymentLightning.body
_ = DeliveryStatusView(status: .sending).body
_ = DeliveryStatusView(status: .sent).body
_ = DeliveryStatusView(status: .delivered(to: "Alice", at: Date())).body
_ = DeliveryStatusView(status: .read(by: "Alice", at: Date())).body
_ = DeliveryStatusView(status: .failed(reason: "offline")).body
_ = DeliveryStatusView(status: .partiallyDelivered(reached: 2, total: 3)).body
_ = mount(appInfo)
_ = mount(header)
_ = mount(featureRow)
_ = mount(paymentCashu)
_ = mount(paymentLightning)
#expect(PaymentChipView.PaymentType.cashu("cashuA_test-token").url?.scheme == "cashu")
#expect(PaymentChipView.PaymentType.cashu("https://example.com/cashu").url?.absoluteString == "https://example.com/cashu")
#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(
textColor: .green,
secondaryTextColor: .gray,
onTapPerson: {}
)
let truncatableMessage = BitchatMessage(
sender: viewModel.nickname,
content: String(repeating: "verylongtoken ", count: 160),
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sent
)
let paymentMessage = BitchatMessage(
sender: viewModel.nickname,
content: "lightning:lnbc1test cashuA_test-token",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Bob",
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
)
_ = 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"))
}
@Test
func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL()
let imageURL = try makeTemporaryImageURL()
defer {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL)
}
let waveformView = WaveformView(
samples: [0.1, 0.6, 0.3, 0.8],
playbackProgress: 0.25,
sendProgress: 0.75,
onSeek: nil,
isInteractive: false
)
let imageView = BlockRevealImageView(
url: imageURL,
revealProgress: 0.5,
isSending: true,
onCancel: {},
initiallyBlurred: true,
onOpen: {},
onDelete: {}
)
let voiceNoteView = VoiceNoteView(
url: audioURL,
isSending: true,
sendProgress: 0.4,
onCancel: {}
)
let playback = VoiceNotePlaybackController(url: audioURL)
_ = waveformView.body
_ = imageView.body
_ = mount(waveformView)
_ = mount(imageView)
_ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
continuation.resume(returning: values)
}
}
playback.loadDuration()
try? await Task.sleep(nanoseconds: 250_000_000)
playback.seek(to: 1.25)
playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback)
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
await VoiceRecorder.shared.cancelRecording()
#expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
#expect(playback.duration > 0)
#expect(playback.progress == 0)
}
#if os(iOS)
@Test
func cameraScannerView_previewAndCoordinatorSmoke() {
let preview = CameraScannerView.PreviewView(frame: .zero)
let coordinator = CameraScannerView.Coordinator()
_ = CameraScannerView.PreviewView.layerClass
_ = preview.videoPreviewLayer
coordinator.setup(sessionOwner: preview) { _ in }
coordinator.setActive(false)
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
}
#endif
}