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