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
@testable import Grain
import SwiftUI
import XCTest
/// The search tab once something has actually been searched for. Everything
/// below the field — the gallery cards, the profile rows, the empty state per
/// tab — only appears when the view model has a query in it, so the results
/// half of the screen is unreachable from a plain render.
@MainActor
final class SearchResultsRenderTests: GrainTestCase {
private var account: TestAccount!
/// Async overrides: the synchronous `setUp`/`tearDown` are nonisolated, so
/// touching main-actor state from them warns.
override func setUp() async throws {
try await super.setUp()
account = TestAccount()
MockURLProtocol.respondByPath(Fixtures.routes)
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
account.restore()
try await super.tearDown()
}
private let settle: TimeInterval = 0.35
private func gallery(_ index: Int, creator: String) -> GrainGallery {
GrainGallery(
uri: "at://\(creator)/social.grain.gallery/\(index)",
cid: "bafyg\(index)",
title: "Result \(index)",
description: "A found gallery with a #hashtag in its description.",
locationDisplay: "Lisboa, Portugal",
creator: GrainProfile(
cid: "c\(index)",
did: creator,
handle: "\(creator).test",
displayName: "Someone",
avatar: "https://test.local/a.jpg"
),
items: [GrainPhoto(
uri: "at://\(creator)/social.grain.photo/\(index)",
cid: "bafyp\(index)",
thumb: "https://test.local/thumb.jpg",
fullsize: "https://test.local/full.jpg",
alt: "A found photo",
aspectRatio: AspectRatio(width: 3, height: 2)
)],
favCount: 12,
commentCount: 2,
createdAt: "2025-01-02T00:00:00Z",
indexedAt: "2025-01-02T00:00:00Z"
)
}
private func searched(
_ query: String,
tab: SearchViewModel.SearchTab,
galleries: [GrainGallery] = [],
profiles: [ProfileSearchResult] = [],
env: TestEnvironment
) -> SearchViewModel {
let viewModel = SearchViewModel(client: env.client)
viewModel.searchText = query
viewModel.selectedTab = tab
viewModel.galleryResults = galleries
viewModel.profileResults = profiles
return viewModel
}
private func render(_ viewModel: SearchViewModel, env: TestEnvironment) {
ViewRender.render(
SearchView(client: env.client, viewModel: viewModel).withTestEnvironment(env),
settle: settle
)
}
// MARK: - Gallery results
/// A page of found galleries — each is a full card, with the same actions
/// the feed's cards have.
func testRendersGalleryResults() {
let env = TestEnvironment()
let results = (1 ... 3).map { gallery($0, creator: "did:plc:someone") }
render(searched("film", tab: .galleries, galleries: results, env: env), env: env)
}
/// Your own gallery in the results gets a delete action where someone
/// else's gets report.
func testRendersYourOwnGalleryInTheResults() {
let env = TestEnvironment()
let results = [gallery(1, creator: "did:plc:test"), gallery(2, creator: "did:plc:someone")]
render(searched("film", tab: .galleries, galleries: results, env: env), env: env)
}
/// A query that matched nothing is a different state from one that hasn't
/// been run yet.
func testRendersTheGalleriesTabWithNoMatches() {
let env = TestEnvironment()
render(searched("nothing matches this", tab: .galleries, env: env), env: env)
}
/// Mid-search the tab shows a spinner over whatever was there before.
func testRendersTheGalleriesTabWhileSearching() {
let env = TestEnvironment()
let viewModel = searched("film", tab: .galleries, galleries: [gallery(1, creator: "did:plc:someone")], env: env)
viewModel.isSearching = true
render(viewModel, env: env)
}
// MARK: - Profile results
func testRendersProfileResults() {
let env = TestEnvironment()
let people = [
ProfileSearchResult(
did: "did:plc:alpha", handle: "alpha.test", displayName: "Alpha",
description: "A bio long enough to wrap onto a second line in the row.",
avatar: "https://test.local/a.jpg"
),
ProfileSearchResult(did: "did:plc:beta", handle: "beta.test"),
]
render(searched("al", tab: .profiles, profiles: people, env: env), env: env)
}
func testRendersTheProfilesTabWithNoMatches() {
let env = TestEnvironment()
render(searched("nobody", tab: .profiles, env: env), env: env)
}
/// A profile with a live story gets a ring in the results row.
func testRendersAProfileResultWithAStory() {
let env = TestEnvironment()
env.storyStatus.update(from: [
GrainStoryAuthor(
profile: GrainProfile(cid: "c", did: "did:plc:alpha", handle: "alpha.test"),
storyCount: 2,
latestAt: Fixtures.freshTimestamp
),
])
let people = [ProfileSearchResult(did: "did:plc:alpha", handle: "alpha.test", displayName: "Alpha")]
render(searched("al", tab: .profiles, profiles: people, env: env), env: env)
}
// MARK: - Signed out
/// Signed out the results still render, without the actions that need an
/// account behind them.
func testRendersResultsWhileSignedOut() {
let env = TestEnvironment(authenticated: false)
let results = [gallery(1, creator: "did:plc:someone")]
render(searched("film", tab: .galleries, galleries: results, env: env), env: env)
}
// MARK: - Recent searches
/// With history and no query the tab shows the recents list instead.
func testRendersRecentSearchesWithBothKinds() {
let did = AccountScopedStorage.activeAccountID
let recents = RecentSearchStorage(did: did)
recents.addTextSearch("portra")
recents.addTextSearch("film")
recents.addProfile(did: "did:plc:alpha", displayName: "Alpha", handle: "alpha.test", avatar: nil)
defer { recents.clearAll() }
let env = TestEnvironment()
render(searched("", tab: .galleries, env: env), env: env)
}
/// A fresh account has no history at all, which is the placeholder state.
func testRendersTheEmptySearchPlaceholder() {
let did = AccountScopedStorage.activeAccountID
RecentSearchStorage(did: did).clearAll()
let env = TestEnvironment()
render(searched("", tab: .profiles, env: env), env: env)
}
}