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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
@testable import Grain
import XCTest
/// Search and discovery both swallow their errors so the tab degrades to an
/// empty state rather than an alert. That makes the failure paths worth pinning
/// down explicitly — a regression there is silent by design.
@MainActor
final class SearchViewModelTests: GrainTestCase {
private var client: XRPCClient!
private var vm: SearchViewModel!
override func setUp() async throws {
try await super.setUp()
client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
vm = SearchViewModel(client: client)
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
try await super.tearDown()
}
private static let galleryResults = """
{
"items": [{
"uri": "at://did:plc:a/social.grain.gallery/1",
"cid": "bafyg",
"title": "Found gallery",
"creator": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"},
"indexedAt": "2025-01-02T00:00:00Z"
}],
"cursor": null
}
"""
private static let profileResults = """
{
"items": [
{"did": "did:plc:a", "handle": "alice.test", "displayName": "Alice"},
{"did": "did:plc:b", "handle": "bob.test"}
],
"cursor": null
}
"""
// MARK: - Tabs
func testItStartsOnGalleriesWithBothTabsAvailable() {
XCTAssertEqual(vm.selectedTab, .galleries)
XCTAssertEqual(SearchViewModel.SearchTab.allCases, [.galleries, .profiles])
XCTAssertEqual(SearchViewModel.SearchTab.galleries.rawValue, "Galleries")
XCTAssertEqual(SearchViewModel.SearchTab.profiles.rawValue, "Profiles")
}
// MARK: - search
func testSearchingOnTheGalleriesTabFillsGalleryResults() async {
MockURLProtocol.respondWithJSON(Self.galleryResults)
vm.searchText = "film"
await vm.search()
XCTAssertEqual(vm.galleryResults.map(\.title), ["Found gallery"])
XCTAssertTrue(vm.profileResults.isEmpty, "The galleries tab shouldn't populate the profiles tab")
XCTAssertFalse(vm.isSearching)
}
func testSearchingOnTheProfilesTabFillsProfileResults() async {
MockURLProtocol.respondWithJSON(Self.profileResults)
vm.selectedTab = .profiles
vm.searchText = "ali"
await vm.search()
XCTAssertEqual(vm.profileResults.map(\.did), ["did:plc:a", "did:plc:b"])
XCTAssertTrue(vm.galleryResults.isEmpty)
XCTAssertFalse(vm.isSearching)
}
/// Whitespace is what a user leaves behind after clearing the field, and it
/// must not cost a request.
func testAnEmptyOrBlankQueryNeverHitsTheNetwork() async {
var requestMade = false
MockURLProtocol.handler = { request in
requestMade = true
return (Data("{}".utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
vm.searchText = ""
await vm.search()
vm.searchText = " \n "
await vm.search()
XCTAssertFalse(requestMade)
XCTAssertFalse(vm.isSearching, "Bailing early must not leave the spinner up")
}
func testAFailedSearchLeavesNoResultsAndStopsTheSpinner() async {
MockURLProtocol.respondWithError(statusCode: 500)
vm.searchText = "film"
await vm.search()
XCTAssertTrue(vm.galleryResults.isEmpty)
XCTAssertFalse(vm.isSearching)
}
/// A search that comes back with nothing has to clear the previous results,
/// or the tab keeps showing matches for a query that is no longer on screen.
func testASearchWithNoMatchesClearsThePreviousResults() async {
MockURLProtocol.respondWithJSON(Self.galleryResults)
vm.searchText = "film"
await vm.search()
XCTAssertEqual(vm.galleryResults.count, 1)
MockURLProtocol.respondWithJSON(#"{"items": [], "cursor": null}"#)
vm.searchText = "nothing matches this"
await vm.search()
XCTAssertTrue(vm.galleryResults.isEmpty)
}
/// A response that omits `items` entirely is not the same as one that sends
/// an empty array, and both have to end up as no results.
func testAResponseWithNoItemsKeyIsTreatedAsNoResults() async {
MockURLProtocol.respondWithJSON("{}")
vm.searchText = "film"
await vm.search()
XCTAssertTrue(vm.galleryResults.isEmpty)
}
// MARK: - As you type
func testASearchThatAnswersMarksItselfSearchedAndClearingUnmarksIt() async {
MockURLProtocol.respondWithJSON(Self.galleryResults)
vm.searchText = "film"
XCTAssertFalse(vm.hasSearched)
await vm.search()
XCTAssertTrue(vm.hasSearched)
XCTAssertEqual(vm.galleryResults.count, 1)
vm.clearResults()
XCTAssertFalse(vm.hasSearched)
XCTAssertFalse(vm.isSearching)
XCTAssertTrue(vm.galleryResults.isEmpty)
}
/// Two searches in flight, the first answering last: the field has moved
/// on, and the slow answer must not overwrite the one that matches it.
func testAnOvertakenSearchDoesNotOverwriteTheNewerAnswer() async {
let calls = CallCounter()
MockURLProtocol.handler = { request in
let call = calls.next()
if call == 1 {
// The first request is slow; the second overtakes it.
Thread.sleep(forTimeInterval: 0.3)
}
let body = call == 1 ? Self.galleryResults : Self.galleryResults.replacingOccurrences(of: "Found gallery", with: "Newer gallery")
return (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
vm.searchText = "fil"
let slow = Task { await vm.search() }
try? await Task.sleep(for: .milliseconds(50))
vm.searchText = "film"
await vm.search()
await slow.value
XCTAssertEqual(vm.galleryResults.map(\.title), ["Newer gallery"])
XCTAssertFalse(vm.isSearching)
}
// MARK: - loadDiscovery
func testDiscoveryLoadsLocationsAndCameras() async {
MockURLProtocol.handler = { request in
let body = request.url!.path.contains("getLocations")
? #"{"locations": [{"name": "Lisboa", "h3Index": "8a2a", "galleryCount": 12}]}"#
: #"{"cameras": [{"camera": "Fujifilm X100V", "photoCount": 40}]}"#
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(body.utf8), response)
}
await vm.loadDiscovery()
XCTAssertEqual(vm.locations.map(\.name), ["Lisboa"])
XCTAssertEqual(vm.locations.first?.id, "8a2a", "Locations are identified by their H3 index")
XCTAssertEqual(vm.cameras.map(\.camera), ["Fujifilm X100V"])
XCTAssertEqual(vm.cameras.first?.id, "Fujifilm X100V")
}
/// Discovery is decoration on the search tab; failing it must leave the tab
/// usable rather than half-populated.
func testAFailedDiscoveryLeavesBothListsEmpty() async {
MockURLProtocol.respondWithError(statusCode: 500)
await vm.loadDiscovery()
XCTAssertTrue(vm.locations.isEmpty)
XCTAssertTrue(vm.cameras.isEmpty)
}
/// Cameras are fetched after locations, so a failure on the second call
/// must not silently keep the first call's results either — they're set
/// together or not at all.
func testCamerasFailingLeavesLocationsUnset() async {
MockURLProtocol.handler = { request in
guard request.url!.path.contains("getLocations") else {
throw URLError(.timedOut)
}
let body = #"{"locations": [{"name": "Lisboa", "h3Index": "8a2a", "galleryCount": 12}]}"#
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(body.utf8), response)
}
await vm.loadDiscovery()
XCTAssertTrue(vm.locations.isEmpty)
XCTAssertTrue(vm.cameras.isEmpty)
}
}
/// Counts the mock's calls from the URL loading thread.
private final class CallCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
func next() -> Int {
lock.withLock {
count += 1
return count
}
}
}