mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Finish coordinator migration: zero ChatViewModel back-references remain
ChatPeerListCoordinator, ChatComposerCoordinator, ChatOutgoingCoordinator, and GeoChannelCoordinator complete the migration; every coordinator now depends on a narrow @MainActor context protocol. GeoChannelCoordinator's three injected closures collapse into a weak context. New intent op recordPublicActivity(forChannelKey:) keeps lastPublicActivityAt single-writer. 15 new mock-context tests; flaky-poll deadline in the gift-wrap dedup test raised for parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// GeoChannelCoordinatorContextTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Exercises `GeoChannelCoordinator` against a mock `GeoChannelContext` —
|
||||
// proving the coordinator works without a `ChatViewModel`, following the
|
||||
// `ChatDeliveryCoordinatorContextTests` exemplar.
|
||||
//
|
||||
// Scope note: the location/bookmark managers are real `LocationStateManager`
|
||||
// instances backed by throwaway `UserDefaults` suites and mocked CoreLocation
|
||||
// seams. `TorManager` has no test seam (private init singleton); sampling
|
||||
// tests pin `TorManager.shared` to foreground (its default) so the
|
||||
// begin-sampling branch is deterministic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import Tor
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Mock Context
|
||||
|
||||
/// Lightweight stand-in for `GeoChannelContext` proving that
|
||||
/// `GeoChannelCoordinator` is testable without a `ChatViewModel`.
|
||||
@MainActor
|
||||
private final class MockGeoChannelContext: GeoChannelContext {
|
||||
private(set) var switchedChannels: [ChannelID] = []
|
||||
private(set) var beginSamplingCalls: [[String]] = []
|
||||
private(set) var endSamplingCount = 0
|
||||
|
||||
func switchLocationChannel(to channel: ChannelID) { switchedChannels.append(channel) }
|
||||
func beginGeohashSampling(for geohashes: [String]) { beginSamplingCalls.append(geohashes.sorted()) }
|
||||
func endGeohashSampling() { endSamplingCount += 1 }
|
||||
}
|
||||
|
||||
// MARK: - CoreLocation Seams
|
||||
|
||||
private final class StubLocationManaging: LocationStateManaging {
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
var desiredAccuracy: CLLocationAccuracy = 0
|
||||
var distanceFilter: CLLocationDistance = 0
|
||||
var authorizationStatus: CLAuthorizationStatus = .denied
|
||||
|
||||
func requestWhenInUseAuthorization() {}
|
||||
func requestLocation() {}
|
||||
func startUpdatingLocation() {}
|
||||
func stopUpdatingLocation() {}
|
||||
}
|
||||
|
||||
private final class StubLocationGeocoder: LocationStateGeocoding {
|
||||
func cancelGeocode() {}
|
||||
func reverseGeocodeLocation(
|
||||
_ location: CLLocation,
|
||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||
) {
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager {
|
||||
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
|
||||
let defaults = storage ?? UserDefaults(suiteName: suiteName)!
|
||||
if storage == nil {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
return LocationStateManager(
|
||||
storage: defaults,
|
||||
locationManager: StubLocationManaging(),
|
||||
geocoder: StubLocationGeocoder(),
|
||||
shouldInitializeCoreLocation: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Polls until `condition` holds, letting main-actor tasks and main-queue
|
||||
/// Combine hops drain in between.
|
||||
@MainActor
|
||||
private func waitUntil(_ condition: () -> Bool) async -> Bool {
|
||||
for _ in 0..<100 {
|
||||
if condition() { return true }
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `GeoChannelCoordinator` against `MockGeoChannelContext` with no
|
||||
/// `ChatViewModel`.
|
||||
struct GeoChannelCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func start_publishesPersistedChannelAndEndsSamplingWithoutGeohashes() async throws {
|
||||
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName)!
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
let persisted = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
|
||||
storage.set(try JSONEncoder().encode(persisted), forKey: "locationChannel.selected")
|
||||
|
||||
let locationManager = makeLocationManager(storage: storage)
|
||||
let context = MockGeoChannelContext()
|
||||
let coordinator = GeoChannelCoordinator(
|
||||
locationManager: locationManager,
|
||||
bookmarksStore: locationManager,
|
||||
torManager: TorManager.shared,
|
||||
context: context
|
||||
)
|
||||
defer { withExtendedLifetime(coordinator) {} }
|
||||
|
||||
// The persisted selection is announced and, with no regional or
|
||||
// bookmarked geohashes, sampling ends rather than begins.
|
||||
#expect(await waitUntil { !context.switchedChannels.isEmpty && context.endSamplingCount > 0 })
|
||||
#expect(context.switchedChannels.allSatisfy { $0 == persisted })
|
||||
#expect(context.beginSamplingCalls.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func selectingChannel_propagatesSwitchToContext() async {
|
||||
let locationManager = makeLocationManager()
|
||||
let context = MockGeoChannelContext()
|
||||
let coordinator = GeoChannelCoordinator(
|
||||
locationManager: locationManager,
|
||||
bookmarksStore: locationManager,
|
||||
torManager: TorManager.shared,
|
||||
context: context
|
||||
)
|
||||
defer { withExtendedLifetime(coordinator) {} }
|
||||
|
||||
#expect(await waitUntil { context.switchedChannels.contains(.mesh) })
|
||||
|
||||
let target = ChannelID.location(GeohashChannel(level: .neighborhood, geohash: "u4pruydq"))
|
||||
locationManager.select(target)
|
||||
#expect(await waitUntil { context.switchedChannels.last == target })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bookmarkChanges_beginAndEndGeohashSampling() async {
|
||||
TorManager.shared.setAppForeground(true)
|
||||
let locationManager = makeLocationManager()
|
||||
let context = MockGeoChannelContext()
|
||||
let coordinator = GeoChannelCoordinator(
|
||||
locationManager: locationManager,
|
||||
bookmarksStore: locationManager,
|
||||
torManager: TorManager.shared,
|
||||
context: context
|
||||
)
|
||||
|
||||
// No geohashes yet: only end-sampling has run.
|
||||
#expect(await waitUntil { context.endSamplingCount > 0 })
|
||||
#expect(context.beginSamplingCalls.isEmpty)
|
||||
|
||||
// Bookmarking a geohash starts sampling it.
|
||||
locationManager.toggleBookmark("u4pruydq")
|
||||
#expect(await waitUntil { context.beginSamplingCalls.last == ["u4pruydq"] })
|
||||
|
||||
// Removing the last bookmark ends sampling again, and a manual
|
||||
// refresh keeps reporting the empty state.
|
||||
let endCountBeforeRemoval = context.endSamplingCount
|
||||
locationManager.toggleBookmark("u4pruydq")
|
||||
#expect(await waitUntil { context.endSamplingCount > endCountBeforeRemoval })
|
||||
|
||||
let endCountBeforeRefresh = context.endSamplingCount
|
||||
coordinator.refreshSampling()
|
||||
#expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func releasedContext_isHeldWeaklyAndSafelyIgnored() async {
|
||||
let locationManager = makeLocationManager()
|
||||
var context: MockGeoChannelContext? = MockGeoChannelContext()
|
||||
weak var weakContext = context
|
||||
let coordinator = GeoChannelCoordinator(
|
||||
locationManager: locationManager,
|
||||
bookmarksStore: locationManager,
|
||||
torManager: TorManager.shared,
|
||||
context: context!
|
||||
)
|
||||
|
||||
#expect(await waitUntil { context?.switchedChannels.isEmpty == false })
|
||||
|
||||
// The coordinator must not keep the owner alive (it is owned by it).
|
||||
context = nil
|
||||
#expect(weakContext == nil)
|
||||
|
||||
// Events after the owner is gone are safely dropped.
|
||||
locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru")))
|
||||
locationManager.toggleBookmark("u4pruydq")
|
||||
coordinator.refreshSampling()
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user