mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Four spurious CI failures on July 5, all loaded-runner flakiness: - ViewSmokeTests.voiceAndMediaViews_renderAndWarmCaches asserted an exact bin count on WaveformCache.shared for the same URL the mounted VoiceNoteView was concurrently warming at its default 120-bin width; whichever barrier write landed last owned the entry. Probe the cache with a dedicated audio file no view touches, and purge both URLs. Also replace the fixed 250ms sleep for loadDuration's background hop with a waitUntil poll. - sendImage_privateChatProcessesAndTransfersImage (and its sendVoiceNote / sendImage siblings) wait on work that hops through Task.detached; the global executor is shared with every parallel test worker, so a loaded runner can exceed the 5s wait. Raise those positive waits to TestConstants.longTimeout (10s) — waitUntil returns as soon as the condition holds, so passing runs are unaffected. - subscribeNostrEvent_addsToTimeline_ifMatchesGeohash raced concurrently running suites (e.g. CommandProcessorTests) on the process-wide LocationChannelManager singleton: a mid-test channel flip reroutes or drops the event permanently, so no fixed wait recovers. The wait loop now re-asserts the channel and redelivers the event on each poll — idempotent because channel switches clear the processed-event set and the store dedups by message ID — so interference heals while genuine failures still time out. - The performance floor gate failed on a saturated runner (gcs.buildAndDecode at 85% of floor). check-perf-floors.sh now re-runs the benchmark suite up to twice when a metric lands below floor, appending to the same PERF log and keeping each benchmark's best value across attempts: noise clears on a retry, a real algorithmic regression fails every attempt. Floors are unchanged and never lowered by the mechanism; missing-benchmark failures exit immediately without retrying. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
686 lines
26 KiB
Swift
686 lines
26 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 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
|
|
)
|
|
|
|
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,
|
|
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 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(
|
|
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()
|
|
|
|
#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
|
|
}
|