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
@testable import Grain
import SwiftUI
import XCTest
/// The cells and chrome inside the create flow, plus the story ring that wraps
/// avatars everywhere else. All are small views with several visual states, and
/// none of them was reachable from the suite.
@MainActor
final class EditorCellRenderTests: 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()
}
private func makeItem(alt: String = "", exif: ExifSummary? = nil) -> PhotoItem {
let image = UIImage(systemName: "photo")!
return PhotoItem(
thumbnail: image,
carouselPreview: image,
source: .camera(image, metadata: nil),
alt: alt,
exifSummary: exif
)
}
private func renderCell(
item: PhotoItem,
mode: EditorMode,
isSelected: Bool = false,
isDragging: Bool = false,
hideDelete: Bool = false,
deleteOpacity: CGFloat = 1,
exifState: ExifState = .absent
) {
var bound = item
ViewRender.render(
PhotoThumbnailCell(
item: Binding(get: { bound }, set: { bound = $0 }),
geometry: CellGeometry(mode: mode, maskSide: 72, photoAspect: bound.naturalAspect),
isSelected: isSelected,
isDragging: isDragging,
hideDelete: hideDelete,
deleteOpacity: deleteOpacity,
exifState: exifState,
onTap: {},
onDelete: {}
),
settle: 0
)
}
// MARK: - PhotoThumbnailCell
func testRendersAThumbnailCellInEveryMode() {
for mode in EditorMode.allCases {
renderCell(item: makeItem(), mode: mode)
}
}
/// Selected, dragging and mid-fade are the states the strip and grid put a
/// cell through, and each draws different chrome.
func testRendersAThumbnailCellInItsInteractionStates() {
renderCell(item: makeItem(), mode: .preview, isSelected: true)
renderCell(item: makeItem(), mode: .reorder, isDragging: true)
renderCell(item: makeItem(), mode: .preview, hideDelete: true)
renderCell(item: makeItem(), mode: .preview, deleteOpacity: 0.4)
}
/// The captions cell shows the alt text it already has, which is a
/// different layout from an empty one prompting for it.
func testRendersACaptionsCellWithAndWithoutAltText() {
renderCell(item: makeItem(alt: "A long alt description that wraps onto a second line."), mode: .captions)
renderCell(item: makeItem(), mode: .captions)
}
func testRendersAThumbnailCellForEachExifState() {
let exif = ExifSummary(
camera: "Fujifilm X100V",
lens: "23mm",
exposure: nil,
shutterSpeed: "1/500s",
iso: "ISO 400",
focalLength: "35mm",
aperture: "f/2"
)
for state in [ExifState.absent, .inactive, .active] {
renderCell(item: makeItem(exif: exif), mode: .captions, exifState: state)
}
}
// MARK: - ExifChip
private func chipSize(_ state: ExifState) -> CGSize {
UIHostingController(rootView: ExifChip(state: state))
.sizeThatFits(in: CGSize(width: 100, height: 100))
}
/// The chip is deliberately nothing at all when there's no exif, so it must
/// not take up the badge's room on a photo that has none.
func testTheExifChipTakesNoRoomWhenThereIsNoExif() {
let absent = chipSize(.absent)
let present = chipSize(.active)
XCTAssertLessThan(absent.width, present.width)
XCTAssertLessThan(absent.height, present.height)
XCTAssertEqual(present.width, 19, accuracy: 0.5)
XCTAssertEqual(present.height, 19, accuracy: 0.5)
}
/// Inactive and active are the same badge at different opacities, so they
/// have to occupy identical space.
func testTheInactiveChipIsTheSameSizeAsTheActiveOne() {
XCTAssertEqual(chipSize(.inactive), chipSize(.active))
}
// MARK: - StoryRingView
func testRendersAStoryRingInEachState() {
for (hasStory, viewed) in [(false, false), (true, false), (true, true)] {
ViewRender.render(
StoryRingView(hasStory: hasStory, viewed: viewed, size: 48) {
AvatarView(url: nil, size: 48)
},
settle: 0
)
}
}
/// The ring is drawn as an overlay that spills outside the avatar, so it
/// must never change the footprint — otherwise every row in the story strip
/// and every avatar in a list would shift as story state loaded in.
func testTheRingNeverChangesTheAvatarsFootprint() {
func ringedSize(size: CGFloat, hasStory: Bool, viewed: Bool = false) -> CGSize {
UIHostingController(
rootView: StoryRingView(hasStory: hasStory, viewed: viewed, size: size) {
Color.clear.frame(width: size, height: size)
}
)
.sizeThatFits(in: CGSize(width: 400, height: 400))
}
for size in [CGFloat(24), 32, 48, 68] {
let plain = ringedSize(size: size, hasStory: false)
XCTAssertEqual(plain.height, size, accuracy: 0.5, "Avatar of \(size) didn't measure as itself")
XCTAssertEqual(ringedSize(size: size, hasStory: true), plain, "An unviewed ring resized the avatar")
XCTAssertEqual(
ringedSize(size: size, hasStory: true, viewed: true), plain,
"A viewed ring resized the avatar"
)
}
}
// MARK: - Expandable description
/// Three nested copies of the text are laid out to decide whether "more"
/// belongs there, and the language check runs in a `.task` alongside.
func testRendersAnExpandableDescription() {
ViewRender.render(ExpandableDescriptionView(text: "Short caption."), settle: 0.2)
ViewRender.render(
ExpandableDescriptionView(
text: String(repeating: "A caption long enough to need truncating. ", count: 8),
onMentionTap: { _ in },
onHashtagTap: { _ in }
),
settle: 0.2
)
}
/// A caption in another language grows a translate affordance, which is the
/// branch the detector exists to drive.
func testRendersADescriptionInAnotherLanguage() {
ViewRender.render(
ExpandableDescriptionView(text: "Fotografia tirada ao pôr do sol sobre a cidade."),
settle: 0.3
)
}
}