krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: HutchTests/ContributionCalendarTests.swift · raw
1import Foundation
2import Testing
3@testable import Hutch
4
5struct ContributionCalendarTests {
6
7 @Test
8 @MainActor
9 func contributionCalendarDecodingParsesDatesAndCounts() throws {
10 let data = Data(
11 """
12 {
13 "actor": "~ccleberg",
14 "from": "2026-03-01",
15 "to": "2026-04-15",
16 "is_indexed": false,
17 "last_polled_at": null,
18 "indexing_state": "pending",
19 "days": [
20 { "date": "2026-03-19", "count": 24, "score": 24.0 },
21 { "date": "2026-04-02", "count": 20, "score": 18.5 }
22 ]
23 }
24 """.utf8
25 )
26
27 let decoded = try JSONDecoder().decode(ContributionCalendarResponse.self, from: data)
28
29 #expect(decoded.actor == "~ccleberg")
30 #expect(decoded.from == ContributionDateParser.parse("2026-03-01"))
31 #expect(decoded.to == ContributionDateParser.parse("2026-04-15"))
32 #expect(decoded.isIndexed == false)
33 #expect(decoded.lastPolledAt == nil)
34 #expect(decoded.indexingState == .pending)
35 #expect(decoded.days.count == 2)
36 #expect(decoded.days[0].date == ContributionDateParser.parse("2026-03-19"))
37 #expect(decoded.days[0].count == 24)
38 #expect(decoded.days[1].score == 18.5)
39 }
40
41 @Test
42 @MainActor
43 func contributionStatsDecodingParsesSnakeCasePayload() throws {
44 let data = Data(
45 """
46 {
47 "actor": "~ccleberg",
48 "from": "2026-03-01",
49 "to": "2026-04-15",
50 "is_indexed": true,
51 "last_polled_at": "2026-04-15T15:42:18Z",
52 "indexing_state": "indexed",
53 "total_events": 126,
54 "total_score": 116.75,
55 "active_days": 14,
56 "longest_streak": 5,
57 "current_streak": 0
58 }
59 """.utf8
60 )
61
62 let decoded = try JSONDecoder().decode(ContributionStatsResponse.self, from: data)
63
64 #expect(decoded.totalEvents == 126)
65 #expect(decoded.totalScore == 116.75)
66 #expect(decoded.activeDays == 14)
67 #expect(decoded.longestStreak == 5)
68 #expect(decoded.currentStreak == 0)
69 #expect(decoded.isIndexed)
70 #expect(decoded.indexingState == .indexed)
71 #expect(decoded.lastPolledAt == ContributionDateParser.parseTimestamp("2026-04-15T15:42:18Z"))
72 }
73
74 @Test
75 func contributionDateParserRejectsInvalidDate() {
76 #expect(ContributionDateParser.parse("2026-13-40") == nil)
77 }
78
79 @Test
80 func contributionTimestampParserHandlesFractionalSecondsWithoutTimezone() {
81 let parsed = ContributionDateParser.parseTimestamp("2026-04-11T19:26:45.538015")
82
83 #expect(parsed != nil)
84 }
85
86 @Test
87 func trailingRangeEndsTodayAndStartsOneYearEarlierPlusOneDay() {
88 let service = HutchStatsService(configuration: AppConfiguration())
89 let endDate = ContributionDateParser.parse("2026-04-11")!
90 let range = service.trailingRange(endingOn: endDate)
91
92 #expect(range.lowerBound == ContributionDateParser.parse("2025-04-12"))
93 #expect(range.upperBound == endDate)
94 }
95
96 @Test
97 func selfContributionRequestsIncludeExplicitPrioritySignal() {
98 let service = HutchStatsService(
99 configuration: AppConfiguration(),
100 currentActor: "~alice"
101 )
102 let endDate = ContributionDateParser.parse("2026-04-11")!
103 let queryItems = service.contributionQueryItems(actor: "~alice", endingOn: endDate)
104
105 #expect(queryItems.contains(URLQueryItem(name: "prioritize", value: "self")))
106 }
107
108 @Test
109 func otherContributionRequestsDoNotIncludePrioritySignal() {
110 let service = HutchStatsService(
111 configuration: AppConfiguration(),
112 currentActor: "~alice"
113 )
114 let endDate = ContributionDateParser.parse("2026-04-11")!
115 let queryItems = service.contributionQueryItems(actor: "~bob", endingOn: endDate)
116
117 #expect(queryItems.contains(URLQueryItem(name: "prioritize", value: "self")) == false)
118 }
119
120 @Test
121 func contributionIntensityBucketsMatchProductRules() {
122 #expect(ContributionIntensity(count: 0) == .empty)
123 #expect(ContributionIntensity(count: 1) == .level1)
124 #expect(ContributionIntensity(count: 2) == .level2)
125 #expect(ContributionIntensity(count: 3) == .level2)
126 #expect(ContributionIntensity(count: 4) == .level3)
127 #expect(ContributionIntensity(count: 6) == .level3)
128 #expect(ContributionIntensity(count: 7) == .level4)
129 #expect(ContributionIntensity(count: 12) == .level4)
130 }
131
132 @Test
133 func weekColumnsGroupConsecutiveDaysIntoSundayBasedWeeks() {
134 let days = [
135 ContributionDay(date: ContributionDateParser.parse("2026-03-29")!, count: 1, score: 1),
136 ContributionDay(date: ContributionDateParser.parse("2026-03-30")!, count: 2, score: 2),
137 ContributionDay(date: ContributionDateParser.parse("2026-04-04")!, count: 3, score: 3),
138 ContributionDay(date: ContributionDateParser.parse("2026-04-05")!, count: 4, score: 4)
139 ]
140
141 let weeks = ContributionCalendarLayout.weekColumns(from: days)
142
143 #expect(weeks.count == 2)
144 #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-29"))
145 #expect(weeks[0].days.map(\.count) == [1, 2, 3])
146 #expect(weeks[1].startDate == ContributionDateParser.parse("2026-04-05"))
147 #expect(weeks[1].days.map(\.count) == [4])
148 }
149
150 @Test
151 func recentWeeksReturnsTrailingWindow() {
152 let days = (0..<21).compactMap { offset -> ContributionDay? in
153 guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: offset, to: ContributionDateParser.parse("2026-03-01")!) else {
154 return nil
155 }
156
157 return ContributionDay(date: date, count: offset, score: Double(offset))
158 }
159
160 let weeks = ContributionCalendarLayout.recentWeeks(from: days, count: 2)
161
162 #expect(weeks.count == 2)
163 #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-08"))
164 #expect(weeks[1].startDate == ContributionDateParser.parse("2026-03-15"))
165 }
166
167 @Test
168 @MainActor
169 func emptyCalendarResponseTreatsActivityAsNotIndexedYet() async {
170 let service = MockContributionCalendarService(
171 calendarResponses: [.pending(actor: "~alice", year: 2026)],
172 statsResponses: [.pending(actor: "~alice", year: 2026)]
173 )
174 let viewModel = ContributionCalendarViewModel(
175 actor: "~alice",
176 service: service,
177 selectedEndDate: ContributionDateParser.parse("2026-04-11")
178 )
179
180 await viewModel.load()
181
182 #expect(viewModel.displayState == .indexing)
183 #expect(viewModel.emptyStateTitle == "Indexing Activity")
184 #expect(viewModel.emptyStateMessage == "This user’s SourceHut activity is being indexed. Check back soon.")
185 #expect(viewModel.loadErrorMessage == nil)
186 }
187
188 @Test
189 @MainActor
190 func indexedEmptyCalendarShowsTrueEmptyState() async {
191 let service = MockContributionCalendarService(
192 calendarResponses: [.empty(actor: "~alice", year: 2026)],
193 statsResponses: [.empty(actor: "~alice", year: 2026)]
194 )
195 let viewModel = ContributionCalendarViewModel(
196 actor: "~alice",
197 service: service,
198 selectedEndDate: ContributionDateParser.parse("2026-04-11")
199 )
200
201 await viewModel.load()
202
203 #expect(viewModel.displayState == .empty)
204 #expect(viewModel.emptyStateTitle == "No Contribution Activity")
205 #expect(viewModel.emptyStateMessage == "No activity was found for this time range.")
206 }
207
208 @Test
209 @MainActor
210 func errorIndexingStateShowsUnavailableState() async {
211 let service = MockContributionCalendarService(
212 calendarResponses: [.error(actor: "~alice", year: 2026)],
213 statsResponses: [.error(actor: "~alice", year: 2026)]
214 )
215 let viewModel = ContributionCalendarViewModel(
216 actor: "~alice",
217 service: service,
218 selectedEndDate: ContributionDateParser.parse("2026-04-11")
219 )
220
221 await viewModel.load()
222
223 #expect(viewModel.displayState == .unavailable)
224 #expect(viewModel.emptyStateTitle == "Activity Unavailable")
225 #expect(viewModel.emptyStateMessage == "The contribution graph couldn’t be refreshed right now. Try again later.")
226 }
227
228 @Test
229 @MainActor
230 func populatedStatsExposeLastUpdatedText() async {
231 let service = MockContributionCalendarService(
232 calendarResponses: [.active(actor: "~alice", date: "2026-03-19", count: 4, score: 4)],
233 statsResponses: [.active(actor: "~alice", year: 2026, totalEvents: 4, activeDays: 1, longestStreak: 1)]
234 )
235 let viewModel = ContributionCalendarViewModel(
236 actor: "~alice",
237 service: service,
238 selectedEndDate: ContributionDateParser.parse("2026-04-11")
239 )
240
241 await viewModel.load()
242
243 #expect(viewModel.displayState == .populated)
244 #expect(viewModel.lastUpdatedText != nil)
245 }
246}
247
248private final class MockContributionCalendarService: ContributionCalendarServing, @unchecked Sendable {
249 var calendarResponses: [ContributionCalendarResponse]
250 var statsResponses: [ContributionStatsResponse]
251 private(set) var fetchCalendarCallCount = 0
252 private(set) var fetchStatsCallCount = 0
253
254 init(
255 calendarResponses: [ContributionCalendarResponse],
256 statsResponses: [ContributionStatsResponse]
257 ) {
258 self.calendarResponses = calendarResponses
259 self.statsResponses = statsResponses
260 }
261
262 func fetchContributionCalendar(actor _: String, endingOn _: Date) async throws -> ContributionCalendarResponse {
263 fetchCalendarCallCount += 1
264 return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
265 }
266
267 func fetchContributionStats(actor _: String, endingOn _: Date) async throws -> ContributionStatsResponse {
268 fetchStatsCallCount += 1
269 return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
270 }
271}
272
273private extension ContributionCalendarResponse {
274 static func empty(actor: String, year: Int) -> Self {
275 let from = ContributionDateParser.parse("\(year)-01-01")!
276 let to = ContributionDateParser.parse("\(year)-01-07")!
277 return ContributionCalendarResponse(
278 actor: actor,
279 from: from,
280 to: to,
281 isIndexed: true,
282 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
283 indexingState: .indexed,
284 days: (1...7).map { day in
285 ContributionDay(
286 date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
287 count: 0,
288 score: 0
289 )
290 }
291 )
292 }
293
294 static func active(actor: String, date: String, count: Int, score: Double) -> Self {
295 let resolvedDate = ContributionDateParser.parse(date)!
296 return ContributionCalendarResponse(
297 actor: actor,
298 from: resolvedDate,
299 to: resolvedDate,
300 isIndexed: true,
301 lastPolledAt: ContributionDateParser.parseTimestamp("2026-03-19T12:00:00Z"),
302 indexingState: .indexed,
303 days: [ContributionDay(date: resolvedDate, count: count, score: score)]
304 )
305 }
306
307 static func pending(actor: String, year: Int) -> Self {
308 let from = ContributionDateParser.parse("\(year)-01-01")!
309 let to = ContributionDateParser.parse("\(year)-01-07")!
310 return ContributionCalendarResponse(
311 actor: actor,
312 from: from,
313 to: to,
314 isIndexed: false,
315 lastPolledAt: nil,
316 indexingState: .pending,
317 days: (1...7).map { day in
318 ContributionDay(
319 date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
320 count: 0,
321 score: 0
322 )
323 }
324 )
325 }
326
327 static func error(actor: String, year: Int) -> Self {
328 let from = ContributionDateParser.parse("\(year)-01-01")!
329 let to = ContributionDateParser.parse("\(year)-01-07")!
330 return ContributionCalendarResponse(
331 actor: actor,
332 from: from,
333 to: to,
334 isIndexed: false,
335 lastPolledAt: nil,
336 indexingState: .error,
337 days: (1...7).map { day in
338 ContributionDay(
339 date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
340 count: 0,
341 score: 0
342 )
343 }
344 )
345 }
346}
347
348private extension ContributionStatsResponse {
349 static func empty(actor: String, year: Int) -> Self {
350 ContributionStatsResponse(
351 window: .init(
352 actor: actor,
353 from: ContributionDateParser.parse("\(year)-01-01")!,
354 to: ContributionDateParser.parse("\(year)-01-07")!,
355 isIndexed: true,
356 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
357 indexingState: .indexed
358 ),
359 totals: .init(
360 totalEvents: 0,
361 totalScore: 0,
362 activeDays: 0,
363 longestStreak: 0,
364 currentStreak: 0
365 )
366 )
367 }
368
369 static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
370 ContributionStatsResponse(
371 window: .init(
372 actor: actor,
373 from: ContributionDateParser.parse("\(year)-01-01")!,
374 to: ContributionDateParser.parse("\(year)-01-07")!,
375 isIndexed: true,
376 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
377 indexingState: .indexed
378 ),
379 totals: .init(
380 totalEvents: totalEvents,
381 totalScore: Double(totalEvents),
382 activeDays: activeDays,
383 longestStreak: longestStreak,
384 currentStreak: 0
385 )
386 )
387 }
388
389 static func pending(actor: String, year: Int) -> Self {
390 ContributionStatsResponse(
391 window: .init(
392 actor: actor,
393 from: ContributionDateParser.parse("\(year)-01-01")!,
394 to: ContributionDateParser.parse("\(year)-01-07")!,
395 isIndexed: false,
396 lastPolledAt: nil,
397 indexingState: .pending
398 ),
399 totals: .init(
400 totalEvents: 0,
401 totalScore: 0,
402 activeDays: 0,
403 longestStreak: 0,
404 currentStreak: 0
405 )
406 )
407 }
408
409 static func error(actor: String, year: Int) -> Self {
410 ContributionStatsResponse(
411 window: .init(
412 actor: actor,
413 from: ContributionDateParser.parse("\(year)-01-01")!,
414 to: ContributionDateParser.parse("\(year)-01-07")!,
415 isIndexed: false,
416 lastPolledAt: nil,
417 indexingState: .error
418 ),
419 totals: .init(
420 totalEvents: 0,
421 totalScore: 0,
422 activeDays: 0,
423 longestStreak: 0,
424 currentStreak: 0
425 )
426 )
427 }
428}