krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.14.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 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}