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}