summaryrefslogtreecommitdiff
path: root/HutchTests/ContributionCalendarTests.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
commit61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch)
tree0a2befeb986966d42062802e2a29d795be907fee /HutchTests/ContributionCalendarTests.swift
parent27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff)
downloadhutch-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.swift388
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
+ )
+ }
+}