mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
637 lines
22 KiB
Swift
637 lines
22 KiB
Swift
//
|
|
// GeohashPresenceTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Tests for the Geohash Presence (Kind 20001) feature.
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import Combine
|
|
@testable import bitchat
|
|
|
|
// MARK: - NostrProtocol Presence Event Tests
|
|
|
|
struct NostrProtocolPresenceTests {
|
|
|
|
@Test func createGeohashPresenceEvent_hasCorrectKind() throws {
|
|
let identity = try makeTestIdentity()
|
|
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
geohash: "u4pruydq",
|
|
senderIdentity: identity
|
|
)
|
|
|
|
#expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|
#expect(event.kind == 20001)
|
|
}
|
|
|
|
@Test func createGeohashPresenceEvent_hasEmptyContent() throws {
|
|
let identity = try makeTestIdentity()
|
|
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
geohash: "u4pruydq",
|
|
senderIdentity: identity
|
|
)
|
|
|
|
#expect(event.content == "")
|
|
}
|
|
|
|
@Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws {
|
|
let identity = try makeTestIdentity()
|
|
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
geohash: "u4pruydq",
|
|
senderIdentity: identity
|
|
)
|
|
|
|
// Should have exactly one tag: ["g", geohash]
|
|
#expect(event.tags.count == 1)
|
|
#expect(event.tags[0] == ["g", "u4pruydq"])
|
|
}
|
|
|
|
@Test func createGeohashPresenceEvent_noNicknameTag() throws {
|
|
let identity = try makeTestIdentity()
|
|
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
geohash: "u4pruydq",
|
|
senderIdentity: identity
|
|
)
|
|
|
|
// Should NOT contain nickname tag
|
|
let hasNicknameTag = event.tags.contains { $0.first == "n" }
|
|
#expect(!hasNicknameTag)
|
|
}
|
|
|
|
@Test func createGeohashPresenceEvent_usesSenderPubkey() throws {
|
|
let identity = try makeTestIdentity()
|
|
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
geohash: "u4pruydq",
|
|
senderIdentity: identity
|
|
)
|
|
|
|
#expect(event.pubkey == identity.publicKeyHex)
|
|
}
|
|
|
|
@Test func createGeohashPresenceEvent_isSigned() throws {
|
|
let identity = try makeTestIdentity()
|
|
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
geohash: "u4pruydq",
|
|
senderIdentity: identity
|
|
)
|
|
|
|
#expect(event.sig != nil && !event.sig!.isEmpty)
|
|
#expect(!event.id.isEmpty)
|
|
}
|
|
|
|
@Test func createGeohashPresenceEvent_differentGeohashes() throws {
|
|
let identity = try makeTestIdentity()
|
|
|
|
let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity)
|
|
let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity)
|
|
let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity)
|
|
|
|
#expect(event1.tags[0][1] == "87")
|
|
#expect(event2.tags[0][1] == "87yw")
|
|
#expect(event3.tags[0][1] == "87yw7")
|
|
}
|
|
|
|
// MARK: - Helper
|
|
|
|
private func makeTestIdentity() throws -> NostrIdentity {
|
|
// Generate a fresh test identity
|
|
return try NostrIdentity.generate()
|
|
}
|
|
}
|
|
|
|
// MARK: - NostrFilter Presence Tests
|
|
|
|
struct NostrFilterPresenceTests {
|
|
|
|
@Test func geohashEphemeral_includesBothKinds() {
|
|
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
|
|
|
#expect(filter.kinds?.contains(20000) == true)
|
|
#expect(filter.kinds?.contains(20001) == true)
|
|
}
|
|
|
|
@Test func geohashEphemeral_hasLimit1000() {
|
|
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
|
|
|
#expect(filter.limit == 1000)
|
|
}
|
|
|
|
@Test func geohashEphemeral_respectsSinceParameter() {
|
|
let since = Date(timeIntervalSince1970: 1700000000)
|
|
let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since)
|
|
|
|
#expect(filter.since == 1700000000)
|
|
}
|
|
}
|
|
|
|
// MARK: - ChatViewModel Presence Handling Tests
|
|
|
|
@MainActor
|
|
struct ChatViewModelPresenceHandlingTests {
|
|
|
|
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let geohash = "u4pruydq"
|
|
|
|
// Set up the channel
|
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
|
|
// Create a presence event (kind 20001)
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", geohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
// Handle the event
|
|
viewModel.handleNostrEvent(signed)
|
|
|
|
// Allow async processing
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Participant should be recorded
|
|
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
#expect(count >= 1)
|
|
}
|
|
|
|
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let geohash = "u4pruydq"
|
|
|
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
|
|
let initialMessageCount = viewModel.messages.count
|
|
|
|
// Create a presence event (kind 20001)
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", geohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
viewModel.handleNostrEvent(signed)
|
|
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Message count should NOT increase
|
|
#expect(viewModel.messages.count == initialMessageCount)
|
|
}
|
|
|
|
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let geohash = "u4pruydq"
|
|
|
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
|
|
// Create a chat event (kind 20000) - NOT presence
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .ephemeralEvent,
|
|
tags: [["g", geohash]],
|
|
content: "Hello world"
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
viewModel.handleNostrEvent(signed)
|
|
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Chat messages should also update participant count (not just presence)
|
|
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
#expect(count >= 1)
|
|
}
|
|
|
|
@Test func presenceEvent_hasDifferentKindThanChat() {
|
|
// Verify the two event kinds are distinct
|
|
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
|
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
|
|
#expect(presenceKind != chatKind)
|
|
#expect(presenceKind == 20001)
|
|
#expect(chatKind == 20000)
|
|
}
|
|
|
|
@Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let geohash = "u4pruydq"
|
|
|
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
|
|
// Create presence event
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", geohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
// subscribeNostrEvent should accept kind 20001
|
|
viewModel.subscribeNostrEvent(signed)
|
|
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Should record participant
|
|
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
#expect(count >= 1)
|
|
}
|
|
|
|
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let activeGeohash = "u4pruydq"
|
|
let otherGeohash = "87yw7"
|
|
|
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
|
|
|
// Create presence event for a DIFFERENT geohash
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", otherGeohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
// Use subscribeNostrEvent with geohash parameter
|
|
viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
|
|
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Should record for the other geohash
|
|
let count = viewModel.geohashParticipantCount(for: otherGeohash)
|
|
#expect(count >= 1)
|
|
}
|
|
|
|
@Test func subscribeNostrEvent_samplingDeduplicatesSameEventAcrossGeohashes() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let firstGeohash = "u4pru"
|
|
let secondGeohash = "u4pruy"
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", secondGeohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
viewModel.subscribeNostrEvent(signed, gh: firstGeohash)
|
|
viewModel.subscribeNostrEvent(signed, gh: secondGeohash)
|
|
|
|
#expect(viewModel.geohashParticipantCount(for: firstGeohash) == 1)
|
|
#expect(viewModel.geohashParticipantCount(for: secondGeohash) == 0)
|
|
}
|
|
|
|
@Test func subscribeNostrEvent_samplingDedupDoesNotBlockActiveChannelProcessing() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let sampleGeohash = "u4pru"
|
|
let activeGeohash = "u4pruy"
|
|
let identity = try NostrIdentity.generate()
|
|
|
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
|
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", sampleGeohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
|
|
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
|
|
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(signed.id))
|
|
|
|
viewModel.subscribeNostrEvent(signed)
|
|
|
|
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
|
|
#expect(viewModel.deduplicationService.hasProcessedNostrEvent(signed.id))
|
|
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
|
|
}
|
|
|
|
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let sampleGeohash = "u4pru"
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .geohashPresence,
|
|
tags: [["g", sampleGeohash]],
|
|
content: ""
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
var invalid = signed
|
|
invalid.sig = String(repeating: "0", count: 128)
|
|
|
|
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
|
|
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
|
|
|
|
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
|
|
}
|
|
|
|
// MARK: - Test Helper
|
|
|
|
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Presence Privacy Tests
|
|
|
|
struct GeohashPresencePrivacyTests {
|
|
|
|
@Test func allowedPrecisions_onlyLowPrecision() {
|
|
// The allowed precisions for presence broadcasting should be:
|
|
// Region (2), Province (4), City (5)
|
|
// NOT Neighborhood (6), Block (7), Building (8+)
|
|
|
|
let regionPrecision = GeohashChannelLevel.region.precision
|
|
let provincePrecision = GeohashChannelLevel.province.precision
|
|
let cityPrecision = GeohashChannelLevel.city.precision
|
|
let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision
|
|
let blockPrecision = GeohashChannelLevel.block.precision
|
|
let buildingPrecision = GeohashChannelLevel.building.precision
|
|
|
|
#expect(regionPrecision == 2)
|
|
#expect(provincePrecision == 4)
|
|
#expect(cityPrecision == 5)
|
|
#expect(neighborhoodPrecision == 6)
|
|
#expect(blockPrecision == 7)
|
|
#expect(buildingPrecision == 8)
|
|
|
|
// High precision channels should NOT receive presence broadcasts
|
|
#expect(neighborhoodPrecision > 5)
|
|
#expect(blockPrecision > 5)
|
|
#expect(buildingPrecision > 5)
|
|
}
|
|
|
|
@Test func geohashLengthDeterminesPrecision() {
|
|
// Verify geohash length maps to expected precision
|
|
#expect("87".count == GeohashChannelLevel.region.precision)
|
|
#expect("87yw".count == GeohashChannelLevel.province.precision)
|
|
#expect("87yw7".count == GeohashChannelLevel.city.precision)
|
|
#expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision)
|
|
#expect("87yw7tc".count == GeohashChannelLevel.block.precision)
|
|
#expect("87yw7tcx".count == GeohashChannelLevel.building.precision)
|
|
}
|
|
|
|
@Test func highPrecisionGeohash_isPrivacySensitive() {
|
|
// Helper to check if a geohash is "high precision" (privacy sensitive)
|
|
func isHighPrecision(_ geohash: String) -> Bool {
|
|
geohash.count >= 6
|
|
}
|
|
|
|
// Low precision - OK to broadcast presence
|
|
#expect(!isHighPrecision("87")) // region
|
|
#expect(!isHighPrecision("87yw")) // province
|
|
#expect(!isHighPrecision("87yw7")) // city
|
|
|
|
// High precision - should NOT broadcast presence
|
|
#expect(isHighPrecision("87yw7t")) // neighborhood
|
|
#expect(isHighPrecision("87yw7tc")) // block
|
|
#expect(isHighPrecision("87yw7tcx")) // building
|
|
}
|
|
}
|
|
|
|
// MARK: - Display Logic Tests
|
|
|
|
struct LocationChannelsDisplayLogicTests {
|
|
|
|
@Test func displayLogic_highPrecisionZeroCount_showsUnknown() {
|
|
// Test the logic that determines "?" vs actual count
|
|
// High precision + count 0 = "?"
|
|
|
|
let shouldShowUnknown = shouldShowUnknownCount(
|
|
level: .neighborhood,
|
|
count: 0
|
|
)
|
|
#expect(shouldShowUnknown)
|
|
}
|
|
|
|
@Test func displayLogic_highPrecisionNonZeroCount_showsActual() {
|
|
// High precision + count > 0 = show actual
|
|
let shouldShowUnknown = shouldShowUnknownCount(
|
|
level: .neighborhood,
|
|
count: 5
|
|
)
|
|
#expect(!shouldShowUnknown)
|
|
}
|
|
|
|
@Test func displayLogic_lowPrecisionZeroCount_showsActual() {
|
|
// Low precision + count 0 = show "0" (not "?")
|
|
let shouldShowUnknown = shouldShowUnknownCount(
|
|
level: .city,
|
|
count: 0
|
|
)
|
|
#expect(!shouldShowUnknown)
|
|
}
|
|
|
|
@Test func displayLogic_lowPrecisionNonZeroCount_showsActual() {
|
|
// Low precision + count > 0 = show actual
|
|
let shouldShowUnknown = shouldShowUnknownCount(
|
|
level: .region,
|
|
count: 10
|
|
)
|
|
#expect(!shouldShowUnknown)
|
|
}
|
|
|
|
@Test func displayLogic_allHighPrecisionLevels() {
|
|
// All high precision levels with 0 should show "?"
|
|
let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building]
|
|
|
|
for level in highPrecisionLevels {
|
|
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
|
#expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown")
|
|
}
|
|
}
|
|
|
|
@Test func displayLogic_allLowPrecisionLevels() {
|
|
// All low precision levels with 0 should show actual count
|
|
let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city]
|
|
|
|
for level in lowPrecisionLevels {
|
|
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
|
#expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count")
|
|
}
|
|
}
|
|
|
|
@Test func displayLogic_bookmarkHighPrecision() {
|
|
// Bookmarks use geohash length to determine precision
|
|
#expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6
|
|
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7
|
|
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8
|
|
}
|
|
|
|
@Test func displayLogic_bookmarkLowPrecision() {
|
|
#expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2
|
|
#expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4
|
|
#expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5
|
|
}
|
|
|
|
// MARK: - Helpers (mirror the logic from LocationChannelsSheet)
|
|
|
|
private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool {
|
|
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
|
return isHighPrecision && count == 0
|
|
}
|
|
|
|
private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool {
|
|
let isHighPrecision = (geohash.count >= 6)
|
|
return isHighPrecision && count == 0
|
|
}
|
|
}
|
|
|
|
// MARK: - Event Kind Tests
|
|
|
|
struct NostrEventKindTests {
|
|
|
|
@Test func eventKind_geohashPresence_is20001() {
|
|
#expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001)
|
|
}
|
|
|
|
@Test func eventKind_ephemeralEvent_is20000() {
|
|
#expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000)
|
|
}
|
|
|
|
@Test func eventKind_presenceIsEphemeral() {
|
|
// Both 20000 and 20001 are in the ephemeral range (20000-29999)
|
|
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
|
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
|
|
#expect(presenceKind >= 20000 && presenceKind < 30000)
|
|
#expect(chatKind >= 20000 && chatKind < 30000)
|
|
}
|
|
}
|
|
|
|
// MARK: - Participant Tracker Presence Integration Tests
|
|
|
|
@MainActor
|
|
struct ParticipantTrackerPresenceTests {
|
|
|
|
@Test func recordParticipant_fromPresenceEvent_countsParticipant() async {
|
|
let tracker = GeohashParticipantTracker()
|
|
let context = PresenceTestParticipantContext()
|
|
tracker.configure(context: context)
|
|
|
|
let geohash = "87yw7"
|
|
tracker.setActiveGeohash(geohash)
|
|
|
|
// Simulate recording from a presence event
|
|
tracker.recordParticipant(pubkeyHex: "presence_user_1")
|
|
|
|
#expect(tracker.participantCount(for: geohash) == 1)
|
|
}
|
|
|
|
@Test func recordParticipant_multiplePresenceEvents_countsUnique() async {
|
|
let tracker = GeohashParticipantTracker()
|
|
let context = PresenceTestParticipantContext()
|
|
tracker.configure(context: context)
|
|
|
|
let geohash = "87yw7"
|
|
tracker.setActiveGeohash(geohash)
|
|
|
|
// Multiple presence events from same user = 1 participant
|
|
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
tracker.recordParticipant(pubkeyHex: "user_a")
|
|
|
|
#expect(tracker.participantCount(for: geohash) == 1)
|
|
|
|
// Different user = 2 participants
|
|
tracker.recordParticipant(pubkeyHex: "user_b")
|
|
|
|
#expect(tracker.participantCount(for: geohash) == 2)
|
|
}
|
|
|
|
@Test func recordParticipant_nonActiveGeohash_stillCounts() async {
|
|
let tracker = GeohashParticipantTracker()
|
|
let context = PresenceTestParticipantContext()
|
|
tracker.configure(context: context)
|
|
|
|
// Active geohash is different from where we're recording
|
|
tracker.setActiveGeohash("active_gh")
|
|
|
|
// Record to a non-active geohash (like when sampling nearby channels)
|
|
tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh")
|
|
|
|
#expect(tracker.participantCount(for: "other_gh") == 1)
|
|
#expect(tracker.participantCount(for: "active_gh") == 0)
|
|
}
|
|
|
|
@Test func objectWillChange_firesOnNonActiveGeohashUpdate() async {
|
|
let tracker = GeohashParticipantTracker()
|
|
let context = PresenceTestParticipantContext()
|
|
tracker.configure(context: context)
|
|
|
|
tracker.setActiveGeohash("active_gh")
|
|
|
|
var changeCount = 0
|
|
let cancellable = tracker.objectWillChange.sink { _ in
|
|
changeCount += 1
|
|
}
|
|
|
|
// Record to non-active geohash
|
|
tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh")
|
|
|
|
// Should fire objectWillChange even for non-active geohash
|
|
#expect(changeCount >= 1)
|
|
|
|
_ = cancellable // Keep alive
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock for Participant Context (Presence Tests)
|
|
|
|
@MainActor
|
|
private final class PresenceTestParticipantContext: GeohashParticipantContext {
|
|
var blockedPubkeys: Set<String> = []
|
|
var nicknameMap: [String: String] = [:]
|
|
var selfPubkey: String?
|
|
|
|
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
|
let suffix = String(pubkeyHex.suffix(4))
|
|
if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() {
|
|
return "me#\(suffix)"
|
|
}
|
|
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
|
return "\(nick)#\(suffix)"
|
|
}
|
|
return "anon#\(suffix)"
|
|
}
|
|
|
|
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
|
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
|
}
|
|
}
|