Extract GeohashParticipantTracker from ChatViewModel

Refactor participant tracking logic into a dedicated, testable class:

- Create GeohashParticipantTracker with GeohashParticipantContext protocol
- Move GeoPerson struct to new file
- Extract participant recording, refresh, and count logic
- Add 18 comprehensive tests for the tracker
- Update ChatViewModel to use tracker via dependency injection
- Subscribe to tracker's objectWillChange for SwiftUI observation

This reduces coupling in ChatViewModel and makes participant tracking
testable in isolation.
This commit is contained in:
jack
2025-11-24 11:34:47 -10:00
parent ce33132a0f
commit f893f0605a
3 changed files with 498 additions and 95 deletions
@@ -0,0 +1,159 @@
//
// GeohashParticipantTracker.swift
// bitchat
//
// Tracks participants in geohash-based location channels.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Represents a participant in a geohash channel
public struct GeoPerson: Identifiable, Equatable, Sendable {
public let id: String // pubkey hex (lowercased)
public let displayName: String
public let lastSeen: Date
public init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
}
}
/// Protocol for resolving display names and checking block status
@MainActor
public protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
}
/// Tracks participants across multiple geohash channels
@MainActor
public final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
public let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published public private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
/// Context for display name resolution and block checking
private weak var context: GeohashParticipantContext?
/// Timer for periodic refresh
private var refreshTimer: Timer?
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
public func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
public func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
} else {
refresh()
}
}
/// Record activity from a participant in the current active geohash
public func recordParticipant(pubkeyHex: String) {
guard let gh = activeGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record activity from a participant in a specific geohash
public func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = participants[geohash] ?? [:]
map[key] = Date()
participants[geohash] = map
// Only refresh visible list if this geohash is currently active
if activeGeohash == geohash {
refresh()
}
}
/// Remove a participant from all geohashes (used when blocking)
public func removeParticipant(pubkeyHex: String) {
let key = pubkeyHex.lowercased()
for (gh, var map) in participants {
map.removeValue(forKey: key)
participants[gh] = map
}
refresh()
}
/// Get participant count for a specific geohash
public func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
public func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !context.isBlocked($0.key) }
return map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
}
/// Refresh the visible people list
public func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
public func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
/// Stop the periodic refresh timer
public func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
public func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
public func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
}
}
}
+43 -92
View File
@@ -92,7 +92,7 @@ import UniformTypeIdentifiers
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider {
final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext {
// Precompiled regexes and detectors reused across formatting
private enum Regexes {
static let hashtag: NSRegularExpression = {
@@ -419,10 +419,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participants (per geohash: pubkey -> lastSeen)
private var geoParticipants: [String: [String: Date]] = [:]
@Published private(set) var geohashPeople: [GeoPerson] = []
private var geoParticipantsTimer: Timer? = nil
// Geohash participant tracker
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
// Participants who indicated they teleported (by tag in their events)
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
@@ -526,6 +524,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
// Wire up dependencies
self.commandProcessor.chatViewModel = self
self.participantTracker.configure(context: self)
// Subscribe to privateChatManager changes to trigger UI updates
privateChatManager.objectWillChange
@@ -533,6 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self?.objectWillChange.send()
}
.store(in: &cancellables)
// Subscribe to participantTracker changes to trigger UI updates
participantTracker.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
self.commandProcessor.meshService = meshService
loadNickname()
@@ -873,7 +879,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
return
}
// Ensure participant decay timer is running
startGeoParticipantsTimer()
participantTracker.startRefreshTimer()
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
@@ -932,7 +938,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey
recordGeoParticipant(pubkeyHex: event.pubkey)
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in
@@ -1479,7 +1485,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
// Track ourselves as active participant
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
@@ -1514,8 +1520,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
stopGeoParticipantsTimer()
geohashPeople = []
participantTracker.stopRefreshTimer()
participantTracker.setActiveGeohash(nil)
teleportedGeo.removeAll()
case .location:
refreshVisibleMessages(from: channel)
@@ -1536,16 +1542,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
geoDmSubscriptionID = nil
}
currentGeohash = nil
participantTracker.setActiveGeohash(nil)
// Reset nickname cache for geochat participants
geoNicknames.removeAll()
geohashPeople = []
guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash
participantTracker.setActiveGeohash(ch.geohash)
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
recordGeoParticipant(pubkeyHex: id.publicKeyHex)
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased()
@@ -1559,7 +1566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID
startGeoParticipantsTimer()
participantTracker.startRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
@@ -1629,7 +1636,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey
recordGeoParticipant(pubkeyHex: event.pubkey)
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content
@@ -1811,49 +1818,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
// MARK: - Geohash Participants
struct GeoPerson: Identifiable, Equatable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
}
private func recordGeoParticipant(pubkeyHex: String) {
guard let gh = currentGeohash else { return }
let key = pubkeyHex.lowercased()
var map = geoParticipants[gh] ?? [:]
map[key] = Date()
geoParticipants[gh] = map
refreshGeohashPeople()
}
private func recordGeoParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = geoParticipants[geohash] ?? [:]
map[key] = Date()
geoParticipants[geohash] = map
// Only refresh list if this geohash is currently selected
if currentGeohash == geohash {
refreshGeohashPeople()
}
}
private func refreshGeohashPeople() {
guard let gh = currentGeohash else { geohashPeople = []; return }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
var map = geoParticipants[gh] ?? [:]
// Prune expired entries
map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
geoParticipants[gh] = map
// Build display list
let people = map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
geohashPeople = people
}
@MainActor
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
@@ -1878,33 +1842,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
return false
}
private func startGeoParticipantsTimer() {
stopGeoParticipantsTimer()
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refreshGeohashPeople()
}
}
}
private func stopGeoParticipantsTimer() {
geoParticipantsTimer?.invalidate()
geoParticipantsTimer = nil
}
// MARK: - Public helpers
/// Published geohash people list for SwiftUI observation
var geohashPeople: [GeoPerson] {
participantTracker.visiblePeople
}
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
@MainActor
func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen }
return people
participantTracker.getVisiblePeople()
}
/// CommandContextProvider conformance - returns visible geo participants
@@ -1914,9 +1862,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
/// Returns the current participant count for a specific geohash, using the 5-minute activity window.
@MainActor
func geohashParticipantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = geoParticipants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
participantTracker.participantCount(for: geohash)
}
// MARK: - GeohashParticipantContext Protocol
func displayNameForPubkey(_ pubkeyHex: String) -> String {
displayNameForNostrPubkey(pubkeyHex)
}
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
}
// Geohash block helpers
@@ -1930,11 +1886,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
identityManager.setNostrBlocked(hex, isBlocked: true)
// Remove from participants for all geohashes
for (gh, var map) in geoParticipants {
map.removeValue(forKey: hex)
geoParticipants[gh] = map
}
refreshGeohashPeople()
participantTracker.removeParticipant(pubkeyHex: hex)
// Remove their public messages from current geohash timeline and visible list
if let gh = currentGeohash {
@@ -2024,11 +1976,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let existingCount = geoParticipants[gh]?.values.filter { $0 >= cutoff }.count ?? 0
let existingCount = participantTracker.participantCount(for: gh)
// Update participants for this specific geohash
recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -3229,7 +3180,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
self.addSystemMessage(
@@ -0,0 +1,293 @@
//
// GeohashParticipantTrackerTests.swift
// bitchatTests
//
// Tests for GeohashParticipantTracker.
// This is free and unencumbered software released into the public domain.
//
import Testing
import Foundation
@testable import bitchat
/// Mock context for testing
@MainActor
final class MockParticipantContext: 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 self = selfPubkey, pubkeyHex.lowercased() == self.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())
}
}
@MainActor
struct GeohashParticipantTrackerTests {
// MARK: - Basic Recording Tests
@Test func recordParticipant_addsToActiveGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
#expect(tracker.participantCount(for: "abc123") == 1)
}
@Test func recordParticipant_noActiveGeohash_noOp() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
// No active geohash set
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
// Should not throw or crash
#expect(tracker.participantCount(for: "abc123") == 0)
}
@Test func recordParticipant_specificGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
#expect(tracker.participantCount(for: "geo1") == 1)
#expect(tracker.participantCount(for: "geo2") == 1)
}
@Test func recordParticipant_updatesLastSeen() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
// Small delay and record again
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
tracker.recordParticipant(pubkeyHex: "pubkey1")
// Should still count as 1 participant (updated, not duplicated)
#expect(tracker.participantCount(for: "abc123") == 1)
}
@Test func recordParticipant_lowercasesPubkey() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "DEADBEEF")
tracker.recordParticipant(pubkeyHex: "deadbeef")
// Should be treated as same participant
#expect(tracker.participantCount(for: "abc123") == 1)
}
// MARK: - Visible People Tests
@Test func getVisiblePeople_returnsActiveGeohashParticipants() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
tracker.recordParticipant(pubkeyHex: "pubkey2")
let people = tracker.getVisiblePeople()
#expect(people.count == 2)
}
@Test func getVisiblePeople_excludesBlockedParticipants() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
context.blockedPubkeys = ["pubkey2"]
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
tracker.recordParticipant(pubkeyHex: "pubkey2")
let people = tracker.getVisiblePeople()
#expect(people.count == 1)
#expect(people.first?.id == "pubkey1")
}
@Test func getVisiblePeople_usesDisplayNameFromContext() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
context.nicknameMap = ["pubkey1234": "alice"]
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1234")
let people = tracker.getVisiblePeople()
#expect(people.count == 1)
#expect(people.first?.displayName == "alice#1234")
}
@Test func getVisiblePeople_sortedByLastSeen() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "older")
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
tracker.recordParticipant(pubkeyHex: "newer")
let people = tracker.getVisiblePeople()
#expect(people.count == 2)
#expect(people.first?.id == "newer")
#expect(people.last?.id == "older")
}
@Test func getVisiblePeople_emptyWhenNoActiveGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
let people = tracker.getVisiblePeople()
#expect(people.isEmpty)
}
// MARK: - Activity Cutoff Tests
@Test func participantCount_excludesExpiredEntries() async {
// Use a very short cutoff for testing
let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
// Should be counted immediately
#expect(tracker.participantCount(for: "abc123") == 1)
// Wait for expiry
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Should be expired now
#expect(tracker.participantCount(for: "abc123") == 0)
}
// MARK: - Remove Participant Tests
@Test func removeParticipant_removesFromAllGeohashes() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1")
tracker.removeParticipant(pubkeyHex: "pubkey1")
#expect(tracker.participantCount(for: "geo1") == 1)
#expect(tracker.participantCount(for: "geo2") == 0)
}
// MARK: - Clear Tests
@Test func clear_removesAllData() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other")
tracker.clear()
#expect(tracker.participantCount(for: "abc123") == 0)
#expect(tracker.participantCount(for: "other") == 0)
#expect(tracker.visiblePeople.isEmpty)
}
@Test func clearGeohash_removesOnlySpecificGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
tracker.clear(geohash: "geo1")
#expect(tracker.participantCount(for: "geo1") == 0)
#expect(tracker.participantCount(for: "geo2") == 1)
}
// MARK: - Set Active Geohash Tests
@Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
#expect(!tracker.visiblePeople.isEmpty)
tracker.setActiveGeohash(nil)
#expect(tracker.visiblePeople.isEmpty)
}
@Test func setActiveGeohash_refreshesVisiblePeople() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
// Pre-populate a geohash
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
// Set it as active
tracker.setActiveGeohash("abc123")
#expect(tracker.visiblePeople.count == 1)
}
// MARK: - GeoPerson Tests
@Test func geoPerson_identifiable() async {
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date())
#expect(person1.id == person2.id)
#expect(person1.id != person3.id)
}
@Test func geoPerson_equatable() async {
let date = Date()
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
#expect(person1 == person2)
}
}