1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
@testable import Grain
import XCTest
/// The @ detection runs on every keystroke in a comment box, and getting it
/// wrong either pops a suggestion strip over an email address or fails to offer
/// one at all. Detection and completion are synchronous; only the lookup they
/// trigger is not, so these stay off the network by clearing before it fires.
@MainActor
final class MentionAutocompleteTests: GrainTestCase {
private var state: MentionAutocompleteState!
override func setUp() async throws {
try await super.setUp()
state = MentionAutocompleteState()
}
override func tearDown() async throws {
// Cancels the debounced lookup so it can't outlive the test.
state.clear()
state = nil
try await super.tearDown()
}
/// `update` sets the query synchronously and only then schedules the search,
/// so the query can be read without waiting for the network.
private func query(after text: String) -> String? {
state.update(text: text)
let result = state.activeQuery
state.clear()
return result
}
// MARK: - Detection
func testAnAtAtTheStartOfTheTextStartsAMention() {
XCTAssertEqual(query(after: "@ali"), "ali")
}
func testAnAtAfterASpaceStartsAMention() {
XCTAssertEqual(query(after: "nice shot @ali"), "ali")
}
func testAnAtAfterANewlineStartsAMention() {
XCTAssertEqual(query(after: "nice shot\n@ali"), "ali")
}
/// The classic false positive: an email address is not a mention.
func testAnAtInTheMiddleOfAWordIsNotAMention() {
XCTAssertNil(query(after: "mail me at chad@example.com"))
}
func testABareAtIsNotYetAMention() {
XCTAssertNil(query(after: "@"))
XCTAssertNil(query(after: "hello @"))
}
/// Once a space follows the handle the user has moved on, so the strip has
/// to go away rather than keep matching the finished handle.
func testASpaceAfterTheHandleEndsTheMention() {
XCTAssertNil(query(after: "@alice.grain.social nice"))
}
func testTextWithNoAtIsNotAMention() {
XCTAssertNil(query(after: "just a caption"))
XCTAssertNil(query(after: ""))
}
/// Only the mention being typed matters, not one already finished earlier
/// in the sentence.
func testTheLastAtWins() {
XCTAssertEqual(query(after: "@alice.test and @bo"), "bo")
}
func testAPartialHandleWithDotsIsCarriedThrough() {
XCTAssertEqual(query(after: "@alice.grain."), "alice.grain.")
}
// MARK: - isActive
func testTheStripIsOnlyActiveWhileAMentionIsBeingTyped() {
XCTAssertFalse(state.isActive)
state.update(text: "@ali")
XCTAssertTrue(state.isActive)
state.update(text: "@alice.test done")
XCTAssertFalse(state.isActive)
}
func testClearingTakesTheStripDownAndDropsTheSuggestions() {
state.update(text: "@ali")
state.suggestions = [MentionSuggestion(handle: "alice.test", displayName: "Alice", avatar: nil)]
state.clear()
XCTAssertFalse(state.isActive)
XCTAssertNil(state.activeQuery)
XCTAssertTrue(state.suggestions.isEmpty)
}
// MARK: - complete
func testCompletingReplacesThePartialHandleAndAddsATrailingSpace() {
var text = "nice shot @ali"
state.update(text: text)
state.complete(handle: "alice.grain.social", in: &text)
XCTAssertEqual(text, "nice shot @alice.grain.social ")
}
func testCompletingAtTheStartOfTheText() {
var text = "@ali"
state.update(text: text)
state.complete(handle: "alice.grain.social", in: &text)
XCTAssertEqual(text, "@alice.grain.social ")
}
/// Only the mention under the cursor should be rewritten — an earlier,
/// already-complete mention has to survive untouched.
func testCompletingLeavesAnEarlierMentionAlone() {
var text = "@bob.test hi @ali"
state.update(text: text)
state.complete(handle: "alice.test", in: &text)
XCTAssertEqual(text, "@bob.test hi @alice.test ")
}
func testCompletingTakesTheStripDown() {
var text = "@ali"
state.update(text: text)
state.complete(handle: "alice.test", in: &text)
XCTAssertFalse(state.isActive)
XCTAssertTrue(state.suggestions.isEmpty)
}
/// Nothing is being typed, so there is nothing to replace — the text must
/// come back unchanged rather than gaining a stray handle.
func testCompletingWithNoActiveMentionLeavesTheTextAlone() {
var text = "just a caption"
state.complete(handle: "alice.test", in: &text)
XCTAssertEqual(text, "just a caption")
}
// MARK: - MentionSuggestion
func testSuggestionsAreIdentifiedByHandle() {
let suggestion = MentionSuggestion(handle: "alice.test", displayName: "Alice", avatar: nil)
XCTAssertEqual(suggestion.id, "alice.test")
XCTAssertEqual(suggestion, MentionSuggestion(handle: "alice.test", displayName: "Alice", avatar: nil))
XCTAssertNotEqual(suggestion, MentionSuggestion(handle: "bob.test", displayName: "Alice", avatar: nil))
}
}