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
240
241
242
243
@testable import Grain
import SwiftUI
import XCTest
/// The create flow's chrome: the editor that morphs between three layouts, and
/// the publish overlay that covers the sheet while a gallery goes out. Both are
/// driven entirely by bindings and values, so they can be put into every state
/// they have without a picker or a network.
@MainActor
final class CreateFlowRenderTests: 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: - Items
private func makeItems(_ count: Int, withExif: Bool = false) -> [PhotoItem] {
let exif = ExifSummary(
camera: "Fujifilm X100V",
lens: "23mm f/2",
exposure: nil,
shutterSpeed: "1/500s",
iso: "ISO 400",
focalLength: "35mm",
aperture: "f/2"
)
return (0 ..< count).map { index in
// Alternate portrait and landscape so both mask-fill branches run.
let size = index.isMultiple(of: 2)
? CGSize(width: 90, height: 60)
: CGSize(width: 60, height: 90)
let image = UIGraphicsImageRenderer(size: size).image { context in
context.cgContext.setFillColor(red: 0.2, green: 0.5, blue: 0.8, alpha: 1)
context.cgContext.fill(CGRect(origin: .zero, size: size))
}
return PhotoItem(
thumbnail: image,
carouselPreview: image,
source: .camera(image, metadata: nil),
alt: index.isMultiple(of: 3) ? "Alt text for photo \(index)" : "",
exifSummary: withExif && index.isMultiple(of: 2) ? exif : nil
)
}
}
private func renderEditor(
items: [PhotoItem],
mode: EditorMode,
selected: UUID?,
isReordering: Bool = false,
sendExif: Bool = true,
onDelete: ((PhotoItem) -> Void)? = nil,
settle: TimeInterval = 0.05
) {
var boundItems = items
var boundSelected = selected
var boundReordering = isReordering
var boundAnimating = false
var boundMode = mode
ViewRender.render(
GalleryEditor(
items: Binding(get: { boundItems }, set: { boundItems = $0 }),
selectedPhotoID: Binding(get: { boundSelected }, set: { boundSelected = $0 }),
isReordering: Binding(get: { boundReordering }, set: { boundReordering = $0 }),
isAnimatingMode: Binding(get: { boundAnimating }, set: { boundAnimating = $0 }),
mode: Binding(get: { boundMode }, set: { boundMode = $0 }),
sendExif: sendExif,
onDeleteItem: onDelete
)
.withTestEnvironment(TestEnvironment()),
settle: settle
)
}
// MARK: - Gallery editor
/// Enough photos that the strip overflows and the grid needs three rows,
/// which is the shape the editor is actually used in.
func testRendersTheEditorInEveryModeWithAFullGallery() {
let items = makeItems(8, withExif: true)
for mode in EditorMode.allCases {
renderEditor(items: items, mode: mode, selected: items.first?.id)
}
}
/// Nothing selected is the state the editor opens in before a tap.
func testRendersTheEditorWithNothingSelected() {
for mode in EditorMode.allCases {
renderEditor(items: makeItems(4), mode: mode, selected: nil)
}
}
/// A selection late in the strip forces it to scroll to bring the cell into
/// view, which is different arithmetic from the first cell.
func testRendersTheEditorWithALateSelection() {
let items = makeItems(9)
renderEditor(items: items, mode: .preview, selected: items.last?.id, settle: 0.2)
renderEditor(items: items, mode: .reorder, selected: items[5].id, settle: 0.2)
}
/// Reordering locks the surrounding scroll views and swaps the cell chrome.
func testRendersTheEditorWhileReordering() {
let items = makeItems(6)
renderEditor(items: items, mode: .reorder, selected: items[1].id, isReordering: true, settle: 0.2)
}
/// With exif off the chips go grey rather than disappearing, so the row
/// doesn't reflow when the toggle moves.
func testRendersTheEditorWithExifTurnedOff() {
let items = makeItems(4, withExif: true)
renderEditor(items: items, mode: .captions, selected: items.first?.id, sendExif: false)
}
/// A single photo has no strip to scroll and no grid to speak of.
func testRendersTheEditorWithASinglePhoto() {
let items = makeItems(1)
for mode in EditorMode.allCases {
renderEditor(items: items, mode: mode, selected: items.first?.id)
}
}
/// The editor is built before the picker returns, so it has to survive
/// being asked to lay out nothing.
func testRendersTheEditorWithNoPhotosYet() {
for mode in EditorMode.allCases {
renderEditor(items: [], mode: mode, selected: nil)
}
}
func testRendersTheEditorWithADeleteHandler() {
let items = makeItems(3)
renderEditor(items: items, mode: .preview, selected: items.first?.id, onDelete: { _ in })
}
// MARK: - Captions prototype
func testRendersTheCaptionsListPrototype() {
ViewRender.render(CaptionsListPrototype(), settle: 0.2)
}
// MARK: - Publish overlay
/// The overlay covers the whole sheet, so every stage has to draw something
/// — an indeterminate spinner, a determinate bar, or the failure.
func testRendersThePublishOverlayForEveryStage() {
let stages: [GalleryUploadCenter.Stage] = [
.idle,
.preparing(completed: 0, total: 8),
.preparing(completed: 4, total: 8),
.uploading(completed: 0, total: 8),
.uploading(completed: 7, total: 8),
.publishing,
.finished,
.failed(message: "The connection dropped. Nothing was lost — trying again picks up where it stopped."),
]
for stage in stages {
ViewRender.render(
GalleryPublishOverlay(stage: stage, onRetry: {}, onPostLater: {}),
settle: 0
)
}
}
/// A zero-photo total would divide by zero in the progress fraction.
func testRendersThePublishOverlayWithNoPhotosToCount() {
ViewRender.render(
GalleryPublishOverlay(stage: .uploading(completed: 0, total: 0), onRetry: {}, onPostLater: {}),
settle: 0
)
}
// MARK: - Pending gallery bar
/// The bar is what "post later" leads to. It shows progress while the
/// resume runs, an error when it fails again, and nothing at all when
/// there's nothing outstanding.
func testRendersThePendingBarForEveryStage() {
let cases: [(GalleryUploadCenter.Stage, Int)] = [
(.uploading(completed: 2, total: 5), 1),
(.publishing, 1),
(.preparing(completed: 1, total: 3), 1),
(.failed(message: "Couldn't finish"), 1),
(.idle, 1),
(.idle, 0),
(.failed(message: "Couldn't finish"), 0),
(.finished, 0),
]
for (stage, pendingCount) in cases {
ViewRender.render(
PendingGalleryBar(stage: stage, pendingCount: pendingCount, onRetry: {}, onDiscard: {}),
settle: 0
)
}
}
/// With nothing pending and nothing in flight the bar must add no chrome —
/// it sits directly above the tab bar.
func testThePendingBarIsEmptyWhenThereIsNothingToSay() {
let empty = UIHostingController(
rootView: PendingGalleryBar(stage: .idle, pendingCount: 0, onRetry: {}, onDiscard: {})
).sizeThatFits(in: CGSize(width: 402, height: 200))
let busy = UIHostingController(
rootView: PendingGalleryBar(
stage: .uploading(completed: 1, total: 3), pendingCount: 1, onRetry: {}, onDiscard: {}
)
).sizeThatFits(in: CGSize(width: 402, height: 200))
XCTAssertLessThan(empty.height, busy.height)
}
// MARK: - Create sheet
/// The sheet before any photo has been picked — the state it opens in.
func testRendersTheCreateGallerySheet() {
MockURLProtocol.respondByPath(Fixtures.routes)
defer { MockURLProtocol.handler = nil }
let env = TestEnvironment()
ViewRender.render(CreateGalleryView(client: env.client).withTestEnvironment(env), settle: 0.3)
}
func testRendersTheStoryCreateSheet() {
MockURLProtocol.respondByPath(Fixtures.routes)
defer { MockURLProtocol.handler = nil }
let env = TestEnvironment()
ViewRender.render(StoryCreateView(client: env.client).withTestEnvironment(env), settle: 0.3)
}
}