mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +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,157 @@
|
||||
//
|
||||
// ChatComposerCoordinatorContextTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Exercises `ChatComposerCoordinator` against a mock `ChatComposerContext` —
|
||||
// proving the coordinator works without a `ChatViewModel`, following the
|
||||
// `ChatDeliveryCoordinatorContextTests` exemplar.
|
||||
//
|
||||
// Scope note: mention parsing uses the shared, precompiled
|
||||
// `ChatViewModel.Patterns.mention` regex (a static, stateless singleton);
|
||||
// everything else flows through the mock context.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Mock Context
|
||||
|
||||
/// Lightweight stand-in for `ChatComposerContext` proving that
|
||||
/// `ChatComposerCoordinator` is testable without a `ChatViewModel`.
|
||||
@MainActor
|
||||
private final class MockChatComposerContext: ChatComposerContext {
|
||||
// Autocomplete UI state
|
||||
var autocompleteSuggestions: [String] = []
|
||||
var autocompleteRange: NSRange?
|
||||
var showAutocomplete = false
|
||||
var selectedAutocompleteIndex = -1
|
||||
var queryResult: (suggestions: [String], range: NSRange?) = ([], nil)
|
||||
private(set) var queriedPeerCandidates: [[String]] = []
|
||||
private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = []
|
||||
|
||||
func autocompleteQuery(
|
||||
for text: String,
|
||||
peers: [String],
|
||||
cursorPosition: Int
|
||||
) -> (suggestions: [String], range: NSRange?) {
|
||||
queriedPeerCandidates.append(peers.sorted())
|
||||
return queryResult
|
||||
}
|
||||
|
||||
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
|
||||
appliedSuggestions.append((suggestion, text, range))
|
||||
guard let textRange = Range(range, in: text) else { return text }
|
||||
return text.replacingCharacters(in: textRange, with: suggestion)
|
||||
}
|
||||
|
||||
// Identity & channel state
|
||||
var nickname = "me"
|
||||
var myPeerID = PeerID(str: "0011223344556677")
|
||||
var activeChannel: ChannelID = .mesh
|
||||
var meshNickname = "me"
|
||||
var meshNicknamesByPeerID: [PeerID: String] = [:]
|
||||
|
||||
func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID }
|
||||
|
||||
// Geohash identity
|
||||
var geoNicknames: [String: String] = [:]
|
||||
static let dummyIdentity = NostrIdentity(
|
||||
privateKey: Data(repeating: 0x11, count: 32),
|
||||
publicKey: Data(repeating: 0x22, count: 32),
|
||||
npub: "npub1mock",
|
||||
createdAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
Self.dummyIdentity
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatComposerCoordinator` against `MockChatComposerContext` with
|
||||
/// no `ChatViewModel`.
|
||||
struct ChatComposerCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func updateAutocomplete_onMesh_excludesOwnNicknameAndPublishesSuggestions() {
|
||||
let context = MockChatComposerContext()
|
||||
let coordinator = ChatComposerCoordinator(context: context)
|
||||
context.meshNicknamesByPeerID = [
|
||||
PeerID(str: "1111111111111111"): "alice",
|
||||
PeerID(str: "2222222222222222"): "bob",
|
||||
PeerID(str: "3333333333333333"): "me",
|
||||
]
|
||||
|
||||
// Matching query: suggestions and range are published, index resets.
|
||||
context.queryResult = (["@alice"], NSRange(location: 0, length: 3))
|
||||
coordinator.updateAutocomplete(for: "@al", cursorPosition: 3)
|
||||
#expect(context.queriedPeerCandidates == [["alice", "bob"]])
|
||||
#expect(context.autocompleteSuggestions == ["@alice"])
|
||||
#expect(context.autocompleteRange == NSRange(location: 0, length: 3))
|
||||
#expect(context.showAutocomplete)
|
||||
#expect(context.selectedAutocompleteIndex == 0)
|
||||
|
||||
// No match: all autocomplete state is cleared.
|
||||
context.queryResult = ([], nil)
|
||||
context.selectedAutocompleteIndex = 3
|
||||
coordinator.updateAutocomplete(for: "plain text", cursorPosition: 5)
|
||||
#expect(context.autocompleteSuggestions.isEmpty)
|
||||
#expect(context.autocompleteRange == nil)
|
||||
#expect(!context.showAutocomplete)
|
||||
#expect(context.selectedAutocompleteIndex == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func updateAutocomplete_onLocationChannel_buildsGeoTokensWithoutOwnToken() {
|
||||
let context = MockChatComposerContext()
|
||||
let coordinator = ChatComposerCoordinator(context: context)
|
||||
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq"))
|
||||
context.geoNicknames = [
|
||||
"aaaabbbbccccdddd": "carol",
|
||||
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
|
||||
// identity's public key hex ends in "2222".
|
||||
"ffffeeeeddddcccc2222": "me",
|
||||
]
|
||||
|
||||
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
|
||||
#expect(context.queriedPeerCandidates == [["carol#dddd"]])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() {
|
||||
let context = MockChatComposerContext()
|
||||
let coordinator = ChatComposerCoordinator(context: context)
|
||||
|
||||
// Without an active range the text is untouched.
|
||||
var text = "hello @al"
|
||||
#expect(coordinator.completeNickname("@alice", in: &text) == text.count)
|
||||
#expect(context.appliedSuggestions.isEmpty)
|
||||
|
||||
// With a range the suggestion is applied and state cleared.
|
||||
context.autocompleteRange = NSRange(location: 6, length: 3)
|
||||
context.autocompleteSuggestions = ["@alice"]
|
||||
context.showAutocomplete = true
|
||||
let cursor = coordinator.completeNickname("@alice", in: &text)
|
||||
#expect(text == "hello @alice")
|
||||
#expect(cursor == 6 + "@alice".count + 1)
|
||||
#expect(!context.showAutocomplete)
|
||||
#expect(context.autocompleteSuggestions.isEmpty)
|
||||
#expect(context.autocompleteRange == nil)
|
||||
#expect(context.selectedAutocompleteIndex == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func parseMentions_acceptsKnownPeersOwnNicknameAndHashSuffix() {
|
||||
let context = MockChatComposerContext()
|
||||
let coordinator = ChatComposerCoordinator(context: context)
|
||||
context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"]
|
||||
|
||||
let mentions = coordinator.parseMentions(
|
||||
from: "hi @alice and @me and @me#0011 but not @stranger"
|
||||
)
|
||||
#expect(Set(mentions) == ["alice", "me", "me#0011"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user