mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:45:20 +00:00
Voice recording happens inside the people/DM sheet, but its error alert was still presented unconditionally from the root view. A mic-permission failure therefore force-dismissed the sheet and ended the conversation through exactly the reconciliation path this branch fixes for the Bluetooth alert. Treat it the same way: the sheet-dismiss guard now also ignores reconciliation while the voice alert is up, the root copy of the alert defers to any other root modal (people sheet included), and the sheet presents its own copy gated on the sheet's modal state. The voice alert keeps priority over the Bluetooth alert on both levels via the existing isVoiceAlertPresented flag, now mirrored into the people-sheet state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
818 lines
30 KiB
Swift
818 lines
30 KiB
Swift
import Combine
|
|
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
|
|
let boardAlertsModel: BoardAlertsModel
|
|
let sharedContentImportModel: SharedContentImportModel
|
|
}
|
|
|
|
@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 conversations = viewModel.conversations
|
|
let publicChatModel = PublicChatModel(conversations: conversations)
|
|
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
|
let privateInboxModel = PrivateInboxModel(conversations: conversations)
|
|
let appChromeModel = AppChromeModel(
|
|
chatViewModel: viewModel,
|
|
privateInboxModel: privateInboxModel
|
|
)
|
|
let privateConversationModel = PrivateConversationModel(
|
|
chatViewModel: viewModel,
|
|
conversations: conversations,
|
|
locationChannelsModel: locationChannelsModel
|
|
)
|
|
let verificationModel = VerificationModel(
|
|
chatViewModel: viewModel,
|
|
privateConversationModel: privateConversationModel
|
|
)
|
|
let conversationUIModel = ConversationUIModel(
|
|
chatViewModel: viewModel,
|
|
privateConversationModel: privateConversationModel,
|
|
conversations: conversations
|
|
)
|
|
let peerListModel = PeerListModel(
|
|
chatViewModel: viewModel,
|
|
conversations: conversations,
|
|
locationChannelsModel: locationChannelsModel
|
|
)
|
|
|
|
let boardAlertsModel = BoardAlertsModel(
|
|
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
|
|
dependencies: BoardAlertsModel.Dependencies(
|
|
isOwnPost: { _ in false },
|
|
emitSystemLine: { _, _ in }
|
|
)
|
|
)
|
|
|
|
return SmokeFeatureModels(
|
|
publicChatModel: publicChatModel,
|
|
appChromeModel: appChromeModel,
|
|
locationChannelsModel: locationChannelsModel,
|
|
privateInboxModel: privateInboxModel,
|
|
privateConversationModel: privateConversationModel,
|
|
verificationModel: verificationModel,
|
|
conversationUIModel: conversationUIModel,
|
|
peerListModel: peerListModel,
|
|
boardAlertsModel: boardAlertsModel,
|
|
sharedContentImportModel: SharedContentImportModel(store: nil)
|
|
)
|
|
}
|
|
|
|
@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)
|
|
.environmentObject(featureModels.boardAlertsModel)
|
|
.environmentObject(featureModels.sharedContentImportModel)
|
|
}
|
|
|
|
@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,
|
|
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,
|
|
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(
|
|
onTapPeer: { _ in },
|
|
onToggleFavorite: { _ in },
|
|
onShowFingerprint: { _ in }
|
|
)
|
|
.environmentObject(featureModels.peerListModel)
|
|
)
|
|
_ = MeshPeerList(
|
|
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(
|
|
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 }
|
|
)
|
|
)
|
|
.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 noticesView_rendersNoRelayAndLoadedStates() throws {
|
|
let (viewModel, transport, _) = makeSmokeViewModel()
|
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
|
featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq")))
|
|
defer { featureModels.locationChannelsModel.select(.mesh) }
|
|
let board = BoardManager(
|
|
transport: transport,
|
|
store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
|
|
publishToNostr: { _, _, _, _, _ in nil },
|
|
deleteFromNostr: { _, _ in }
|
|
)
|
|
|
|
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(
|
|
NoticesView(
|
|
senderNickname: viewModel.nickname,
|
|
board: board,
|
|
initialTab: .geo,
|
|
notesManager: noRelayManager
|
|
)
|
|
.environmentObject(featureModels.locationChannelsModel)
|
|
)
|
|
_ = mount(
|
|
NoticesView(
|
|
senderNickname: viewModel.nickname,
|
|
board: board,
|
|
initialTab: .geo,
|
|
notesManager: loadedManager
|
|
)
|
|
.environmentObject(featureModels.locationChannelsModel)
|
|
)
|
|
_ = mount(
|
|
NoticesView(
|
|
senderNickname: viewModel.nickname,
|
|
board: board,
|
|
initialTab: .mesh
|
|
)
|
|
.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"
|
|
)
|
|
|
|
// AppInfoView's settings pane reads LocationChannelsModel from the
|
|
// environment, so it can only render mounted with one installed.
|
|
let appInfo = AppInfoView()
|
|
.environmentObject(LocationChannelsModel(manager: makeSmokeLocationManager()))
|
|
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"))
|
|
|
|
_ = 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("Root Bluetooth alert waits for location and notices sheets")
|
|
func rootBluetoothAlertGuard_includesHeaderSheets() {
|
|
#expect(!ContentRootModalPresentationState().hasPresentation)
|
|
#expect(
|
|
ContentRootModalPresentationState(
|
|
isLocationChannelsSheetPresented: true
|
|
).hasPresentation
|
|
)
|
|
#expect(
|
|
ContentRootModalPresentationState(
|
|
isNoticesSheetPresented: true
|
|
).hasPresentation
|
|
)
|
|
}
|
|
|
|
@Test("People-sheet Bluetooth alert waits for local verification sheet")
|
|
func peopleSheetBluetoothAlertGuard_includesVerificationSheet() {
|
|
#expect(!ContentPeopleSheetModalPresentationState().hasPresentation)
|
|
#expect(
|
|
ContentPeopleSheetModalPresentationState(
|
|
isVerificationSheetPresented: true
|
|
).hasPresentation
|
|
)
|
|
}
|
|
|
|
@Test("Bluetooth alerts wait for the voice recording error alert")
|
|
func bluetoothAlertGuards_includeVoiceAlert() {
|
|
#expect(
|
|
ContentRootModalPresentationState(
|
|
isVoiceAlertPresented: true
|
|
).hasPresentation
|
|
)
|
|
#expect(
|
|
ContentPeopleSheetModalPresentationState(
|
|
isVoiceAlertPresented: true
|
|
).hasPresentation
|
|
)
|
|
}
|
|
|
|
@Test("Root Bluetooth alert waits for screenshot privacy alert")
|
|
@MainActor
|
|
func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() {
|
|
let (viewModel, _, _) = makeSmokeViewModel()
|
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
|
|
|
#expect(
|
|
!ContentRootModalPresentationState(
|
|
appChromeModel: featureModels.appChromeModel
|
|
).hasPresentation
|
|
)
|
|
|
|
featureModels.appChromeModel.showScreenshotPrivacyWarning = true
|
|
|
|
#expect(
|
|
ContentRootModalPresentationState(
|
|
appChromeModel: featureModels.appChromeModel
|
|
).hasPresentation
|
|
)
|
|
}
|
|
|
|
@Test("People-sheet Bluetooth alert waits for legacy media consent")
|
|
@MainActor
|
|
func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async {
|
|
let (viewModel, _, _) = makeSmokeViewModel()
|
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
|
|
|
#expect(
|
|
!ContentPeopleSheetModalPresentationState(
|
|
legacyPrivateMediaConsentRequest:
|
|
featureModels.conversationUIModel
|
|
.legacyPrivateMediaConsentRequest
|
|
).hasPresentation
|
|
)
|
|
|
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
|
for: PeerID(str: "5152535455565758"),
|
|
transferId: "legacy-consent-transfer",
|
|
messageID: "legacy-consent-message"
|
|
) { _ in }
|
|
defer { viewModel.cancelAllLegacyPrivateMediaConsents() }
|
|
|
|
let consentPropagated = await TestHelpers.waitUntil {
|
|
featureModels.conversationUIModel
|
|
.legacyPrivateMediaConsentRequest != nil
|
|
}
|
|
#expect(
|
|
consentPropagated
|
|
)
|
|
#expect(
|
|
ContentPeopleSheetModalPresentationState(
|
|
legacyPrivateMediaConsentRequest:
|
|
featureModels.conversationUIModel
|
|
.legacyPrivateMediaConsentRequest
|
|
).hasPresentation
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func geohashAndTextMessageViews_renderCoreBranches() {
|
|
let (viewModel, _, _) = makeSmokeViewModel()
|
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
|
let geohashPeopleList = GeohashPeopleList(
|
|
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()
|
|
// Probed directly below. Deliberately a separate file from `audioURL`:
|
|
// `WaveformCache.shared` is process-wide and the mounted
|
|
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
|
|
// width concurrently, so asserting an exact bin count for that URL
|
|
// races with the view's own cache write.
|
|
let waveformProbeURL = try makeTemporaryAudioURL()
|
|
let imageURL = try makeTemporaryImageURL()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: audioURL)
|
|
try? FileManager.default.removeItem(at: waveformProbeURL)
|
|
try? FileManager.default.removeItem(at: imageURL)
|
|
WaveformCache.shared.purge(url: audioURL)
|
|
WaveformCache.shared.purge(url: waveformProbeURL)
|
|
}
|
|
|
|
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: waveformProbeURL, bins: 16) { values in
|
|
continuation.resume(returning: values)
|
|
}
|
|
}
|
|
playback.loadDuration()
|
|
// loadDuration hops through a background queue and back to main; poll
|
|
// instead of a fixed sleep so a loaded runner can't outlast the wait.
|
|
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
|
|
playback.seek(to: 1.25)
|
|
playback.stop()
|
|
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
|
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
|
await VoiceRecorder.shared.cancelRecording(owner: VoiceRecorder.RecordingOwner())
|
|
|
|
#expect(bins.count == 16)
|
|
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
|
|
#expect(playback.duration > 0)
|
|
#expect(playback.progress == 0)
|
|
}
|
|
|
|
@Test
|
|
func messageRows_snapshotDeliveryStatusForSwiftUIDiffing() {
|
|
// Regression: `BitchatMessage` is a reference type mutated in place
|
|
// by `ConversationStore.applyDeliveryStatus`, and SwiftUI compares
|
|
// reference-typed view fields by identity — so a status-only change
|
|
// (delivered → read) on the SAME instance is invisible to the row's
|
|
// structural diff and its body gets skipped even when the list
|
|
// re-renders. The row views must therefore snapshot the status as a
|
|
// value-typed stored property at init, so a rebuilt row value
|
|
// compares different and re-renders.
|
|
func deliveryStatusSnapshot(of row: Any) -> DeliveryStatus? {
|
|
Mirror(reflecting: row).children
|
|
.first { $0.label == "deliveryStatus" }?
|
|
.value as? DeliveryStatus
|
|
}
|
|
|
|
let delivered = DeliveryStatus.delivered(to: "builder", at: Date(timeIntervalSince1970: 50))
|
|
let message = BitchatMessage(
|
|
id: "dm-status-1",
|
|
sender: "anon",
|
|
content: "hello",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "builder",
|
|
senderPeerID: PeerID(str: "abcdef1234567890"),
|
|
deliveryStatus: delivered
|
|
)
|
|
|
|
#expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == delivered)
|
|
|
|
// In-place mutation of the shared instance (what the store does on a
|
|
// READ ack); a freshly built row must carry the new status value.
|
|
let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100))
|
|
message.deliveryStatus = read
|
|
|
|
#expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == read)
|
|
|
|
let mediaRow = MediaMessageView(
|
|
message: message,
|
|
media: .image(URL(fileURLWithPath: "/tmp/never-loaded.jpg")),
|
|
imagePreviewURL: .constant(nil)
|
|
)
|
|
#expect(deliveryStatusSnapshot(of: mediaRow) == read)
|
|
}
|
|
|
|
#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
|
|
}
|