bitchat/bitchatTests/ViewSmokeTests.swift
Felix-Ayush 8f9489790d
Add macOS camera QR scanning for peer verification (#1477)
* Add macOS camera QR scanning for peer verification.

Mac verification previously only supported paste/validate. Reuse the same AVCapture metadata pipeline as iOS, keep paste as a fallback, and extend the scanner smoke test to macOS.

* Grant sandboxed macOS camera access for QR scanning.

NSCameraUsageDescription alone is not enough under App Sandbox. Add com.apple.security.device.camera so the new macOS AVCapture QR path can open the camera after user permission.

* Check camera auth before capture input for QR scanning.

Consult AVCaptureDevice.authorizationStatus before creating AVCaptureDeviceInput so smoke tests and cold launches do not trigger TCC prompts, gate the smoke test on prior authorization, and show a one-line hint when the camera is unavailable.
2026-08-01 10:03:48 +01:00

832 lines
31 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)
// ContentView + people sheet must mount with the full feature-model
// set (peerList / publicChat / privateInbox included). Missing any of
// those crashes the NavigationStack sheet on some iOS versions (#1558).
_ = 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)
}
@Test
func cameraScannerView_previewAndCoordinatorSmoke() {
#if os(iOS) || os(macOS)
// Avoid constructing AVCaptureDeviceInput (and the TCC prompt it can
// trigger) unless the host process already has camera authorization
// same class of isolation as keeping tests off the login keychain.
let status = AVCaptureDevice.authorizationStatus(for: .video)
let preview = CameraScannerView.PreviewView(frame: .zero)
let coordinator = CameraScannerCoordinator()
#if os(iOS)
_ = CameraScannerView.PreviewView.layerClass
#elseif os(macOS)
preview.layout()
#endif
_ = preview.videoPreviewLayer
if status == .authorized {
coordinator.setup(previewLayer: preview.videoPreviewLayer) { _ in }
coordinator.setActive(false)
}
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
#endif
}
}