Files
bitchat/bitchatTests/ChatPeerListCoordinatorContextTests.swift
266827ceff Extend BLE mesh range: relax RSSI gates, lift sparse TTL clamps, faster walk-back reconnects (#1338)
* Extend mesh range: relax RSSI gates, lift sparse TTL clamps, drain connection queue

Range improvements to the BLE mesh, all policy-level (no wire/protocol
changes):

- Drain the connection candidate queue from the maintenance tick.
  Weak-RSSI discoveries are enqueued rather than connected, but the
  queue was only drained on disconnect/failure/timeout events — an
  isolated node surrounded only by weak (distant) peers queued them
  all and never connected to anyone.
- Relax isolated RSSI floors from -90/-92 to -95/-100 and relax after
  30s instead of 60s. When isolated, a fringe connection beats no
  connection; CoreBluetooth rarely reports below -100 so prolonged
  isolation now effectively accepts any decodable peer.
- Drop the global high-timeout RSSI escalation (-80 after 3 timeouts
  in 60s). One flaky distant peer could blind the node to every other
  edge-of-range peer; per-peripheral cooldown, the discovery ignore
  window, and score bias already contain flaky links individually.
- Relay at full incoming TTL in thin chains (degree <= 2). Sparse
  line topologies are exactly where every hop counts and where flood
  cost is minimal; previously messages lost a hop to the clamp.
- Raise the fragment relay TTL cap from 5 to 7 in sparse graphs so
  media reaches as far as text; dense graphs keep the 5-hop clamp to
  contain full-fanout fragment floods.
- Extend peer reachability retention from 21s to 60s (verified) /
  45s (unverified) so duty-cycled nodes (worst-case dense announce
  interval 38s) don't forget peers between announces.
- Extend the directed store-and-forward spool window from 15s to 60s
  so brief link gaps heal via the periodic flush.

957 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reconnect quickly after walk-away disconnects

Field test (walk away + return between two devices) showed reconnect
landing exactly 15.0s after the supervision-timeout disconnect: the
scheduler records a dropped established connection via
recordDisconnectError into the same map as connect timeouts, and
handleDiscovery hard-ignores rediscoveries for 15s.

Those are different situations. A connect attempt that timed out means
the peer likely isn't reachable, so backing off is right. A dropped
established connection usually means the peer walked out of range and
will return — track it separately and only ignore rediscoveries for 3s
(enough for CoreBluetooth to settle), so walking back into range
reconnects ~12s sooner.

Disconnect errors also no longer feed the weak-link cooldown or the
candidate-score timeout bias; those penalties now apply only to peers
that never answered a connect attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Honor the disconnect settle window on the queue drain path

Codex review caught that the 3s settle window was only enforced in
handleDiscovery. A candidate can already be sitting in the queue when
its peripheral drops (weak-RSSI adverts are enqueued even while
connected, since the RSSI check precedes the existing-state check),
and didDisconnectPeripheral immediately drains the queue — so the
stale entry could reconnect right through the window, recreating the
reconnect/cancel thrash it exists to prevent.

nextCandidate now defers such candidates with retryAfter for the
window's remainder, mirroring the weak-link cooldown pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Relay on one link per bound peer instead of both dual-role links

Three-device field test (star topology) showed every relayed fragment
arriving twice at the leaf: dual-role pairs hold two live links (we as
central writing to their peripheral, they as central subscribed to
ours) and broadcast/relay fanout sent the same packet down both — 2x
airtime on exactly the pairs that talk most, with the receiver just
discarding the duplicate.

The fanout selector now collapses link selection to one link per bound
peer, preferring the peripheral (write) side since it has per-link
flow control via canSendWriteWithoutResponse, while notifications
share the peripheral manager's update queue across all centrals.
Links with no bound peer yet (pre-announce) pass through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Only notify "bitchatters nearby" on the empty-to-populated transition

Devices sitting idle and connected kept re-firing the notification.
Two bugs in handleNetworkAvailability:

- Peers first sighted during the 5-minute cooldown were never added to
  recentlySeenPeers (the formUnion only ran when a notification
  fired), so they stayed "new" forever and re-triggered on the next
  routine peer-list event once the cooldown lapsed.
- There was no went-from-zero gate at all: any unseen peer notified,
  even while already meshed with others who are visible in the app.

Every sighted peer is now recorded regardless of cooldown, and the
notification only fires when the mesh transitions from confirmed-empty
to populated with genuinely new peers. meshWasEmpty resets only via
the existing confirmed-empty paths (30s empty confirmation, 10-minute
quiet reset), so brief link flaps stay silent. The cooldown becomes
injectable so tests can prove the transition gate independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bump version to 1.5.2; Xcode 26.5 project settings update

Marketing version 1.5.1 -> 1.5.2 (pbxproj + Release.xcconfig).
Project settings refresh from Xcode 26.5: upgrade-check stamp, drop
redundant DEVELOPMENT_TEAM self-references, scheme version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Disable string catalog symbol generation

The Xcode 26.5 settings refresh enabled STRING_CATALOG_GENERATE_SYMBOLS
(the new default), which fails on the literal "%@" key in
Localizable.xcstrings — a pure format placeholder can't become a Swift
identifier. Nothing in the codebase references generated catalog
symbols, so turn the feature off rather than renaming keys around it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard _PreviewHelpers references for archive builds

Archiving for TestFlight failed: _PreviewHelpers is a development
asset, so its sources (PreviewKeychainManager, BitchatMessage.preview)
are excluded from Release/archive builds, and two call sites
referenced them unconditionally:

- TextMessageView's #Preview block — now wrapped in #if DEBUG
- FavoritesPersistenceService.makeDefaultKeychain's test branch — the
  in-memory-keychain-under-test path is now #if DEBUG; tests always
  run Debug so behavior is unchanged, and Release always gets the real
  KeychainManager

Verified with an iOS Release arm64 build (the archive configuration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:38:39 +02:00

262 lines
10 KiB
Swift

//
// ChatPeerListCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext` —
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` /
// `ChatTransportEventCoordinatorContextTests` exemplars.
//
// Scope note: the network-availability notification now posts through the
// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so
// its gating is covered here; the wall-clock timer-driven reset flows are
// covered by integration-level tests.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPeerListContext` proving that
/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPeerListContext: ChatPeerListContext {
// Connection & chat state
var isConnected = false
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var hasTrackedPrivateChatSelection = false
private(set) var updatePrivateChatPeerIfNeededCount = 0
private(set) var cleanupOldReadReceiptsCount = 0
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func updatePrivateChatPeerIfNeeded() {
updatePrivateChatPeerIfNeededCount += 1
}
func cleanupOldReadReceipts() {
cleanupOldReadReceiptsCount += 1
}
// Peers & sessions
var unifiedPeers: [BitchatPeer] = []
var connectedMeshPeers: Set<PeerID> = []
var reachableMeshPeers: Set<PeerID> = []
var activeMeshPeerCountValue = 0
private(set) var registeredEphemeralSessions: [PeerID] = []
private(set) var updateEncryptionStatusForPeersCount = 0
func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) }
func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) }
func activeMeshPeerCount() -> Int { activeMeshPeerCountValue }
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 }
// Notifications
private(set) var networkAvailableNotifications: [Int] = []
func notifyNetworkAvailable(peerCount: Int) {
networkAvailableNotifications.append(peerCount)
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor … }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with
/// no `ChatViewModel`.
struct ChatPeerListCoordinatorContextTests {
@Test @MainActor
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.isConnected = true
// Empty list: disconnected, read-receipt hygiene still runs, no sessions.
coordinator.didUpdatePeerList([])
await drainMainActorTasks()
#expect(!context.isConnected)
#expect(context.cleanupOldReadReceiptsCount == 1)
#expect(context.registeredEphemeralSessions.isEmpty)
#expect(context.updateEncryptionStatusForPeersCount == 1)
// Non-empty list: connected, every peer gets an ephemeral session.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerA, peerB])
#expect(context.updateEncryptionStatusForPeersCount == 2)
#expect(context.cleanupOldReadReceiptsCount == 2)
}
@Test @MainActor
func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerID = PeerID(str: "0011223344556677")
coordinator.didUpdatePeerList([peerID])
await drainMainActorTasks()
#expect(context.updatePrivateChatPeerIfNeededCount == 0)
context.hasTrackedPrivateChatSelection = true
coordinator.didUpdatePeerList([peerID])
await drainMainActorTasks()
#expect(context.updatePrivateChatPeerIfNeededCount == 1)
}
@Test @MainActor
func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let currentPeer = PeerID(str: "0011223344556677")
let staleShortPeer = PeerID(str: "8899aabbccddeeff")
let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8))
let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8))
let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32))
context.unifiedPeers = [
BitchatPeer(
peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice"
),
]
context.unreadPrivateMessages = [
currentPeer,
staleShortPeer,
geoDMWithMessages,
geoDMWithoutMessages,
noiseKeyWithMessages,
]
context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
]
coordinator.didUpdatePeerList([currentPeer])
await drainMainActorTasks()
// Stale IDs without a backing conversation are dropped; geo-DM and
// Noise-key IDs with stored messages survive, as does the live peer.
#expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages])
#expect(context.cleanupOldReadReceiptsCount == 1)
}
@Test @MainActor
func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.connectedMeshPeers = [peerA, peerB]
// First sighting of a mesh-active peer notifies with the mesh peer count.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// The same peer again is not new — no repeat notification.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// A genuinely new peer inside the cooldown window stays silent too.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_peerJoiningExistingMeshDoesNotNotify() async {
// Cooldown zero so this proves the empty-transition gate alone — a
// new peer joining while already meshed must stay silent even with
// the cooldown long expired (the sitting-idle re-notify bug).
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.connectedMeshPeers = [peerA, peerB]
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// peerB arrives while peerA is still connected: no notification.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// Repeat events while idle keep staying silent.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_briefMeshFlapDoesNotRenotify() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
let peerA = PeerID(str: "0011223344556677")
context.connectedMeshPeers = [peerA]
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// Link flap: empty list, then the peer returns before the 30s empty
// confirmation fires — silent.
coordinator.didUpdatePeerList([])
await drainMainActorTasks()
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_meshInactivePeersNeverNotify() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
// Peer present but neither connected nor reachable: no notification.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications.isEmpty)
}
}