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
@testable import Grain
import SwiftUI
import XCTest
/// The pinch-to-zoom overlay and the story viewer's drag-to-dismiss both push
/// their work down into UIKit for smoothness, which leaves a small amount of
/// SwiftUI-side state and a lot of view code that nothing was reaching.
@MainActor
final class ZoomAndDismissTests: GrainTestCase {
// MARK: - ImageZoomState
func testTheZoomStateStartsAtRest() {
let state = ImageZoomState()
XCTAssertFalse(state.showOverlay)
XCTAssertEqual(state.scale, 1)
XCTAssertEqual(state.anchor, .center)
XCTAssertEqual(state.offset, .zero)
XCTAssertEqual(state.sourceFrame, .zero)
XCTAssertNil(state.localImage)
XCTAssertTrue(state.imageURL.isEmpty)
}
// MARK: - ZoomableImage
func testRendersAZoomableImageFromAURL() {
let state = ImageZoomState()
ViewRender.render(
ZoomableImage(url: "https://test.local/full.jpg", thumbURL: "https://test.local/thumb.jpg", aspectRatio: 1.5)
.environment(state)
)
}
func testRendersAZoomableImageFromALocalImage() throws {
let state = ImageZoomState()
let image = try XCTUnwrap(UIImage(systemName: "photo"))
ViewRender.render(
ZoomableImage(localImage: image, aspectRatio: 1, zoomImage: image, onSingleTap: {}, onDoubleTap: { _ in })
.environment(state)
)
}
/// Zoom is optional: a `ZoomableImage` with no state in the environment has
/// to still draw the photo rather than trap on a missing value.
func testAZoomableImageRendersWithNoZoomStateInTheEnvironment() {
ViewRender.render(ZoomableImage(url: "https://test.local/full.jpg", aspectRatio: 1))
}
/// While an image is zoomed the base copy is hidden so the overlay isn't
/// drawn twice — that branch only runs when the state points at this image.
func testRendersTheZoomedBranchForTheImageBeingZoomed() {
let state = ImageZoomState()
state.showOverlay = true
state.imageURL = "https://test.local/full.jpg"
state.scale = 2.5
state.anchor = .topLeading
state.offset = CGSize(width: 12, height: -8)
state.aspectRatio = 1.5
state.sourceFrame = CGRect(x: 20, y: 40, width: 300, height: 200)
ViewRender.render(
ZoomableImage(url: "https://test.local/full.jpg", aspectRatio: 1.5).environment(state)
)
}
/// A different image in the same carousel must stay visible while its
/// neighbour is zoomed.
func testAnotherImageStaysVisibleWhileItsNeighbourIsZoomed() {
let state = ImageZoomState()
state.showOverlay = true
state.imageURL = "https://test.local/other.jpg"
ViewRender.render(
ZoomableImage(url: "https://test.local/full.jpg", aspectRatio: 1).environment(state)
)
}
// MARK: - ImageZoomOverlay
func testRendersTheZoomOverlayForBothSources() {
let urlState = ImageZoomState()
urlState.showOverlay = true
urlState.imageURL = "https://test.local/full.jpg"
urlState.aspectRatio = 1.5
urlState.sourceFrame = CGRect(x: 0, y: 0, width: 300, height: 200)
ViewRender.render(Color.black.modifier(ImageZoomOverlay(zoomState: urlState)))
let localState = ImageZoomState()
localState.showOverlay = true
localState.localImage = UIImage(systemName: "photo")
localState.aspectRatio = 1
localState.sourceFrame = CGRect(x: 0, y: 0, width: 200, height: 200)
ViewRender.render(Color.black.modifier(ImageZoomOverlay(zoomState: localState)))
}
/// With nothing zoomed the overlay must add no chrome at all — it sits
/// above every scroll view in the app.
func testTheZoomOverlayDrawsNothingWhenIdle() {
ViewRender.render(Color.black.modifier(ImageZoomOverlay(zoomState: ImageZoomState())), settle: 0)
}
// MARK: - Drag to dismiss
/// The handle is held by StoryViewer so it can fade out programmatically
/// when the last story ends. Before anything installs it there's no view to
/// animate, and it has to fall straight through to the dismiss callback.
func testTheDismissHandleFallsThroughWhenNothingIsInstalled() {
let handle = FadeDismissHandle()
handle.fadeDismiss()
// No view, no callback wired: the point is that it doesn't trap.
XCTAssertNotNil(handle)
}
func testRendersTheDragToDismissInstaller() {
ViewRender.render(
Color.black.background {
DragToDismissInstaller(
handle: FadeDismissHandle(),
onDismiss: {},
onDragStart: {},
onDragCancel: {},
onSwipeLeft: {},
onSwipeRight: {}
)
},
settle: 0
)
}
// MARK: - Reorder lockers
/// Both lockers walk the responder chain to find something to disable, and
/// have to be harmless when there's nothing there.
func testTheReorderLockersRenderInsideAndOutsideAScrollView() {
for disabled in [true, false] {
ViewRender.render(
ScrollView {
Color.gray.frame(height: 200).background { ScrollPanLocker(isDisabled: disabled) }
},
settle: 0
)
ViewRender.render(Color.gray.background { ScrollPanLocker(isDisabled: disabled) }, settle: 0)
ViewRender.render(
NavigationStack {
Color.gray.background { InteractivePopLocker(isDisabled: disabled) }
},
settle: 0
)
}
}
// MARK: - Reorderable thumbnail
func testRendersTheReorderableThumbnailModifier() {
ViewRender.render(Color.teal.frame(width: 100, height: 100).reorderableThumbnail(isSelected: true), settle: 0)
ViewRender.render(
Color.teal.frame(width: 100, height: 100).reorderableThumbnail(isSelected: false, cornerRadius: 0),
settle: 0
)
}
// MARK: - Custom full screen cover
func testRendersTheCustomFullScreenCoverInBothStates() {
for presented in [true, false] {
var isPresented = presented
ViewRender.render(
Color.black.customFullScreenCover(
isPresented: Binding(get: { isPresented }, set: { isPresented = $0 })
) {
Text("Covered")
},
settle: 0
)
}
}
}