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
@testable import Grain
import SwiftUI
import XCTest
/// What advances a story. Five seconds per story, ticking twenty times a
/// second, with a pause that has to resume where it left off rather than start
/// over — holding a finger down mid-story and letting go shouldn't rewind it.
@MainActor
final class StoryTimerTests: GrainTestCase {
/// A story that runs in a fifth of a second rather than five. The ticker
/// works in fractions of the duration, so every assertion below holds at
/// either speed — it just isn't worth a minute of the suite's time to watch
/// real stories play out.
private func makeTimer(duration: TimeInterval = 0.2) -> StoryTimer {
StoryTimer(duration: duration)
}
/// Pump the run loop so the timer's task gets to tick.
private func advance(_ seconds: TimeInterval) {
RunLoop.current.run(until: Date().addingTimeInterval(seconds))
}
private func waitUntil(_ condition: () -> Bool, timeout: TimeInterval = 5) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
advance(0.01)
}
return condition()
}
func testATimerStartsIdleAtZero() {
let timer = makeTimer()
XCTAssertEqual(timer.progress, 0)
XCTAssertFalse(timer.isRunning)
}
func testStartingRunsTheProgressForward() {
let timer = makeTimer(duration: 1)
defer { timer.stop() }
timer.start()
XCTAssertTrue(timer.isRunning)
XCTAssertTrue(waitUntil { timer.progress > 0.1 }, "The bar never moved")
}
/// The story advances when the bar fills, so this is what pages the viewer.
func testItReportsCompletionWhenTheBarFills() {
let timer = makeTimer()
defer { timer.stop() }
var completed = false
timer.onComplete = { completed = true }
timer.start()
XCTAssertTrue(waitUntil({ completed }, timeout: 30), "The story never finished")
XCTAssertEqual(timer.progress, 1, accuracy: 0.01)
XCTAssertFalse(timer.isRunning)
}
/// Fires once, early, so a story counts as viewed almost immediately rather
/// than only if you sit through all five seconds.
func testItReportsTheViewOnceShortlyAfterStarting() {
let timer = makeTimer()
defer { timer.stop() }
var quarterCount = 0
timer.onQuarter = { quarterCount += 1 }
timer.start()
XCTAssertTrue(waitUntil { quarterCount > 0 }, "The story was never marked viewed")
_ = waitUntil({ timer.progress > 0.5 }, timeout: 5)
XCTAssertEqual(quarterCount, 1, "Marking viewed more than once would re-post the read receipt")
}
/// Holding a finger on a story pauses it; letting go resumes from where it
/// stopped, not from the beginning.
func testResumingPicksUpWhereItStopped() {
let timer = makeTimer(duration: 1)
defer { timer.stop() }
timer.start()
XCTAssertTrue(waitUntil { timer.progress > 0.2 })
timer.stop()
let paused = timer.progress
XCTAssertFalse(timer.isRunning)
timer.resume()
XCTAssertTrue(timer.isRunning)
XCTAssertGreaterThanOrEqual(timer.progress, paused - 0.01, "Resuming rewound the story")
}
/// Touch-down and touch-up can arrive in a burst; resuming something that's
/// already running must not start a second ticker.
func testResumingSomethingAlreadyRunningIsANoOp() {
// A longer story and a generous timeout: this one waits for a real
// completion rather than a state flip, so it is the one test here that
// a loaded machine can starve.
let timer = makeTimer(duration: 0.5)
defer { timer.stop() }
var completions = 0
timer.onComplete = { completions += 1 }
timer.start()
timer.resume()
timer.resume()
XCTAssertTrue(waitUntil({ completions > 0 }, timeout: 30), "The story never finished")
advance(0.3)
XCTAssertEqual(completions, 1, "A second ticker would advance the story twice")
}
/// Resuming a story that already ran out restarts it rather than sitting
/// finished — that's what re-opening a watched story does.
func testResumingAFinishedStoryStartsItOver() {
let timer = makeTimer()
defer { timer.stop() }
timer.progress = 1
timer.resume()
XCTAssertTrue(timer.isRunning)
XCTAssertLessThan(timer.progress, 1)
}
/// Paging away has to stop the outgoing story, or two stories advance at once.
func testStoppingHaltsProgressAndReportsNothing() {
let timer = makeTimer(duration: 1)
var completed = false
timer.onComplete = { completed = true }
timer.start()
XCTAssertTrue(waitUntil { timer.progress > 0.1 })
timer.stop()
let stopped = timer.progress
advance(0.3)
XCTAssertFalse(timer.isRunning)
XCTAssertEqual(timer.progress, stopped, accuracy: 0.001, "The bar kept moving after being stopped")
XCTAssertFalse(completed)
}
func testStoppingSomethingThatNeverStartedIsHarmless() {
let timer = makeTimer()
timer.stop()
timer.stop()
XCTAssertFalse(timer.isRunning)
XCTAssertEqual(timer.progress, 0)
}
/// Paging back to a story restarts its bar from zero.
func testStartingAgainResetsTheBar() {
let timer = makeTimer(duration: 1)
defer { timer.stop() }
timer.start()
XCTAssertTrue(waitUntil { timer.progress > 0.2 })
timer.start()
XCTAssertLessThan(timer.progress, 0.2)
}
/// The view marks a story viewed off `onQuarter`, so a restart has to arm
/// it again for the next story.
func testRestartingArmsTheViewReportAgain() {
let timer = makeTimer()
defer { timer.stop() }
var quarterCount = 0
timer.onQuarter = { quarterCount += 1 }
timer.start()
XCTAssertTrue(waitUntil { quarterCount == 1 })
timer.start()
XCTAssertTrue(waitUntil { quarterCount == 2 }, "The next story would never be marked viewed")
}
// MARK: - Progress bars
/// One bar per story, filled up to the one playing.
func testRendersTheProgressBars() {
let timer = makeTimer()
defer { timer.stop() }
let stories = (0 ..< 4).map { index in
GrainStory(
uri: "at://did:plc:test/social.grain.story/\(index)",
cid: "bafy\(index)",
creator: GrainProfile(cid: "c", did: "did:plc:test", handle: "tester.test"),
thumb: "https://test.local/t.jpg",
fullsize: "https://test.local/f.jpg",
aspectRatio: AspectRatio(width: 3, height: 4),
createdAt: Fixtures.freshTimestamp
)
}
for index in [0, 2, 3] {
ViewRender.render(
StoryProgressBars(timer: timer, stories: stories, currentStoryIndex: index),
settle: 0
)
}
}
/// Before the stories land the bars are drawn from the author's count, so
/// the header doesn't pop in a beat later than everything else.
func testRendersPlaceholderBarsBeforeTheStoriesArrive() {
let timer = makeTimer()
defer { timer.stop() }
ViewRender.render(
StoryProgressBars(timer: timer, stories: [], currentStoryIndex: 0, placeholderCount: 3),
settle: 0
)
}
/// Nothing known at all — no stories and no count — still has to lay out.
func testRendersNoBarsWithNothingToShow() {
let timer = makeTimer()
defer { timer.stop() }
ViewRender.render(
StoryProgressBars(timer: timer, stories: [], currentStoryIndex: 0),
settle: 0
)
}
}