forked from MacMagazine/app-iOS
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPodcastViewModelTests.swift
More file actions
360 lines (299 loc) · 12 KB
/
PodcastViewModelTests.swift
File metadata and controls
360 lines (299 loc) · 12 KB
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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
import FeedLibrary
import Foundation
import NetworkLibrary
@testable import PodcastLibrary
import StorageLibrary
import Testing
import UIComponentsLibrary
@Suite("PodcastViewModel Tests")
@MainActor
struct PodcastViewModelTests {
// MARK: - Pagination Logic Tests
@Test("Should not load more when index is 0")
func noLoadAtIndexZero() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When
sut.loadMoreIfNeeded(index: 0)
// Then - No network call should be made (status should remain idle)
#expect(sut.status == .idle, "Should not load when index is 0")
}
@Test("Should not load more when index is less than threshold")
func noLoadBelowThreshold() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When - Try indices below threshold (16)
for index in 1..<16 {
sut.loadMoreIfNeeded(index: index)
}
// Then
#expect(sut.status == .idle, "Should not load when below threshold of 16")
}
@Test("Should load page 2 when reaching first threshold (index 16)")
func loadPage2AtThreshold() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When
sut.loadMoreIfNeeded(index: 16)
// Wait up to 2s for status to change from idle to any other state to avoid timing flakiness on CI
let changed = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
// Then - Page 2 should be requested (16 / 16 + 1 = 2)
// Status will transition to .done or .error depending on mock
#expect(changed, "Should trigger load at threshold index 16")
}
@Test("Should load page 3 when reaching second threshold (index 32)")
func loadPage3AtSecondThreshold() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When - Simulate scrolling past first threshold
sut.loadMoreIfNeeded(index: 16) // First threshold, loads page 2
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
sut.loadMoreIfNeeded(index: 32) // Second threshold, should load page 3
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
// Then
#expect(sut.status != .idle, "Should trigger load at second threshold index 32")
}
@Test("Should only trigger load at exact multiples of threshold")
func onlyLoadAtExactMultiples() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When - Try indices around threshold
sut.loadMoreIfNeeded(index: 15) // Below threshold
#expect(sut.status == .idle)
sut.loadMoreIfNeeded(index: 17) // Above threshold but not multiple
#expect(sut.status == .idle)
sut.loadMoreIfNeeded(index: 16) // Exact multiple
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
// Then
#expect(sut.status != .idle, "Should only load at exact multiples of 16")
}
@Test("Should not reload same page when scrolling back and forth")
func noReloadSamePage() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When - Load page 2
sut.loadMoreIfNeeded(index: 16)
try await Task.sleep(for: .milliseconds(100))
let firstStatus = sut.status
// Scroll back to earlier index
sut.loadMoreIfNeeded(index: 15)
sut.loadMoreIfNeeded(index: 14)
// Scroll forward again to same threshold
sut.loadMoreIfNeeded(index: 16)
// Then - Should not trigger another load
#expect(sut.status == firstStatus, "Should not reload when returning to same index")
}
@Test("Should calculate correct page numbers for various thresholds")
func correctPageCalculation() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// Test page calculation logic: page = Int(index / threshold) + 1
// Index 16, threshold 16: page = 16/16 + 1 = 2
// Index 32, threshold 16: page = 32/16 + 1 = 3
// Index 48, threshold 16: page = 48/16 + 1 = 4
// When/Then - Index 16 → Page 2
sut.loadMoreIfNeeded(index: 16)
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
#expect(sut.status != .idle)
// When/Then - Index 32 → Page 3
sut.loadMoreIfNeeded(index: 32)
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
#expect(sut.status != .idle)
// When/Then - Index 48 → Page 4
sut.loadMoreIfNeeded(index: 48)
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
#expect(sut.status != .idle)
}
@Test("Should only load when index increases beyond last loaded index")
func onlyLoadWhenIndexIncreases() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When - Load at index 16
sut.loadMoreIfNeeded(index: 16)
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
// Try loading at same index again
sut.loadMoreIfNeeded(index: 16)
let statusAfterSameIndex = sut.status
// Try loading at lower index
sut.loadMoreIfNeeded(index: 12)
let statusAfterLowerIndex = sut.status
// Then - Status should not change
#expect(statusAfterSameIndex == statusAfterLowerIndex,
"Should not load when index doesn't increase")
}
@Test("Should handle rapid scrolling correctly")
func handleRapidScrolling() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When - Rapidly scroll through multiple thresholds
sut.loadMoreIfNeeded(index: 16) // Page 2
sut.loadMoreIfNeeded(index: 32) // Page 3
sut.loadMoreIfNeeded(index: 48) // Page 4
_ = await waitForStatusChange(
from: .idle,
of: sut,
timeout: .seconds(2),
poll: .milliseconds(20)
)
// Then - Should handle all loads without crashes
#expect(sut.status != .idle, "Should handle rapid scrolling")
}
// MARK: - Status Tests
@Test("Should start with idle status")
func initialStatusIsIdle() async throws {
// Given/When
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let sut = PodcastViewModel(storage: storage, mapper: [])
// Then
#expect(sut.status == .idle)
}
@Test("Should update status to done on successful fetch")
func statusUpdatesOnSuccess() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When
try await sut.getPodcasts(status: .loading, page: 1)
// Then
#expect(sut.status == .done, "Status should be done after successful fetch")
}
@Test("Should update status to error on failed fetch")
func statusUpdatesOnError() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let failedNetwork = [NetworkMockData]() // Empty will cause parsing error
let sut = PodcastViewModel(storage: storage, mapper: failedNetwork)
// When
do {
try await sut.getPodcasts(status: .loading, page: 1)
} catch {
Issue.record("Not expected error")
}
// Then
if case .done = sut.status {
#expect(storage.count(PodcastDB.self) == 0)
} else {
Issue.record("Expected done")
}
}
@Test("Should preserve custom status when provided")
func preserveCustomStatus() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let mockNetwork = createMockNetwork()
let sut = PodcastViewModel(storage: storage, mapper: mockNetwork)
// When
try await sut.getPodcasts(status: .loading, page: 1)
// Then - Status should eventually be .done after loading
#expect(sut.status == .done)
}
// MARK: - Options Tests
@Test("Should start with home option")
func initialOptionIsHome() async throws {
// Given/When
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let sut = PodcastViewModel(storage: storage, mapper: [])
// Then
#expect(sut.options == .home)
}
@Test("Should support search option")
func supportsSearchOption() async throws {
// Given
let storage = Database(models: [FeedDB.self, PodcastDB.self], inMemory: true)
let sut = PodcastViewModel(storage: storage, mapper: [])
// When
sut.options = .search(text: "test query")
// Then
if case .search(let text) = sut.options {
#expect(text == "test query")
} else {
Issue.record("Expected search option")
}
}
}
extension PodcastViewModelTests {
private func waitForStatusChange(
from initial: APIStatus,
of sut: PodcastViewModel,
timeout: Duration = .seconds(2),
poll: Duration = .milliseconds(20)
) async -> Bool {
let deadline = ContinuousClock.now.advanced(by: timeout)
while ContinuousClock.now < deadline {
if sut.status != initial { return true }
try? await Task.sleep(for: poll)
}
return false
}
}
// MARK: - Test Helpers
private func createMockNetwork() -> [NetworkMockData] {
// Create mock network that returns valid podcast data
// In a real scenario, you'd have a JSON fixture file
[
NetworkMockData(
api: "/podcasts.xml",
filename: "podcasts",
bundlePath: Bundle.module.resourcePath
)
]
}