krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.1: 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 contributionIntensityBucketsMatchProductRules() {
98 #expect(ContributionIntensity(count: 0) == .empty)
99 #expect(ContributionIntensity(count: 1) == .level1)
100 #expect(ContributionIntensity(count: 2) == .level2)
101 #expect(ContributionIntensity(count: 3) == .level2)
102 #expect(ContributionIntensity(count: 4) == .level3)
103 #expect(ContributionIntensity(count: 6) == .level3)
104 #expect(ContributionIntensity(count: 7) == .level4)
105 #expect(ContributionIntensity(count: 12) == .level4)
106 }
107
108 @Test
109 func weekColumnsGroupConsecutiveDaysIntoSundayBasedWeeks() {
110 let days = [
111 ContributionDay(date: ContributionDateParser.parse("2026-03-29")!, count: 1, score: 1),
112 ContributionDay(date: ContributionDateParser.parse("2026-03-30")!, count: 2, score: 2),
113 ContributionDay(date: ContributionDateParser.parse("2026-04-04")!, count: 3, score: 3),
114 ContributionDay(date: ContributionDateParser.parse("2026-04-05")!, count: 4, score: 4)
115 ]
116
117 let weeks = ContributionCalendarLayout.weekColumns(from: days)
118
119 #expect(weeks.count == 2)
120 #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-29"))
121 #expect(weeks[0].days.map(\.count) == [1, 2, 3])
122 #expect(weeks[1].startDate == ContributionDateParser.parse("2026-04-05"))
123 #expect(weeks[1].days.map(\.count) == [4])
124 }
125
126 @Test
127 func recentWeeksReturnsTrailingWindow() {
128 let days = (0..<21).compactMap { offset -> ContributionDay? in
129 guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: offset, to: ContributionDateParser.parse("2026-03-01")!) else {
130 return nil
131 }
132
133 return ContributionDay(date: date, count: offset, score: Double(offset))
134 }
135
136 let weeks = ContributionCalendarLayout.recentWeeks(from: days, count: 2)
137
138 #expect(weeks.count == 2)
139 #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-08"))
140 #expect(weeks[1].startDate == ContributionDateParser.parse("2026-03-15"))
141 }
142
143 @Test
144 @MainActor
145 func emptyCalendarResponseTreatsActivityAsNotIndexedYet() async {
146 let service = MockContributionCalendarService(
147 calendarResponses: [.pending(actor: "~alice", year: 2026)],
148 statsResponses: [.pending(actor: "~alice", year: 2026)]
149 )
150 let viewModel = ContributionCalendarViewModel(
151 actor: "~alice",
152 service: service,
153 selectedEndDate: ContributionDateParser.parse("2026-04-11")
154 )
155
156 await viewModel.load()
157
158 #expect(viewModel.displayState == .indexing)
159 #expect(viewModel.emptyStateTitle == "Indexing Activity")
160 #expect(viewModel.emptyStateMessage == "This user’s SourceHut activity is being indexed. Check back soon.")
161 #expect(viewModel.loadErrorMessage == nil)
162 }
163
164 @Test
165 @MainActor
166 func indexedEmptyCalendarShowsTrueEmptyState() async {
167 let service = MockContributionCalendarService(
168 calendarResponses: [.empty(actor: "~alice", year: 2026)],
169 statsResponses: [.empty(actor: "~alice", year: 2026)]
170 )
171 let viewModel = ContributionCalendarViewModel(
172 actor: "~alice",
173 service: service,
174 selectedEndDate: ContributionDateParser.parse("2026-04-11")
175 )
176
177 await viewModel.load()
178
179 #expect(viewModel.displayState == .empty)
180 #expect(viewModel.emptyStateTitle == "No Contribution Activity")
181 #expect(viewModel.emptyStateMessage == "No activity was found for this time range.")
182 }
183
184 @Test
185 @MainActor
186 func errorIndexingStateShowsUnavailableState() async {
187 let service = MockContributionCalendarService(
188 calendarResponses: [.error(actor: "~alice", year: 2026)],
189 statsResponses: [.error(actor: "~alice", year: 2026)]
190 )
191 let viewModel = ContributionCalendarViewModel(
192 actor: "~alice",
193 service: service,
194 selectedEndDate: ContributionDateParser.parse("2026-04-11")
195 )
196
197 await viewModel.load()
198
199 #expect(viewModel.displayState == .unavailable)
200 #expect(viewModel.emptyStateTitle == "Activity Unavailable")
201 #expect(viewModel.emptyStateMessage == "The contribution graph couldn’t be refreshed right now. Try again later.")
202 }
203
204 @Test
205 @MainActor
206 func populatedStatsExposeLastUpdatedText() async {
207 let service = MockContributionCalendarService(
208 calendarResponses: [.active(actor: "~alice", date: "2026-03-19", count: 4, score: 4)],
209 statsResponses: [.active(actor: "~alice", year: 2026, totalEvents: 4, activeDays: 1, longestStreak: 1)]
210 )
211 let viewModel = ContributionCalendarViewModel(
212 actor: "~alice",
213 service: service,
214 selectedEndDate: ContributionDateParser.parse("2026-04-11")
215 )
216
217 await viewModel.load()
218
219 #expect(viewModel.displayState == .populated)
220 #expect(viewModel.lastUpdatedText != nil)
221 }
222}
223
224private final class MockContributionCalendarService: ContributionCalendarServing, @unchecked Sendable {
225 var calendarResponses: [ContributionCalendarResponse]
226 var statsResponses: [ContributionStatsResponse]
227 private(set) var fetchCalendarCallCount = 0
228 private(set) var fetchStatsCallCount = 0
229
230 init(
231 calendarResponses: [ContributionCalendarResponse],
232 statsResponses: [ContributionStatsResponse]
233 ) {
234 self.calendarResponses = calendarResponses
235 self.statsResponses = statsResponses
236 }
237
238 func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
239 fetchCalendarCallCount += 1
240 return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
241 }
242
243 func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
244 fetchStatsCallCount += 1
245 return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
246 }
247}
248
249private extension ContributionCalendarResponse {
250 static func empty(actor: String, year: Int) -> Self {
251 let from = ContributionDateParser.parse("\(year)-01-01")!
252 let to = ContributionDateParser.parse("\(year)-01-07")!
253 return ContributionCalendarResponse(
254 actor: actor,
255 from: from,
256 to: to,
257 isIndexed: true,
258 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
259 indexingState: .indexed,
260 days: (1...7).map { day in
261 ContributionDay(
262 date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
263 count: 0,
264 score: 0
265 )
266 }
267 )
268 }
269
270 static func active(actor: String, date: String, count: Int, score: Double) -> Self {
271 let resolvedDate = ContributionDateParser.parse(date)!
272 return ContributionCalendarResponse(
273 actor: actor,
274 from: resolvedDate,
275 to: resolvedDate,
276 isIndexed: true,
277 lastPolledAt: ContributionDateParser.parseTimestamp("2026-03-19T12:00:00Z"),
278 indexingState: .indexed,
279 days: [ContributionDay(date: resolvedDate, count: count, score: score)]
280 )
281 }
282
283 static func pending(actor: String, year: Int) -> Self {
284 let from = ContributionDateParser.parse("\(year)-01-01")!
285 let to = ContributionDateParser.parse("\(year)-01-07")!
286 return ContributionCalendarResponse(
287 actor: actor,
288 from: from,
289 to: to,
290 isIndexed: false,
291 lastPolledAt: nil,
292 indexingState: .pending,
293 days: (1...7).map { day in
294 ContributionDay(
295 date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
296 count: 0,
297 score: 0
298 )
299 }
300 )
301 }
302
303 static func error(actor: String, year: Int) -> Self {
304 let from = ContributionDateParser.parse("\(year)-01-01")!
305 let to = ContributionDateParser.parse("\(year)-01-07")!
306 return ContributionCalendarResponse(
307 actor: actor,
308 from: from,
309 to: to,
310 isIndexed: false,
311 lastPolledAt: nil,
312 indexingState: .error,
313 days: (1...7).map { day in
314 ContributionDay(
315 date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
316 count: 0,
317 score: 0
318 )
319 }
320 )
321 }
322}
323
324private extension ContributionStatsResponse {
325 static func empty(actor: String, year: Int) -> Self {
326 ContributionStatsResponse(
327 actor: actor,
328 from: ContributionDateParser.parse("\(year)-01-01")!,
329 to: ContributionDateParser.parse("\(year)-01-07")!,
330 isIndexed: true,
331 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
332 indexingState: .indexed,
333 totalEvents: 0,
334 totalScore: 0,
335 activeDays: 0,
336 longestStreak: 0,
337 currentStreak: 0
338 )
339 }
340
341 static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
342 ContributionStatsResponse(
343 actor: actor,
344 from: ContributionDateParser.parse("\(year)-01-01")!,
345 to: ContributionDateParser.parse("\(year)-01-07")!,
346 isIndexed: true,
347 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
348 indexingState: .indexed,
349 totalEvents: totalEvents,
350 totalScore: Double(totalEvents),
351 activeDays: activeDays,
352 longestStreak: longestStreak,
353 currentStreak: 0
354 )
355 }
356
357 static func pending(actor: String, year: Int) -> Self {
358 ContributionStatsResponse(
359 actor: actor,
360 from: ContributionDateParser.parse("\(year)-01-01")!,
361 to: ContributionDateParser.parse("\(year)-01-07")!,
362 isIndexed: false,
363 lastPolledAt: nil,
364 indexingState: .pending,
365 totalEvents: 0,
366 totalScore: 0,
367 activeDays: 0,
368 longestStreak: 0,
369 currentStreak: 0
370 )
371 }
372
373 static func error(actor: String, year: Int) -> Self {
374 ContributionStatsResponse(
375 actor: actor,
376 from: ContributionDateParser.parse("\(year)-01-01")!,
377 to: ContributionDateParser.parse("\(year)-01-07")!,
378 isIndexed: false,
379 lastPolledAt: nil,
380 indexingState: .error,
381 totalEvents: 0,
382 totalScore: 0,
383 activeDays: 0,
384 longestStreak: 0,
385 currentStreak: 0
386 )
387 }
388}