From fe1ccc69661603d419a642a450e4ac9e1258adb0 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 13 Apr 2026 19:44:44 -0500 Subject: fix: centralize URLs, tighten models, and polish SwiftUI bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shared HutchDeepLinkURL constants for app, widgets, and tests. - Bump marketing version to 3.1.2 and build to 70 (app + widget extension). - Point Hutch stats default base URL at HutchStatsAPI; add SRHTWebURL status incident feed and reuse it from SystemStatusService. - Group Project into Metadata and Resources; split ContributionStatsResponse into StatsWindow and StatsTotals with updated decoding and tests. - Replace @Bindable usage with explicit Bindings in Profile, Projects list, and repository ACL flows; simplify Home pinned-item helper; add no-op bodies on cancel alert buttons where the compiler requires a statement. - Move repository row build-status indicator next to the relative-updated caption and reserve a fixed 8×8 slot so the row does not jump when status loads. - Use NSString.lastPathComponent for build artifact filenames; collapse duplicate ACL error branches; minor HutchStats HTTP and XMLParserDelegate cleanups. - Point widgets at HutchDeepLinkURL helpers; align tests with the new response and URL types. --- HutchTests/ContributionCalendarTests.swift | 108 +++++++++++++---------- HutchTests/DeepLinkTests.swift | 26 +++--- HutchTests/ProjectPinStoreTests.swift | 4 +- HutchTests/ProjectTests.swift | 135 +++++++++++++++-------------- HutchTests/SRHTWebURLTests.swift | 26 ++++-- HutchTests/SystemStatusServiceTests.swift | 5 +- 6 files changed, 171 insertions(+), 133 deletions(-) (limited to 'HutchTests') diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift index 9e76e42..0328ad5 100644 --- a/HutchTests/ContributionCalendarTests.swift +++ b/HutchTests/ContributionCalendarTests.swift @@ -259,12 +259,12 @@ private final class MockContributionCalendarService: ContributionCalendarServing self.statsResponses = statsResponses } - func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse { + func fetchContributionCalendar(actor _: String, endingOn _: Date) async throws -> ContributionCalendarResponse { fetchCalendarCallCount += 1 return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)] } - func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse { + func fetchContributionStats(actor _: String, endingOn _: Date) async throws -> ContributionStatsResponse { fetchStatsCallCount += 1 return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)] } @@ -348,65 +348,81 @@ private extension ContributionCalendarResponse { 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 + window: .init( + 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 + ), + totals: .init( + 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 + window: .init( + 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 + ), + totals: .init( + 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 + window: .init( + actor: actor, + from: ContributionDateParser.parse("\(year)-01-01")!, + to: ContributionDateParser.parse("\(year)-01-07")!, + isIndexed: false, + lastPolledAt: nil, + indexingState: .pending + ), + totals: .init( + 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 + window: .init( + actor: actor, + from: ContributionDateParser.parse("\(year)-01-01")!, + to: ContributionDateParser.parse("\(year)-01-07")!, + isIndexed: false, + lastPolledAt: nil, + indexingState: .error + ), + totals: .init( + totalEvents: 0, + totalScore: 0, + activeDays: 0, + longestStreak: 0, + currentStreak: 0 + ) ) } } diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift index 12f759b..c57cfce 100644 --- a/HutchTests/DeepLinkTests.swift +++ b/HutchTests/DeepLinkTests.swift @@ -6,61 +6,61 @@ struct DeepLinkTests { @Test func parsesHomeLink() { - let link = DeepLink(url: URL(string: "hutch://home")!) + let link = DeepLink(url: HutchDeepLinkURL.home) #expect(link == .home) } @Test func parsesNilPathAsHome() { - let link = DeepLink(url: URL(string: "hutch://")!) + let link = DeepLink(url: HutchDeepLinkURL.emptyHost) #expect(link == .home) } @Test func parsesRepositoryLink() { - let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!) + let link = DeepLink(url: HutchDeepLinkURL.repositoryGit) #expect(link == .repository(owner: "~user", repo: "repo")) } @Test func parsesTicketLink() { - let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!) + let link = DeepLink(url: HutchDeepLinkURL.ticket) #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42)) } @Test func parsesBuildJobLink() { - let link = DeepLink(url: URL(string: "hutch://builds/12345")!) + let link = DeepLink(url: HutchDeepLinkURL.buildJob) #expect(link == .build(jobId: 12345)) } @Test func parsesBuildsTabLink() { - let link = DeepLink(url: URL(string: "hutch://builds")!) + let link = DeepLink(url: HutchDeepLinkURL.builds) #expect(link == .buildsTab) } @Test func parsesRepositoriesTabLink() { - let link = DeepLink(url: URL(string: "hutch://repositories")!) + let link = DeepLink(url: HutchDeepLinkURL.repositories) #expect(link == .repositoriesTab) } @Test func parsesTrackersTabLink() { - let link = DeepLink(url: URL(string: "hutch://trackers")!) + let link = DeepLink(url: HutchDeepLinkURL.trackers) #expect(link == .trackersTab) } @Test func parsesSystemStatusLink() { - let link = DeepLink(url: URL(string: "hutch://status")!) + let link = DeepLink(url: HutchDeepLinkURL.status) #expect(link == .systemStatus) } @Test func parsesLookupLink() { - let link = DeepLink(url: URL(string: "hutch://lookup")!) + let link = DeepLink(url: HutchDeepLinkURL.lookup) #expect(link == .lookup) } @@ -72,19 +72,19 @@ struct DeepLinkTests { @Test func rejectsUnknownPath() { - let link = DeepLink(url: URL(string: "hutch://unknown")!) + let link = DeepLink(url: HutchDeepLinkURL.unknown) #expect(link == nil) } @Test func rejectsTicketLinkWithNonNumericId() { - let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!) + let link = DeepLink(url: HutchDeepLinkURL.invalidTicketId) #expect(link == nil) } @Test func rejectsBuildLinkWithNonNumericId() { - let link = DeepLink(url: URL(string: "hutch://builds/abc")!) + let link = DeepLink(url: HutchDeepLinkURL.invalidBuildId) #expect(link == nil) } } diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift index 17b4397..13df539 100644 --- a/HutchTests/ProjectPinStoreTests.swift +++ b/HutchTests/ProjectPinStoreTests.swift @@ -27,11 +27,11 @@ struct ProjectPinStoreTests { } @Test - func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() { + func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() throws { let defaults = UserDefaults(suiteName: #function)! defaults.removePersistentDomain(forName: #function) - let encoded = try! JSONEncoder().encode([ + let encoded = try JSONEncoder().encode([ "~alice": [" project-1 ", "", "project-1", "project-2"] ]) defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects) diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift index eff800f..0323f25 100644 --- a/HutchTests/ProjectTests.swift +++ b/HutchTests/ProjectTests.swift @@ -3,52 +3,61 @@ import Testing @testable import Hutch struct ProjectTests { + private enum Fixture { + static let exampleWebsite = "https://example.com" + } + @Test func resourceSummaryIncludesCounts() { let project = Project( - id: "project-1", - name: "Hutch", - description: nil, - website: nil, - visibility: .public, - tags: [], - updated: Date(timeIntervalSince1970: 0), - mailingLists: [ - Project.MailingList( - id: "list-1", - name: "hutch-devel", - description: nil, - visibility: .public, - owner: Entity(canonicalName: "~owner") - ) - ], - sources: [ - Project.SourceRepo( - id: "repo-1", - name: "hutch", - description: nil, - visibility: .public, - owner: Entity(canonicalName: "~owner"), - repoType: .git - ), - Project.SourceRepo( - id: "repo-2", - name: "hutch-web", - description: nil, - visibility: .public, - owner: Entity(canonicalName: "~owner"), - repoType: .git - ) - ], - trackers: [ - Project.Tracker( - id: "tracker-1", - name: "bugs", - description: nil, - visibility: .public, - owner: Entity(canonicalName: "~owner") - ) - ] + metadata: .init( + id: "project-1", + name: "Hutch", + description: nil, + website: nil, + visibility: .public, + tags: [], + updated: Date(timeIntervalSince1970: 0) + ), + resources: .init( + mailingLists: [ + Project.MailingList( + id: "list-1", + name: "hutch-devel", + description: nil, + visibility: .public, + owner: Entity(canonicalName: "~owner") + ) + ], + sources: [ + Project.SourceRepo( + id: "repo-1", + name: "hutch", + description: nil, + visibility: .public, + owner: Entity(canonicalName: "~owner"), + repoType: .git + ), + Project.SourceRepo( + id: "repo-2", + name: "hutch-web", + description: nil, + visibility: .public, + owner: Entity(canonicalName: "~owner"), + repoType: .git + ) + ], + trackers: [ + Project.Tracker( + id: "tracker-1", + name: "bugs", + description: nil, + visibility: .public, + owner: Entity(canonicalName: "~owner") + ) + ], + isFullyLoaded: true + ) ) #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list") @@ -57,16 +66,16 @@ struct ProjectTests { @Test func resourceSummaryFallsBackToWebsite() { let project = Project( - id: "project-1", - name: "Docs", - description: nil, - website: "https://example.com", - visibility: .public, - tags: [], - updated: Date(timeIntervalSince1970: 0), - mailingLists: [], - sources: [], - trackers: [] + metadata: .init( + id: "project-1", + name: "Docs", + description: nil, + website: Fixture.exampleWebsite, + visibility: .public, + tags: [], + updated: Date(timeIntervalSince1970: 0) + ), + resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true) ) #expect(project.resourceSummary == "Website linked") @@ -75,16 +84,16 @@ struct ProjectTests { @Test func displayHelpersNormalizeBlankValues() { let project = Project( - id: "project-1", - name: " ", - description: "\n", - website: "https://example.com", - visibility: .unlisted, - tags: [" docs ", "", "Docs", "ios"], - updated: Date(timeIntervalSince1970: 0), - mailingLists: [], - sources: [], - trackers: [] + metadata: .init( + id: "project-1", + name: " ", + description: "\n", + website: Fixture.exampleWebsite, + visibility: .unlisted, + tags: [" docs ", "", "Docs", "ios"], + updated: Date(timeIntervalSince1970: 0) + ), + resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true) ) #expect(project.displayName == "Untitled Project") diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift index a183f00..c1f0dc0 100644 --- a/HutchTests/SRHTWebURLTests.swift +++ b/HutchTests/SRHTWebURLTests.swift @@ -3,6 +3,16 @@ import Testing @testable import Hutch struct SRHTWebURLTests { + private enum Expected { + static let chatOrigin = "https://chat.sr.ht" + static let statusOrigin = "https://status.sr.ht" + static let gitRepoHTTPS = "https://git.sr.ht/~ccleberg/hutch" + static let gitSSH = "git@git.sr.ht:~ccleberg/hutch" + static let tracker = "https://todo.sr.ht/~ccleberg/todo" + static let ticket = "https://todo.sr.ht/~ccleberg/todo/42" + static let buildJob = "https://builds.sr.ht/~ccleberg/job/12" + } + private let repository = RepositorySummary( id: 1, rid: "repo-1", @@ -26,21 +36,21 @@ struct SRHTWebURLTests { @Test func browserOnlyServiceURLsUseCanonicalHosts() { - #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht") - #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht") + #expect(SRHTWebURL.chat.absoluteString == Expected.chatOrigin) + #expect(SRHTWebURL.status.absoluteString == Expected.statusOrigin) } @Test func repositoryAndCloneURLsUseStableUserScopedPaths() { - #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch") - #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch") - #expect(SRHTWebURL.sshCloneURL(repository) == "git@git.sr.ht:~ccleberg/hutch") + #expect(SRHTWebURL.repository(repository)?.absoluteString == Expected.gitRepoHTTPS) + #expect(SRHTWebURL.httpsCloneURL(repository) == Expected.gitRepoHTTPS) + #expect(SRHTWebURL.sshCloneURL(repository) == Expected.gitSSH) } @Test func trackerTicketAndBuildURLsUseStableUserScopedPaths() { - #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo") - #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42") - #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12") + #expect(SRHTWebURL.tracker(tracker)?.absoluteString == Expected.tracker) + #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == Expected.ticket) + #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == Expected.buildJob) } } diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift index d1577b6..d963fa4 100644 --- a/HutchTests/SystemStatusServiceTests.swift +++ b/HutchTests/SystemStatusServiceTests.swift @@ -3,6 +3,9 @@ import Testing @testable import Hutch struct SystemStatusServiceTests { + private enum Fixture { + static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/" + } @Test func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws { @@ -16,7 +19,7 @@ struct SystemStatusServiceTests { #expect(snapshot.activeIncidents.count == 1) #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack") #expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.") - #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/") + #expect(snapshot.activeIncidents[0].url?.absoluteString == Fixture.ddosIssueURL) } @Test -- cgit v1.2.3