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}