diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 4d5346c..61f8ee3 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -40,6 +40,10 @@ 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; }; 54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */; }; 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; }; + 13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */; }; + 46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */; }; + 7FE95983BF8B17122E22BCEC /* ListContributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A977FEABD137038AA221BC7 /* ListContributor.swift */; }; + FC8FE4C9D951A4C0081C2793 /* ListContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */; }; 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; }; 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; }; 58151C239F0FDD01BD1BAEBB /* LinkedInPostingTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */; }; @@ -253,6 +257,10 @@ 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentSyncTests.swift; sourceTree = ""; }; 47F77792CB1F50F6A8AC6111 /* AIStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIStatus.swift; sourceTree = ""; }; 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURLTests.swift; sourceTree = ""; }; + 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientListContributorsTests.swift; sourceTree = ""; }; + 4A977FEABD137038AA221BC7 /* ListContributor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributor.swift; sourceTree = ""; }; + 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+ListContributors.swift"; sourceTree = ""; }; + F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributorsView.swift; sourceTree = ""; }; 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURL.swift; sourceTree = ""; }; 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedResolverTests.swift; sourceTree = ""; }; 4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageLinkView.swift; sourceTree = ""; }; @@ -495,6 +503,7 @@ A1B1C1D1E1F1002E /* List.swift */, A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */, A2B2C2D2E2F20042 /* AppNotification.swift */, + 4A977FEABD137038AA221BC7 /* ListContributor.swift */, A2B2C2D2E2F20044 /* FollowState.swift */, A3C3D3E3F3A30052 /* Document.swift */, C1D1E1F1A1B10002 /* Organization.swift */, @@ -530,6 +539,7 @@ A1B1C1D1E1F1000A /* KeychainService.swift */, A1B1C1D1E1F1000C /* APIClient.swift */, A1B1C1D1E1F1000E /* AuthState.swift */, + 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */, A3C3D3E3F3A30056 /* URLSessionProtocol.swift */, B1C1D1E1F1A10002 /* DataCache.swift */, B1C1D1E1F1A10004 /* AppDataStore.swift */, @@ -567,6 +577,7 @@ A1B1C1D1E1F10032 /* EditMessageView.swift */, A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */, A1B1C1D1E1F10034 /* MessageThreadView.swift */, + F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */, A1B1C1D1E1F10036 /* CreateListView.swift */, A1B1C1D1E1F10038 /* UserProfileView.swift */, A1B1C1D1E1F1003A /* ScheduledMessagesView.swift */, @@ -644,6 +655,7 @@ T1E5T1E5T1E5N002 /* APIClientMessagesTests.swift */, T1E5T1E5T1E5N004 /* APIClientListsTests.swift */, T1E5T1E5T1E5N006 /* APIClientDocumentsTests.swift */, + 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */, T1E5T1E5T1E5N008 /* APIClientNotificationsTests.swift */, T1E5T1E5T1E5N010 /* APIClientFollowTests.swift */, T1E5T1E5T1E5N012 /* APIClientProfileTests.swift */, @@ -851,6 +863,9 @@ A1B1C1D1E1F100A3 /* ListSchemaDraft.swift in Sources */, A1B1C1D1E1F10033 /* MessageThreadView.swift in Sources */, A1B1C1D1E1F10035 /* CreateListView.swift in Sources */, + 7FE95983BF8B17122E22BCEC /* ListContributor.swift in Sources */, + 46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */, + FC8FE4C9D951A4C0081C2793 /* ListContributorsView.swift in Sources */, A1B1C1D1E1F10037 /* UserProfileView.swift in Sources */, A1B1C1D1E1F10039 /* ScheduledMessagesView.swift in Sources */, A2B2C2D2E2F20041 /* AppNotification.swift in Sources */, @@ -966,6 +981,7 @@ T1E5T1E5T1E5M003 /* AppNotificationModelTests.swift in Sources */, T1E5T1E5T1E5M005 /* FollowStateModelTests.swift in Sources */, T1E5T1E5T1E5M007 /* DocumentModelTests.swift in Sources */, + 13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */, T1E5T1E5T1E5N001 /* APIClientMessagesTests.swift in Sources */, T1E5T1E5T1E5N003 /* APIClientListsTests.swift in Sources */, T1E5T1E5T1E5N005 /* APIClientDocumentsTests.swift in Sources */, diff --git a/InterlinedList/Models/ListContributor.swift b/InterlinedList/Models/ListContributor.swift new file mode 100644 index 0000000..c2d495a --- /dev/null +++ b/InterlinedList/Models/ListContributor.swift @@ -0,0 +1,50 @@ +// +// ListContributor.swift +// InterlinedList +// + +import Foundation + +/// A person who has actually written rows into a list, aggregated by the backend +/// across every row's created-by / last-edited-by attribution. +/// +/// Distinct from `ListWatcher`: a watcher has been *granted* access, a +/// contributor has *used* it. On a shared list the latter is the more useful +/// signal, which is why the web surfaces it on the list header. +struct ListContributor: Identifiable, Codable { + let id: String + let username: String + let displayName: String? + let avatar: String? + /// Rows this person created. + let addedCount: Int + /// Rows this person was the last to edit. + let editedCount: Int + /// `addedCount + editedCount` — the server's ranking key. + let score: Int + + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } + + /// Matches the web's contributor subtitle: "added 4 · edited 2". + var contributionSummary: String { + "added \(addedCount) · edited \(editedCount)" + } +} + +/// `GET /api/lists/{id}/contributors` — the **full** ranked set with no server +/// paging, so `totalContributors` always equals `contributors.count`. It is +/// decoded anyway because it is what the web's "+N" overflow bubble counts +/// against, and trusting the server's own total keeps iOS and web in step. +struct ListContributorsResult: Decodable { + let contributors: [ListContributor] + let totalContributors: Int + + static let empty = ListContributorsResult(contributors: [], totalContributors: 0) + + init(contributors: [ListContributor], totalContributors: Int) { + self.contributors = contributors + self.totalContributors = totalContributors + } +} diff --git a/InterlinedList/Services/APIClient+ListContributors.swift b/InterlinedList/Services/APIClient+ListContributors.swift new file mode 100644 index 0000000..e7f0765 --- /dev/null +++ b/InterlinedList/Services/APIClient+ListContributors.swift @@ -0,0 +1,25 @@ +// +// APIClient+ListContributors.swift +// InterlinedList +// + +import Foundation + +/// Who has actually contributed rows to a list. +/// +/// Read-only: this extension adds no write path. Row mutations — GitHub-backed +/// ones especially, which must send the full row through `updateItem` — are +/// untouched by anything here. +extension APIClient { + + /// `GET /api/lists/{id}/contributors` — the full ranked contributor set, + /// ordered by score (added + edited) descending, then added, then username. + /// The route does not page. + /// + /// Returns an empty set for GitHub-backed lists by design; callers should + /// skip the request entirely for those rather than rely on the empty answer. + func listContributors(listId: String) async throws -> ListContributorsResult { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + return try await get("/api/lists/\(encoded)/contributors") + } +} diff --git a/InterlinedList/Views/ListContributorsView.swift b/InterlinedList/Views/ListContributorsView.swift new file mode 100644 index 0000000..4b0e48c --- /dev/null +++ b/InterlinedList/Views/ListContributorsView.swift @@ -0,0 +1,158 @@ +// +// ListContributorsView.swift +// InterlinedList +// + +import SwiftUI + +/// Cascading avatars for the top contributors to a list, sized to sit inline in +/// the list detail header. Overflow beyond `maxAvatars` collapses into a "+N" +/// bubble, mirroring the web's `ContributorAvatarStack`. +struct ContributorAvatarStack: View { + let contributors: [ListContributor] + let total: Int + var size: CGFloat = 28 + var maxAvatars: Int = 3 + + private var shown: [ListContributor] { Array(contributors.prefix(maxAvatars)) } + private var overflow: Int { max(0, total - shown.count) } + private var overlap: CGFloat { size * 0.35 } + + var body: some View { + HStack(spacing: -overlap) { + ForEach(shown) { contributor in + ContributorAvatar(avatar: contributor.avatar, size: size) + .overlay(Circle().stroke(ILColor.surface, lineWidth: 2)) + } + if overflow > 0 { + Text("+\(overflow)") + .font(.ilMono(size * 0.4)) + .foregroundStyle(.white) + .frame(width: size, height: size) + .background(Color.secondary, in: Circle()) + .overlay(Circle().stroke(ILColor.surface, lineWidth: 2)) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Self.label(for: total)) + } + + static func label(for total: Int) -> String { + total == 1 ? "1 contributor" : "\(total) contributors" + } +} + +/// The full ranked contributor set. The route returns everything at once, so +/// this scrolls rather than paging (the web's 6-per-page modal is a desktop +/// affordance). Row style mirrors `WatchersListView`. +struct ListContributorsSheet: View { + let contributors: [ListContributor] + let total: Int + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if contributors.isEmpty { + ContentUnavailableView { + Label("No contributors yet", systemImage: "person.2") + } description: { + Text("Nobody has added or edited rows on this list.") + } + } else { + ForEach(contributors) { contributor in + ContributorRow(contributor: contributor) + } + } + } + .navigationTitle("Contributors (\(total))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +private struct ContributorRow: View { + let contributor: ListContributor + + var body: some View { + HStack(spacing: 12) { + ContributorAvatar(avatar: contributor.avatar, size: 36) + VStack(alignment: .leading, spacing: 2) { + Text(contributor.displayNameOrUsername) + .font(.ilBody()) + Text("@\(contributor.username) · \(contributor.contributionSummary)") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(contributor.score)") + .font(.ilMono()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(ILColor.surface2) + .clipShape(Capsule()) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(contributor.displayNameOrUsername), added \(contributor.addedCount), edited \(contributor.editedCount)" + ) + } +} + +/// Circular remote avatar with a person-glyph fallback, matching the watcher row. +private struct ContributorAvatar: View { + let avatar: String? + let size: CGFloat + + var body: some View { + if let url = avatar.flatMap({ URL(string: $0) }) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + placeholder + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } else { + placeholder + .frame(width: size, height: size) + .clipShape(Circle()) + } + } + + private var placeholder: some View { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + } +} + +#Preview("Stack") { + ContributorAvatarStack(contributors: ListContributor.previewSet, total: 5) + .padding() +} + +#Preview("Sheet") { + ListContributorsSheet(contributors: ListContributor.previewSet, total: 3) +} + +private extension ListContributor { + static let previewSet: [ListContributor] = [ + ListContributor(id: "u1", username: "adron", displayName: "Adron Hall", + avatar: nil, addedCount: 12, editedCount: 4, score: 16), + ListContributor(id: "u2", username: "rhea", displayName: "Rhea Kim", + avatar: nil, addedCount: 7, editedCount: 1, score: 8), + ListContributor(id: "u3", username: "sam", displayName: nil, + avatar: nil, addedCount: 2, editedCount: 3, score: 5) + ] +} diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index d33b711..681ec64 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -399,6 +399,8 @@ struct ListDetailView: View { @State private var isRefreshingGitHub = false @State private var gitHubRefreshError: String? @State private var gitHubStateFilter: GitHubStateFilter = .open + @State private var contributors = ListContributorsResult.empty + @State private var showContributors = false /// Open/closed filter for GitHub-backed lists. GitHub issues carry a `state` /// of `open`/`closed`; the list defaults to showing open issues only. @@ -445,6 +447,13 @@ struct ListDetailView: View { : "No closed issues. Switch to Open to see open issues." } + /// GitHub-backed lists have no contributor set — the route returns empty by + /// design — and a single-owner list with nobody else on it is noise, so the + /// stack only appears from two distinct contributors up (matching the web). + private var showsContributors: Bool { + !list.isGitHubBacked && contributors.totalContributors >= 2 + } + var body: some View { Group { if isLoading && items.isEmpty { @@ -463,6 +472,9 @@ struct ListDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { + if showsContributors { + contributorsSection + } if list.isGitHubBacked { gitHubStatusSection if !items.isEmpty { @@ -664,6 +676,12 @@ struct ListDetailView: View { Task { await saveEdit(item: item, rowData: rowData) } } } + .sheet(isPresented: $showContributors) { + ListContributorsSheet( + contributors: contributors.contributors, + total: contributors.totalContributors + ) + } .confirmationDialog("Delete this item?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { Button("Delete", role: .destructive) { if let item = deletingItem { Task { await deleteItem(item) } } @@ -672,6 +690,33 @@ struct ListDetailView: View { } } + @ViewBuilder + private var contributorsSection: some View { + Section { + Button { + showContributors = true + } label: { + HStack(spacing: 10) { + ContributorAvatarStack( + contributors: contributors.contributors, + total: contributors.totalContributors + ) + Text(ContributorAvatarStack.label(for: contributors.totalContributors)) + .font(.ilBody(15)) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "chevron.right") + .font(.ilBody(12)) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ContributorAvatarStack.label(for: contributors.totalContributors)) + .accessibilityHint("Opens the full list of contributors") + } + } + @ViewBuilder private var gitHubStatusSection: some View { Section { @@ -844,6 +889,7 @@ struct ListDetailView: View { async let itemsTask = APIClient.shared.listItems(listId: list.id) async let connectionsTask = APIClient.shared.listConnections() async let allListsTask = APIClient.shared.lists() + async let contributorsTask = fetchContributors() let (fetchedSchema, fetchedItems) = try await (schemaTask, itemsTask) // The server returns the synthetic issue schema for GitHub-backed // lists (backend fix). The client fallback is a safety net for an @@ -858,6 +904,7 @@ struct ListDetailView: View { connections = (try? await connectionsTask)? .filter { $0.fromListId == listId || $0.toListId == listId } ?? [] allLists = (try? await allListsTask) ?? [] + contributors = await contributorsTask } catch APIError.status(401) { authState.handleUnauthorized() errorMessage = "Session expired or not authorized." @@ -867,6 +914,14 @@ struct ListDetailView: View { errorMessage = "Failed to load list." } } + + /// Non-fatal: a failed or forbidden contributor fetch simply leaves the stack + /// hidden rather than failing the whole list. Skipped entirely for + /// GitHub-backed lists, whose contributor set is empty by design. + private func fetchContributors() async -> ListContributorsResult { + guard !list.isGitHubBacked else { return .empty } + return (try? await APIClient.shared.listContributors(listId: list.id)) ?? .empty + } } // MARK: - Dynamic item row diff --git a/InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift b/InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift new file mode 100644 index 0000000..e7ec22c --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientListContributorsTests.swift @@ -0,0 +1,147 @@ +import XCTest +@testable import InterlinedList + +/// W6 (#58). `GET /api/lists/{id}/contributors` returns the full ranked +/// contributor set with no server paging. These tests pin the route, the decode +/// of the aggregation counts the header stack renders, and the two shapes that +/// must degrade quietly: the empty set GitHub-backed lists get by design, and a +/// 403 on a list the viewer can read but not aggregate. +final class APIClientListContributorsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + /// Live shape: camelCase keys straight off `serialize()`, ranked by score + /// descending, with `totalContributors` alongside. + private var contributorsJSON: String { + #""" + {"contributors":[ + {"id":"u1","username":"adron","displayName":"Adron Hall", + "avatar":"https://example.com/a.png","addedCount":12,"editedCount":4,"score":16}, + {"id":"u2","username":"rhea","displayName":"Rhea Kim", + "avatar":null,"addedCount":7,"editedCount":1,"score":8}, + {"id":"u3","username":"sam","displayName":null, + "avatar":null,"addedCount":2,"editedCount":3,"score":5} + ], + "totalContributors":3} + """# + } + + func test_listContributors_sendsGetToContributorsPath() async throws { + session.stub(json: contributorsJSON) + _ = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/list-1/contributors") + } + + func test_listContributors_percentEncodesListId() async throws { + session.stub(json: contributorsJSON) + _ = try await sut.listContributors(listId: "list one") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/list one/contributors") + XCTAssertTrue( + session.lastRequest?.url?.absoluteString.contains("list%20one") == true, + session.lastRequest?.url?.absoluteString ?? "nil" + ) + } + + func test_listContributors_decodesFullSetAndTotal() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(result.totalContributors, 3) + XCTAssertEqual(result.contributors.count, 3) + } + + func test_listContributors_decodesAggregationCounts() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + let top = try XCTUnwrap(result.contributors.first) + XCTAssertEqual(top.id, "u1") + XCTAssertEqual(top.username, "adron") + XCTAssertEqual(top.displayName, "Adron Hall") + XCTAssertEqual(top.avatar, "https://example.com/a.png") + XCTAssertEqual(top.addedCount, 12) + XCTAssertEqual(top.editedCount, 4) + XCTAssertEqual(top.score, 16) + } + + /// The server ranks; the client must not re-sort or the stack would disagree + /// with the web's order. + func test_listContributors_preservesServerRanking() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(result.contributors.map(\.id), ["u1", "u2", "u3"]) + XCTAssertEqual(result.contributors.map(\.score), [16, 8, 5]) + } + + /// `displayName` is nullable; the row headline falls back to the username. + func test_contributor_nullDisplayNameFallsBackToUsername() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + let sam = try XCTUnwrap(result.contributors.last) + XCTAssertNil(sam.displayName) + XCTAssertEqual(sam.displayNameOrUsername, "sam") + XCTAssertEqual(result.contributors[0].displayNameOrUsername, "Adron Hall") + } + + /// An empty displayName is as good as absent — same fallback. + func test_contributor_emptyDisplayNameFallsBackToUsername() throws { + let json = #"{"id":"u9","username":"kit","displayName":"","avatar":null,"addedCount":1,"editedCount":0,"score":1}"# + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let contributor = try decoder.decode(ListContributor.self, from: Data(json.utf8)) + XCTAssertEqual(contributor.displayNameOrUsername, "kit") + } + + /// Matches the web's contributor subtitle exactly. + func test_contributor_contributionSummaryMatchesWebCopy() async throws { + session.stub(json: contributorsJSON) + let result = try await sut.listContributors(listId: "list-1") + XCTAssertEqual(result.contributors[0].contributionSummary, "added 12 · edited 4") + XCTAssertEqual(result.contributors[1].contributionSummary, "added 7 · edited 1") + } + + /// What a GitHub-backed list returns by design. It must decode cleanly, not throw. + func test_listContributors_emptySetDecodes() async throws { + session.stub(json: #"{"contributors":[],"totalContributors":0}"#) + let result = try await sut.listContributors(listId: "gh-list") + XCTAssertTrue(result.contributors.isEmpty) + XCTAssertEqual(result.totalContributors, 0) + } + + func test_listContributors_403_throws() async { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.listContributors(listId: "list-1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_listContributors_401_throws() async { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.listContributors(listId: "list-1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + /// The stack's accessibility label singularizes at one. + func test_avatarStackLabel_pluralization() { + XCTAssertEqual(ContributorAvatarStack.label(for: 1), "1 contributor") + XCTAssertEqual(ContributorAvatarStack.label(for: 3), "3 contributors") + XCTAssertEqual(ContributorAvatarStack.label(for: 0), "0 contributors") + } +}