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
@testable import Grain
import SwiftUI
import XCTest
/// The screens you reach from the feed and profile — detail, story, editing and
/// creation flows. Like the other render suites these exist to run the layout
/// code, which is where most of these files' lines are.
@MainActor
final class DetailViewRenderTests: 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()
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
account.restore()
try await super.tearDown()
}
// MARK: - Model builders
private func makeProfile(did: String = "did:plc:test") -> GrainProfile {
GrainProfile(
cid: "bafyprofile",
did: did,
handle: "tester.grain.social",
displayName: "Tester",
avatar: "https://test.local/avatar.jpg"
)
}
private func makeStory(index: Int = 1) -> GrainStory {
GrainStory(
uri: "at://did:plc:test/social.grain.story/\(index)",
cid: "bafystory\(index)",
creator: makeProfile(),
thumb: "https://test.local/thumb.jpg",
fullsize: "https://test.local/full.jpg",
aspectRatio: AspectRatio(width: 3, height: 4),
createdAt: "2025-01-03T00:00:00Z"
)
}
private func makeAuthor() -> GrainStoryAuthor {
GrainStoryAuthor(profile: makeProfile(), storyCount: 2, latestAt: "2025-01-03T00:00:00Z")
}
private func makeComment(index: Int = 1, replyTo: String? = nil) -> GrainComment {
GrainComment(
uri: "at://did:plc:test/social.grain.comment/\(index)",
cid: "bafycomment\(index)",
author: makeProfile(),
text: "A test comment that is long enough to wrap onto a second line.",
replyTo: replyTo,
createdAt: "2025-01-03T00:00:00Z",
favCount: 1
)
}
// MARK: - Story viewer
func testRendersStoryViewer() {
MockURLProtocol.respondWithJSON(#"{"stories": []}"#)
let env = TestEnvironment()
ViewRender.render(
StoryViewer(
authors: [makeAuthor()],
startAuthorDid: "did:plc:test",
initialStories: [makeStory(index: 1), makeStory(index: 2)],
startStoryIndex: 0,
client: env.client
)
.withTestEnvironment(env),
settle: 0.2
)
}
func testRendersStoryViewerWithoutPreloadedStories() {
MockURLProtocol.respondWithJSON(#"{"stories": []}"#)
let env = TestEnvironment()
ViewRender.render(
StoryViewer(authors: [makeAuthor()], client: env.client)
.withTestEnvironment(env),
settle: 0.2
)
}
// MARK: - Gallery detail
func testRendersGalleryDetail() {
MockURLProtocol.respondWithJSON(Fixtures.gallery)
let env = TestEnvironment()
ViewRender.render(
GalleryDetailView(
client: env.client,
galleryUri: "at://did:plc:test/social.grain.gallery/1"
)
.withTestEnvironment(env)
)
}
func testRendersGalleryDetailWhenItFailsToLoad() {
MockURLProtocol.respondWithError(statusCode: 404)
let env = TestEnvironment()
ViewRender.render(
GalleryDetailView(
client: env.client,
galleryUri: "at://did:plc:test/social.grain.gallery/missing"
)
.withTestEnvironment(env)
)
}
// MARK: - Comments
func testRendersCommentSheet() {
let env = TestEnvironment()
let comments = [
makeComment(index: 1),
makeComment(index: 2, replyTo: "at://did:plc:test/social.grain.comment/1"),
]
ViewRender.render(
CommentSheetContent(
comments: comments,
isLoading: false,
isPostingComment: false,
client: env.client,
onPost: { _, _ in },
onDelete: { _ in }
)
.withTestEnvironment(env)
)
}
func testRendersCommentSheetWhileLoading() {
let env = TestEnvironment()
ViewRender.render(
CommentSheetContent(
comments: [],
isLoading: true,
isPostingComment: false,
client: env.client,
onPost: { _, _ in },
onDelete: { _ in }
)
.withTestEnvironment(env)
)
}
// MARK: - Profile editing
func testRendersEditProfile() {
MockURLProtocol.respondWithJSON(Fixtures.profile)
let env = TestEnvironment()
ViewRender.render(EditProfileView(client: env.client).withTestEnvironment(env))
}
// MARK: - Location feed
func testRendersLocationFeed() {
MockURLProtocol.respondWithJSON(Fixtures.feed)
let env = TestEnvironment()
ViewRender.render(
LocationFeedView(
client: env.client,
h3Index: "8a2a1072b59ffff",
locationName: "Test City"
)
.withTestEnvironment(env)
)
}
// MARK: - Creation flow
func testRendersCreateGallery() {
MockURLProtocol.respondWithJSON("{}")
let env = TestEnvironment()
ViewRender.render(CreateGalleryView(client: env.client).withTestEnvironment(env))
}
/// The editor is driven entirely by bindings, so it renders without a
/// network at all — one pass per mode covers the three layouts.
func testRendersGalleryEditorInEachMode() throws {
let env = TestEnvironment()
let image = try XCTUnwrap(UIImage(systemName: "photo"))
let items = [
PhotoItem(thumbnail: image, carouselPreview: image, source: .camera(image, metadata: nil)),
PhotoItem(thumbnail: image, carouselPreview: image, source: .camera(image, metadata: nil)),
]
for mode in EditorMode.allCases {
var boundItems = items
var selected: UUID? = items.first?.id
var isReordering = mode == .reorder
var isAnimating = false
var boundMode = mode
ViewRender.render(
GalleryEditor(
items: Binding(get: { boundItems }, set: { boundItems = $0 }),
selectedPhotoID: Binding(get: { selected }, set: { selected = $0 }),
isReordering: Binding(get: { isReordering }, set: { isReordering = $0 }),
isAnimatingMode: Binding(get: { isAnimating }, set: { isAnimating = $0 }),
mode: Binding(get: { boundMode }, set: { boundMode = $0 }),
sendExif: true
)
.withTestEnvironment(env)
)
}
}
}