diff options
| author | Christian Cleberg <[email protected]> | 2026-04-11 19:47:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-11 19:47:40 -0500 |
| commit | 61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch) | |
| tree | 0a2befeb986966d42062802e2a29d795be907fee /HutchTests/ContributionCalendarTests.swift | |
| parent | 27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff) | |
| download | hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.gz hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.bz2 hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.zip | |
feat: add contribution calendar to user profilesv2.11.0
Add a trailing 365-day contribution heatmap to user profile views,
backed by HutchStatsService. Includes calendar model, view model,
SwiftUI calendar view, app configuration, and unit tests.
Diffstat (limited to 'HutchTests/ContributionCalendarTests.swift')
| -rw-r--r-- | HutchTests/ContributionCalendarTests.swift | 388 |
1 files changed, 388 insertions, 0 deletions
diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift new file mode 100644 index 0000000..4ce2f1f --- /dev/null +++ b/HutchTests/ContributionCalendarTests.swift @@ -0,0 +1,388 @@ +import Foundation +import Testing +@testable import Hutch + +struct ContributionCalendarTests { + + @Test + @MainActor + func contributionCalendarDecodingParsesDatesAndCounts() throws { + let data = Data( + """ + { + "actor": "~ccleberg", + "from": "2026-03-01", + "to": "2026-04-15", + "is_indexed": false, + "last_polled_at": null, + "indexing_state": "pending", + "days": [ + { "date": "2026-03-19", "count": 24, "score": 24.0 }, + { "date": "2026-04-02", "count": 20, "score": 18.5 } + ] + } + """.utf8 + ) + + let decoded = try JSONDecoder().decode(ContributionCalendarResponse.self, from: data) + + #expect(decoded.actor == "~ccleberg") + #expect(decoded.from == ContributionDateParser.parse("2026-03-01")) + #expect(decoded.to == ContributionDateParser.parse("2026-04-15")) + #expect(decoded.isIndexed == false) + #expect(decoded.lastPolledAt == nil) + #expect(decoded.indexingState == .pending) + #expect(decoded.days.count == 2) + #expect(decoded.days[0].date == ContributionDateParser.parse("2026-03-19")) + #expect(decoded.days[0].count == 24) + #expect(decoded.days[1].score == 18.5) + } + + @Test + @MainActor + func contributionStatsDecodingParsesSnakeCasePayload() throws { + let data = Data( + """ + { + "actor": "~ccleberg", + "from": "2026-03-01", + "to": "2026-04-15", + "is_indexed": true, + "last_polled_at": "2026-04-15T15:42:18Z", + "indexing_state": "indexed", + "total_events": 126, + "total_score": 116.75, + "active_days": 14, + "longest_streak": 5, + "current_streak": 0 + } + """.utf8 + ) + + let decoded = try JSONDecoder().decode(ContributionStatsResponse.self, from: data) + + #expect(decoded.totalEvents == 126) + #expect(decoded.totalScore == 116.75) + #expect(decoded.activeDays == 14) + #expect(decoded.longestStreak == 5) + #expect(decoded.currentStreak == 0) + #expect(decoded.isIndexed) + #expect(decoded.indexingState == .indexed) + #expect(decoded.lastPolledAt == ContributionDateParser.parseTimestamp("2026-04-15T15:42:18Z")) + } + + @Test + func contributionDateParserRejectsInvalidDate() { + #expect(ContributionDateParser.parse("2026-13-40") == nil) + } + + @Test + func contributionTimestampParserHandlesFractionalSecondsWithoutTimezone() { + let parsed = ContributionDateParser.parseTimestamp("2026-04-11T19:26:45.538015") + + #expect(parsed != nil) + } + + @Test + func trailingRangeEndsTodayAndStartsOneYearEarlierPlusOneDay() { + let service = HutchStatsService(configuration: AppConfiguration()) + let endDate = ContributionDateParser.parse("2026-04-11")! + let range = service.trailingRange(endingOn: endDate) + + #expect(range.lowerBound == ContributionDateParser.parse("2025-04-12")) + #expect(range.upperBound == endDate) + } + + @Test + func contributionIntensityBucketsMatchProductRules() { + #expect(ContributionIntensity(count: 0) == .empty) + #expect(ContributionIntensity(count: 1) == .level1) + #expect(ContributionIntensity(count: 2) == .level2) + #expect(ContributionIntensity(count: 3) == .level2) + #expect(ContributionIntensity(count: 4) == .level3) + #expect(ContributionIntensity(count: 6) == .level3) + #expect(ContributionIntensity(count: 7) == .level4) + #expect(ContributionIntensity(count: 12) == .level4) + } + + @Test + func weekColumnsGroupConsecutiveDaysIntoSundayBasedWeeks() { + let days = [ + ContributionDay(date: ContributionDateParser.parse("2026-03-29")!, count: 1, score: 1), + ContributionDay(date: ContributionDateParser.parse("2026-03-30")!, count: 2, score: 2), + ContributionDay(date: ContributionDateParser.parse("2026-04-04")!, count: 3, score: 3), + ContributionDay(date: ContributionDateParser.parse("2026-04-05")!, count: 4, score: 4) + ] + + let weeks = ContributionCalendarLayout.weekColumns(from: days) + + #expect(weeks.count == 2) + #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-29")) + #expect(weeks[0].days.map(\.count) == [1, 2, 3]) + #expect(weeks[1].startDate == ContributionDateParser.parse("2026-04-05")) + #expect(weeks[1].days.map(\.count) == [4]) + } + + @Test + func recentWeeksReturnsTrailingWindow() { + let days = (0..<21).compactMap { offset -> ContributionDay? in + guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: offset, to: ContributionDateParser.parse("2026-03-01")!) else { + return nil + } + + return ContributionDay(date: date, count: offset, score: Double(offset)) + } + + let weeks = ContributionCalendarLayout.recentWeeks(from: days, count: 2) + + #expect(weeks.count == 2) + #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-08")) + #expect(weeks[1].startDate == ContributionDateParser.parse("2026-03-15")) + } + + @Test + @MainActor + func emptyCalendarResponseTreatsActivityAsNotIndexedYet() async { + let service = MockContributionCalendarService( + calendarResponses: [.pending(actor: "~alice", year: 2026)], + statsResponses: [.pending(actor: "~alice", year: 2026)] + ) + let viewModel = ContributionCalendarViewModel( + actor: "~alice", + service: service, + selectedEndDate: ContributionDateParser.parse("2026-04-11") + ) + + await viewModel.load() + + #expect(viewModel.displayState == .indexing) + #expect(viewModel.emptyStateTitle == "Indexing Activity") + #expect(viewModel.emptyStateMessage == "This user’s SourceHut activity is being indexed. Check back soon.") + #expect(viewModel.loadErrorMessage == nil) + } + + @Test + @MainActor + func indexedEmptyCalendarShowsTrueEmptyState() async { + let service = MockContributionCalendarService( + calendarResponses: [.empty(actor: "~alice", year: 2026)], + statsResponses: [.empty(actor: "~alice", year: 2026)] + ) + let viewModel = ContributionCalendarViewModel( + actor: "~alice", + service: service, + selectedEndDate: ContributionDateParser.parse("2026-04-11") + ) + + await viewModel.load() + + #expect(viewModel.displayState == .empty) + #expect(viewModel.emptyStateTitle == "No Contribution Activity") + #expect(viewModel.emptyStateMessage == "No activity was found for this time range.") + } + + @Test + @MainActor + func errorIndexingStateShowsUnavailableState() async { + let service = MockContributionCalendarService( + calendarResponses: [.error(actor: "~alice", year: 2026)], + statsResponses: [.error(actor: "~alice", year: 2026)] + ) + let viewModel = ContributionCalendarViewModel( + actor: "~alice", + service: service, + selectedEndDate: ContributionDateParser.parse("2026-04-11") + ) + + await viewModel.load() + + #expect(viewModel.displayState == .unavailable) + #expect(viewModel.emptyStateTitle == "Activity Unavailable") + #expect(viewModel.emptyStateMessage == "The contribution graph couldn’t be refreshed right now. Try again later.") + } + + @Test + @MainActor + func populatedStatsExposeLastUpdatedText() async { + let service = MockContributionCalendarService( + calendarResponses: [.active(actor: "~alice", date: "2026-03-19", count: 4, score: 4)], + statsResponses: [.active(actor: "~alice", year: 2026, totalEvents: 4, activeDays: 1, longestStreak: 1)] + ) + let viewModel = ContributionCalendarViewModel( + actor: "~alice", + service: service, + selectedEndDate: ContributionDateParser.parse("2026-04-11") + ) + + await viewModel.load() + + #expect(viewModel.displayState == .populated) + #expect(viewModel.lastUpdatedText != nil) + } +} + +private final class MockContributionCalendarService: ContributionCalendarServing, @unchecked Sendable { + var calendarResponses: [ContributionCalendarResponse] + var statsResponses: [ContributionStatsResponse] + private(set) var fetchCalendarCallCount = 0 + private(set) var fetchStatsCallCount = 0 + + init( + calendarResponses: [ContributionCalendarResponse], + statsResponses: [ContributionStatsResponse] + ) { + self.calendarResponses = calendarResponses + self.statsResponses = statsResponses + } + + func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse { + fetchCalendarCallCount += 1 + return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)] + } + + func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse { + fetchStatsCallCount += 1 + return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)] + } +} + +private extension ContributionCalendarResponse { + static func empty(actor: String, year: Int) -> Self { + let from = ContributionDateParser.parse("\(year)-01-01")! + let to = ContributionDateParser.parse("\(year)-01-07")! + return ContributionCalendarResponse( + actor: actor, + from: from, + to: to, + isIndexed: true, + lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), + indexingState: .indexed, + days: (1...7).map { day in + ContributionDay( + date: ContributionDateParser.parse("\(year)-01-0\(day)")!, + count: 0, + score: 0 + ) + } + ) + } + + static func active(actor: String, date: String, count: Int, score: Double) -> Self { + let resolvedDate = ContributionDateParser.parse(date)! + return ContributionCalendarResponse( + actor: actor, + from: resolvedDate, + to: resolvedDate, + isIndexed: true, + lastPolledAt: ContributionDateParser.parseTimestamp("2026-03-19T12:00:00Z"), + indexingState: .indexed, + days: [ContributionDay(date: resolvedDate, count: count, score: score)] + ) + } + + static func pending(actor: String, year: Int) -> Self { + let from = ContributionDateParser.parse("\(year)-01-01")! + let to = ContributionDateParser.parse("\(year)-01-07")! + return ContributionCalendarResponse( + actor: actor, + from: from, + to: to, + isIndexed: false, + lastPolledAt: nil, + indexingState: .pending, + days: (1...7).map { day in + ContributionDay( + date: ContributionDateParser.parse("\(year)-01-0\(day)")!, + count: 0, + score: 0 + ) + } + ) + } + + static func error(actor: String, year: Int) -> Self { + let from = ContributionDateParser.parse("\(year)-01-01")! + let to = ContributionDateParser.parse("\(year)-01-07")! + return ContributionCalendarResponse( + actor: actor, + from: from, + to: to, + isIndexed: false, + lastPolledAt: nil, + indexingState: .error, + days: (1...7).map { day in + ContributionDay( + date: ContributionDateParser.parse("\(year)-01-0\(day)")!, + count: 0, + score: 0 + ) + } + ) + } +} + +private extension ContributionStatsResponse { + static func empty(actor: String, year: Int) -> Self { + ContributionStatsResponse( + actor: actor, + from: ContributionDateParser.parse("\(year)-01-01")!, + to: ContributionDateParser.parse("\(year)-01-07")!, + isIndexed: true, + lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), + indexingState: .indexed, + totalEvents: 0, + totalScore: 0, + activeDays: 0, + longestStreak: 0, + currentStreak: 0 + ) + } + + static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self { + ContributionStatsResponse( + actor: actor, + from: ContributionDateParser.parse("\(year)-01-01")!, + to: ContributionDateParser.parse("\(year)-01-07")!, + isIndexed: true, + lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), + indexingState: .indexed, + totalEvents: totalEvents, + totalScore: Double(totalEvents), + activeDays: activeDays, + longestStreak: longestStreak, + currentStreak: 0 + ) + } + + static func pending(actor: String, year: Int) -> Self { + ContributionStatsResponse( + actor: actor, + from: ContributionDateParser.parse("\(year)-01-01")!, + to: ContributionDateParser.parse("\(year)-01-07")!, + isIndexed: false, + lastPolledAt: nil, + indexingState: .pending, + totalEvents: 0, + totalScore: 0, + activeDays: 0, + longestStreak: 0, + currentStreak: 0 + ) + } + + static func error(actor: String, year: Int) -> Self { + ContributionStatsResponse( + actor: actor, + from: ContributionDateParser.parse("\(year)-01-01")!, + to: ContributionDateParser.parse("\(year)-01-07")!, + isIndexed: false, + lastPolledAt: nil, + indexingState: .error, + totalEvents: 0, + totalScore: 0, + activeDays: 0, + longestStreak: 0, + currentStreak: 0 + ) + } +} |
