Commit 31e9048e43
Verified · cmc
gitbay/ContentView.swift +4
| @@ -80,6 +80,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 80 | 80 | GrepView(client: client, repo: repo) |
| 81 | 81 | case .blame(let repo, let path, let ref): |
| 82 | 82 | BlameView(client: client, repo: repo, path: path, ref: ref) |
| 83 | case .refs(let repo): | |
| 84 | RefsView(client: client, repo: repo) | |
| 85 | case .milestones(let repo): | |
| 86 | MilestoneListView(client: client, repo: repo) | |
| 83 | 87 | case .commit(let repo, let sha): |
| 84 | 88 | CommitView(client: client, repo: repo, sha: sha) |
| 85 | 89 | case .commitDiff(let repo, let sha): |
gitbay/Issues/MilestoneListViewModel.swift added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `milestone list <repo> --state <s>` — milestones with their progress. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final class MilestoneListViewModel { | |
| 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | |
| 10 | case open, closed, all | |
| 11 | var id: String { rawValue } | |
| 12 | } | |
| 13 | ||
| 14 | private(set) var state: LoadState<[Milestone]> = .loading | |
| 15 | var filter: StateFilter = .open { | |
| 16 | didSet { | |
| 17 | guard filter != oldValue else { return } | |
| 18 | Task { await load() } | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | private let client: GitbayClient | |
| 23 | let repoPath: String | |
| 24 | ||
| 25 | init(client: GitbayClient, repoPath: String) { | |
| 26 | self.client = client | |
| 27 | self.repoPath = repoPath | |
| 28 | } | |
| 29 | ||
| 30 | func load() async { | |
| 31 | state = .loading | |
| 32 | do { | |
| 33 | let milestones = try await client.readList( | |
| 34 | ["milestone", "list", repoPath, "--state", filter.rawValue], of: Milestone.self | |
| 35 | ) | |
| 36 | state = milestones.isEmpty | |
| 37 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.") | |
| 38 | : .loaded(milestones) | |
| 39 | } catch { | |
| 40 | state = .from(error) | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
gitbay/MRs/MRDetailViewModel.swift +15
| @@ -66,6 +66,21 @@ final class MRDetailViewModel { | ||
| 66 | 66 | ) |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /// Open milestones for the picker; fetched on first use. | |
| 70 | private(set) var availableMilestones: [Milestone]? | |
| 71 | ||
| 72 | func loadMilestones() async { | |
| 73 | guard availableMilestones == nil else { return } | |
| 74 | availableMilestones = (try? await client.readList( | |
| 75 | ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self | |
| 76 | )) ?? [] | |
| 77 | } | |
| 78 | ||
| 79 | /// nil clears it; the command spells that "none". | |
| 80 | func setMilestone(_ title: String?) async { | |
| 81 | await perform(["mr", "milestone"] + ref + [title ?? "none"]) | |
| 82 | } | |
| 83 | ||
| 69 | 84 | func comment(_ text: String) async { |
| 70 | 85 | // Long text travels in stdin, but the server only reads it when |
| 71 | 86 | // argv says so: --file - is required, not implied. |
gitbay/MRs/MRModels.swift +2 −1
| @@ -35,6 +35,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 35 | 35 | let targetRef: String |
| 36 | 36 | let headSHA: String |
| 37 | 37 | let body: String? |
| 38 | let milestone: String? | |
| 38 | 39 | let createdAt: Date |
| 39 | 40 | let checks: [Check]? |
| 40 | 41 | let checksCombined: String? |
| @@ -44,7 +45,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 44 | 45 | let reviews: [Review]? |
| 45 | 46 | |
| 46 | 47 | enum CodingKeys: String, CodingKey { |
| 47 | case number, title, state, author, source, body, checks, commits, comments, reviews | |
| 48 | case number, title, state, author, source, body, milestone, checks, commits, comments, reviews | |
| 48 | 49 | case targetRef = "target_ref" |
| 49 | 50 | case headSHA = "head_sha" |
| 50 | 51 | case createdAt = "created_at" |
gitbay/Repos/RefsViewModel.swift added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `repo refs` — the branches and tags the server knows, so a ref is | |
| 5 | /// picked rather than typed. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class RefsViewModel { | |
| 9 | ||
| 10 | private(set) var state: LoadState<RepoRefs> = .loading | |
| 11 | var searchText = "" | |
| 12 | ||
| 13 | private let client: GitbayClient | |
| 14 | let repoPath: String | |
| 15 | /// The repo's default branch, marked in the list. | |
| 16 | private(set) var defaultBranch: String? | |
| 17 | ||
| 18 | init(client: GitbayClient, repoPath: String) { | |
| 19 | self.client = client | |
| 20 | self.repoPath = repoPath | |
| 21 | } | |
| 22 | ||
| 23 | private func matching(_ refs: [RepoRef]) -> [RepoRef] { | |
| 24 | let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() | |
| 25 | guard !query.isEmpty else { return refs } | |
| 26 | return refs.filter { $0.name.lowercased().contains(query) } | |
| 27 | } | |
| 28 | ||
| 29 | var branches: [RepoRef] { matching(state.value?.branches ?? []) } | |
| 30 | var tags: [RepoRef] { matching(state.value?.tags ?? []) } | |
| 31 | ||
| 32 | func load() async { | |
| 33 | do { | |
| 34 | async let refs = client.read(["repo", "refs", repoPath], as: RepoRefs.self) | |
| 35 | async let detail = try? client.read(["repo", "show", repoPath], as: RepoDetail.self) | |
| 36 | let loaded = try await refs | |
| 37 | defaultBranch = await detail?.defaultBranch | |
| 38 | state = (loaded.branches.isEmpty && loaded.tags.isEmpty) | |
| 39 | ? .empty("No branches or tags yet.") | |
| 40 | : .loaded(loaded) | |
| 41 | } catch { | |
| 42 | state = .from(error) | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
gitbay/Repos/RepoModels.swift +1 −3
| @@ -38,9 +38,7 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable { | ||
| 38 | 38 | var isArchived: Bool { archived ?? false } |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | /// The shape a `repo refs <owner/name>` command would return. No such | |
| 42 | /// command exists yet — krz/gitbay#45 proposes it, and until it lands | |
| 43 | /// branch names are typed, not picked. Unused on purpose. | |
| 41 | /// `repo refs <owner/name>` — the branches and tags the server knows. | |
| 44 | 42 | nonisolated struct RepoRefs: Decodable, Sendable, Hashable { |
| 45 | 43 | let branches: [RepoRef] |
| 46 | 44 | let tags: [RepoRef] |
gitbay/Views/Issues/MilestoneListView.swift added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Milestones and their progress. | |
| 4 | struct MilestoneListView: View { | |
| 5 | ||
| 6 | @State private var model: MilestoneListViewModel | |
| 7 | ||
| 8 | init(client: GitbayClient, repo: String) { | |
| 9 | _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) | |
| 10 | } | |
| 11 | ||
| 12 | var body: some View { | |
| 13 | List { | |
| 14 | Picker("State", selection: Bindable(model).filter) { | |
| 15 | ForEach(MilestoneListViewModel.StateFilter.allCases) { filter in | |
| 16 | Text(filter.rawValue.capitalized).tag(filter) | |
| 17 | } | |
| 18 | } | |
| 19 | .pickerStyle(.segmented) | |
| 20 | .listRowBackground(Color.clear) | |
| 21 | .listRowInsets(EdgeInsets()) | |
| 22 | ||
| 23 | ForEach(model.state.value ?? []) { milestone in | |
| 24 | MilestoneRow(milestone: milestone) | |
| 25 | } | |
| 26 | } | |
| 27 | .overlay { LoadStateOverlay(state: model.state) } | |
| 28 | .navigationTitle("Milestones") | |
| 29 | .navigationBarTitleDisplayMode(.inline) | |
| 30 | .task { await model.load() } | |
| 31 | .refreshable { await model.load() } | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | private struct MilestoneRow: View { | |
| 36 | let milestone: Milestone | |
| 37 | ||
| 38 | private var total: Int { milestone.open + milestone.closed } | |
| 39 | private var fraction: Double { | |
| 40 | total == 0 ? 0 : Double(milestone.closed) / Double(total) | |
| 41 | } | |
| 42 | ||
| 43 | var body: some View { | |
| 44 | VStack(alignment: .leading, spacing: 5) { | |
| 45 | HStack(spacing: 6) { | |
| 46 | Text(milestone.title) | |
| 47 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 48 | if milestone.state != "open" { | |
| 49 | GBChip(milestone.state, .gbDone) | |
| 50 | } | |
| 51 | Spacer() | |
| 52 | if let due = milestone.due, !due.isEmpty { | |
| 53 | Text(due) | |
| 54 | .font(.gbSans(.caption)) | |
| 55 | .foregroundStyle(.tertiary) | |
| 56 | } | |
| 57 | } | |
| 58 | if let description = milestone.description, !description.isEmpty { | |
| 59 | Text(description) | |
| 60 | .font(.gbSans(.caption)) | |
| 61 | .foregroundStyle(.secondary) | |
| 62 | .lineLimit(2) | |
| 63 | } | |
| 64 | if total > 0 { | |
| 65 | ProgressView(value: fraction) | |
| 66 | .tint(.gbAccent) | |
| 67 | Text("\(milestone.closed) of \(total) closed") | |
| 68 | .font(.gbSans(.caption2)) | |
| 69 | .foregroundStyle(.secondary) | |
| 70 | } | |
| 71 | } | |
| 72 | .padding(.vertical, 2) | |
| 73 | } | |
| 74 | } | |
gitbay/Views/MRs/MRView.swift +26
| @@ -34,6 +34,7 @@ struct MRView: View { | ||
| 34 | 34 | } |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | milestoneSection(mr) | |
| 37 | 38 | diffSection |
| 38 | 39 | |
| 39 | 40 | if let commits = mr.commits, !commits.isEmpty { |
| @@ -117,6 +118,31 @@ struct MRView: View { | ||
| 117 | 118 | } |
| 118 | 119 | } |
| 119 | 120 | |
| 121 | private func milestoneSection(_ mr: MRDetail) -> some View { | |
| 122 | Section("Milestone") { | |
| 123 | Menu { | |
| 124 | Button("None") { Task { await model.setMilestone(nil) } } | |
| 125 | ForEach(model.availableMilestones ?? []) { milestone in | |
| 126 | Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") { | |
| 127 | Task { await model.setMilestone(milestone.title) } | |
| 128 | } | |
| 129 | } | |
| 130 | } label: { | |
| 131 | HStack { | |
| 132 | Label(mr.milestone ?? "None", systemImage: "flag") | |
| 133 | .font(.gbSans(.subheadline)) | |
| 134 | Spacer() | |
| 135 | Image(systemName: "chevron.up.chevron.down") | |
| 136 | .font(.gbSans(.caption2)) | |
| 137 | .foregroundStyle(.secondary) | |
| 138 | } | |
| 139 | } | |
| 140 | .disabled(model.working) | |
| 141 | .task { await model.loadMilestones() } | |
| 142 | .accessibilityIdentifier("mr-milestone-menu") | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 120 | 146 | private var diffSection: some View { |
| 121 | 147 | Section { |
| 122 | 148 | NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) { |
gitbay/Views/Repos/RefsView.swift added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Branches and tags. Tapping one browses the repository at that ref. | |
| 4 | struct RefsView: View { | |
| 5 | ||
| 6 | @State private var model: RefsViewModel | |
| 7 | ||
| 8 | init(client: GitbayClient, repo: String) { | |
| 9 | _model = State(initialValue: RefsViewModel(client: client, repoPath: repo)) | |
| 10 | } | |
| 11 | ||
| 12 | var body: some View { | |
| 13 | List { | |
| 14 | if !model.branches.isEmpty { | |
| 15 | Section("Branches") { | |
| 16 | ForEach(model.branches) { ref in | |
| 17 | NavigationLink(value: RepoRoute.tree( | |
| 18 | repo: model.repoPath, directory: "", ref: ref.name | |
| 19 | )) { | |
| 20 | RefRow(ref: ref, isDefault: ref.name == model.defaultBranch) | |
| 21 | } | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | if !model.tags.isEmpty { | |
| 26 | Section("Tags") { | |
| 27 | ForEach(model.tags) { ref in | |
| 28 | NavigationLink(value: RepoRoute.tree( | |
| 29 | repo: model.repoPath, directory: "", ref: ref.name | |
| 30 | )) { | |
| 31 | RefRow(ref: ref, isDefault: false) | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | } | |
| 37 | .overlay { | |
| 38 | LoadStateOverlay( | |
| 39 | state: model.state, | |
| 40 | isEmpty: model.branches.isEmpty && model.tags.isEmpty | |
| 41 | ) | |
| 42 | } | |
| 43 | .searchable(text: Bindable(model).searchText, prompt: "Filter refs") | |
| 44 | .navigationTitle("Branches & Tags") | |
| 45 | .navigationBarTitleDisplayMode(.inline) | |
| 46 | .task { await model.load() } | |
| 47 | .refreshable { await model.load() } | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | private struct RefRow: View { | |
| 52 | let ref: RepoRef | |
| 53 | let isDefault: Bool | |
| 54 | ||
| 55 | var body: some View { | |
| 56 | HStack(spacing: 8) { | |
| 57 | Text(ref.name) | |
| 58 | .font(.gbSans(.subheadline)) | |
| 59 | .lineLimit(1) | |
| 60 | if isDefault { | |
| 61 | GBChip("default", .gbAccent) | |
| 62 | } | |
| 63 | Spacer() | |
| 64 | Text(String(ref.sha.prefix(10))) | |
| 65 | .font(.gbMono(.caption2)) | |
| 66 | .foregroundStyle(.tertiary) | |
| 67 | } | |
| 68 | } | |
| 69 | } | |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -10,6 +10,8 @@ nonisolated enum RepoRoute: Hashable { | ||
| 10 | 10 | case settings(repo: String) |
| 11 | 11 | case grep(repo: String) |
| 12 | 12 | case blame(repo: String, path: String, ref: String?) |
| 13 | case refs(repo: String) | |
| 14 | case milestones(repo: String) | |
| 13 | 15 | case commit(repo: String, sha: String) |
| 14 | 16 | case commitDiff(repo: String, sha: String) |
| 15 | 17 | case profile(String) |
gitbay/Views/Repos/RepoView.swift +6
| @@ -29,12 +29,18 @@ struct RepoView: View { | ||
| 29 | 29 | NavigationLink(value: RepoRoute.log(repo: path)) { |
| 30 | 30 | Label("History", systemImage: "clock") |
| 31 | 31 | } |
| 32 | NavigationLink(value: RepoRoute.refs(repo: path)) { | |
| 33 | Label("Branches & Tags", systemImage: "arrow.triangle.branch") | |
| 34 | } | |
| 32 | 35 | NavigationLink(value: MRRoute.list(repo: path)) { |
| 33 | 36 | Label("Merge Requests", systemImage: "arrow.triangle.merge") |
| 34 | 37 | } |
| 35 | 38 | NavigationLink(value: IssueRoute.list(repo: path)) { |
| 36 | 39 | Label("Issues", systemImage: "smallcircle.filled.circle") |
| 37 | 40 | } |
| 41 | NavigationLink(value: RepoRoute.milestones(repo: path)) { | |
| 42 | Label("Milestones", systemImage: "flag") | |
| 43 | } | |
| 38 | 44 | NavigationLink(value: BuildRoute.list(repo: path)) { |
| 39 | 45 | Label("Builds", systemImage: "hammer") |
| 40 | 46 | } |
gitbayTests/RefsMilestoneTests.swift added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return try #require(body["argv"] as? [String]) | |
| 18 | } | |
| 19 | ||
| 20 | private let refsJSON = """ | |
| 21 | {"protocol_version":1,"data":{"branches":[\ | |
| 22 | {"name":"main","sha":"aaaaaaaaaabbbbbbbbbb"},{"name":"feature","sha":"cccccccccc"}],\ | |
| 23 | "tags":[{"name":"v1.0.0","sha":"dddddddddd"}]},"exit_code":0} | |
| 24 | """ | |
| 25 | private let repoShowJSON = """ | |
| 26 | {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\ | |
| 27 | "default_branch":"main"},"exit_code":0} | |
| 28 | """ | |
| 29 | private let milestonesJSON = """ | |
| 30 | {"protocol_version":1,"data":[\ | |
| 31 | {"title":"v1.0.0","description":"launch","state":"open","open":3,"closed":9},\ | |
| 32 | {"title":"v0.5.0","state":"closed","open":0,"closed":4}],"exit_code":0} | |
| 33 | """ | |
| 34 | ||
| 35 | @MainActor | |
| 36 | struct RefsViewModelTests { | |
| 37 | ||
| 38 | private func loadedModel() async throws -> (RefsViewModel, StubProtocol.Box) { | |
| 39 | let (client, stub) = try makeClient() | |
| 40 | stub.enqueue(.init(status: 200, json: refsJSON, match: "argv=refs")) | |
| 41 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show")) | |
| 42 | let model = RefsViewModel(client: client, repoPath: "krz/gitbay") | |
| 43 | await model.load() | |
| 44 | return (model, stub) | |
| 45 | } | |
| 46 | ||
| 47 | @Test func loadsBranchesTagsAndTheDefault() async throws { | |
| 48 | let (model, stub) = try await loadedModel() | |
| 49 | ||
| 50 | #expect(model.branches.map(\.name) == ["main", "feature"]) | |
| 51 | #expect(model.tags.map(\.name) == ["v1.0.0"]) | |
| 52 | #expect(model.defaultBranch == "main") | |
| 53 | #expect(stub.seen.contains { $0.url.query() == "argv=repo&argv=refs&argv=krz/gitbay" }) | |
| 54 | } | |
| 55 | ||
| 56 | @Test func theFilterMatchesBothBranchesAndTags() async throws { | |
| 57 | let (model, _) = try await loadedModel() | |
| 58 | ||
| 59 | model.searchText = "v1" | |
| 60 | #expect(model.branches.isEmpty) | |
| 61 | #expect(model.tags.map(\.name) == ["v1.0.0"]) | |
| 62 | ||
| 63 | model.searchText = "feat" | |
| 64 | #expect(model.branches.map(\.name) == ["feature"]) | |
| 65 | #expect(model.tags.isEmpty) | |
| 66 | } | |
| 67 | ||
| 68 | @Test func aRepoWithoutRefsIsAnEmptyState() async throws { | |
| 69 | let (client, stub) = try makeClient() | |
| 70 | stub.enqueue(.init(status: 200, json: | |
| 71 | #"{"protocol_version":1,"data":{"branches":[],"tags":[]},"exit_code":0}"#, | |
| 72 | match: "argv=refs")) | |
| 73 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show")) | |
| 74 | let model = RefsViewModel(client: client, repoPath: "krz/empty") | |
| 75 | ||
| 76 | await model.load() | |
| 77 | ||
| 78 | guard case .empty = model.state else { | |
| 79 | Issue.record("expected .empty, got \(model.state)") | |
| 80 | return | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | @MainActor | |
| 86 | struct MilestoneListViewModelTests { | |
| 87 | ||
| 88 | @Test func loadsMilestonesWithProgress() async throws { | |
| 89 | let (client, stub) = try makeClient() | |
| 90 | stub.enqueue(.init(status: 200, json: milestonesJSON)) | |
| 91 | let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") | |
| 92 | ||
| 93 | await model.load() | |
| 94 | ||
| 95 | let milestones = try #require(model.state.value) | |
| 96 | #expect(milestones.map(\.title) == ["v1.0.0", "v0.5.0"]) | |
| 97 | #expect(milestones[0].open == 3 && milestones[0].closed == 9) | |
| 98 | #expect(stub.seen.first?.url.query() == | |
| 99 | "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open") | |
| 100 | } | |
| 101 | ||
| 102 | @Test func changingTheFilterReloadsWithThatState() async throws { | |
| 103 | let (client, stub) = try makeClient() | |
| 104 | stub.enqueue(.init(status: 200, json: milestonesJSON)) | |
| 105 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) | |
| 106 | let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") | |
| 107 | await model.load() | |
| 108 | ||
| 109 | model.filter = .closed | |
| 110 | try await Task.sleep(for: .milliseconds(300)) | |
| 111 | ||
| 112 | #expect(stub.seen.count == 2) | |
| 113 | #expect(stub.seen[1].url.query()?.contains("argv=closed") == true) | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | @MainActor | |
| 118 | struct MRMilestoneTests { | |
| 119 | ||
| 120 | private let mrShow = """ | |
| 121 | {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ | |
| 122 | "source":"b","target_ref":"main","head_sha":"aa","milestone":"v1.0.0",\ | |
| 123 | "created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0} | |
| 124 | """ | |
| 125 | ||
| 126 | private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) { | |
| 127 | let (client, stub) = try makeClient() | |
| 128 | stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) | |
| 129 | stub.enqueue(.init(status: 200, json: | |
| 130 | #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) | |
| 131 | stub.enqueue(.init(status: 200, json: | |
| 132 | #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) | |
| 133 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 134 | await model.load() | |
| 135 | return (model, stub) | |
| 136 | } | |
| 137 | ||
| 138 | @Test func mrShowCarriesItsMilestone() async throws { | |
| 139 | let (model, _) = try await loadedModel() | |
| 140 | // Previously a blind write: you could set it and nothing read back. | |
| 141 | #expect(model.state.value?.milestone == "v1.0.0") | |
| 142 | } | |
| 143 | ||
| 144 | @Test func settingAndClearingUseTheCommandsSpelling() async throws { | |
| 145 | let (model, stub) = try await loadedModel() | |
| 146 | for _ in 0..<2 { | |
| 147 | stub.enqueue(.init(status: 200, json: | |
| 148 | #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) | |
| 149 | stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) | |
| 150 | stub.enqueue(.init(status: 200, json: | |
| 151 | #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) | |
| 152 | stub.enqueue(.init(status: 200, json: | |
| 153 | #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) | |
| 154 | } | |
| 155 | ||
| 156 | await model.setMilestone("v1.1.0") | |
| 157 | await model.setMilestone(nil) | |
| 158 | ||
| 159 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 160 | #expect(writes[0] == ["mr", "milestone", "krz/gitbay", "7", "v1.1.0"]) | |
| 161 | #expect(writes[1] == ["mr", "milestone", "krz/gitbay", "7", "none"]) | |
| 162 | } | |
| 163 | } | |
gitbayUITests/LiveSmokeUITests.swift +66
| @@ -156,6 +156,18 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 156 | 156 | keys.tap() |
| 157 | 157 | } |
| 158 | 158 | |
| 159 | /// Scroll a list until an element is in the hierarchy. Offscreen | |
| 160 | /// rows do not exist to XCUITest, so waitForExistence alone fails on | |
| 161 | /// anything below the fold. | |
| 162 | @discardableResult | |
| 163 | func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool { | |
| 164 | for _ in 0..<swipes { | |
| 165 | if element.exists { return true } | |
| 166 | app.swipeUp() | |
| 167 | } | |
| 168 | return element.exists | |
| 169 | } | |
| 170 | ||
| 159 | 171 | /// Switch tabs and wait until that tab is actually front. A tap |
| 160 | 172 | /// dispatched before the app is interactive — which happens right |
| 161 | 173 | /// after launch when there is no sign-in to slow things down — is |
| @@ -700,3 +712,57 @@ extension LiveSmokeUITests { | ||
| 700 | 712 | "commit diff rendered nothing") |
| 701 | 713 | } |
| 702 | 714 | } |
| 715 | ||
| 716 | extension LiveSmokeUITests { | |
| 717 | ||
| 718 | /// The three gaps that needed no server work: branches and tags, | |
| 719 | /// milestones, and an MR's milestone. Read-only except the MR | |
| 720 | /// milestone, which is set and cleared back. | |
| 721 | func testRefsAndMilestoneFlows() throws { | |
| 722 | openRepo("krz/gitbay") | |
| 723 | ||
| 724 | // --- branches and tags, and browsing at a ref --- | |
| 725 | app.staticTexts["Branches & Tags"].firstMatch.tap() | |
| 726 | let main = app.staticTexts["main"].firstMatch | |
| 727 | XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load") | |
| 728 | XCTAssertTrue(app.staticTexts["default"].firstMatch.exists, | |
| 729 | "the default branch is not marked") | |
| 730 | XCTAssertTrue(app.staticTexts["Tags"].firstMatch.exists, "tags section missing") | |
| 731 | main.tap() | |
| 732 | // Tapping a ref browses the repository there. | |
| 733 | XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), | |
| 734 | "browsing at a ref showed nothing") | |
| 735 | back() | |
| 736 | back() | |
| 737 | ||
| 738 | // --- milestones with progress --- | |
| 739 | app.staticTexts["Milestones"].firstMatch.tap() | |
| 740 | XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15), | |
| 741 | "milestones did not load") | |
| 742 | app.segmentedControls.buttons["All"].firstMatch.tap() | |
| 743 | XCTAssertTrue(app.staticTexts | |
| 744 | .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch | |
| 745 | .waitForExistence(timeout: 20), "milestone progress missing") | |
| 746 | back() | |
| 747 | ||
| 748 | // --- an MR's milestone reads back, and can be cleared --- | |
| 749 | app.staticTexts["Merge Requests"].firstMatch.tap() | |
| 750 | app.segmentedControls.buttons["All"].firstMatch.tap() | |
| 751 | let mrNumber = app.staticTexts | |
| 752 | .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch | |
| 753 | XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs") | |
| 754 | mrNumber.tap() | |
| 755 | let milestone = app.descendants(matching: .any) | |
| 756 | .matching(identifier: "mr-milestone-menu").firstMatch | |
| 757 | // The nav bar is the "opened" signal; everything else on this | |
| 758 | // screen can be below the fold. | |
| 759 | XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20), | |
| 760 | "MR detail did not open") | |
| 761 | XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing") | |
| 762 | milestone.tap() | |
| 763 | // The picker offers None plus the open milestones. | |
| 764 | XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10), | |
| 765 | "milestone picker did not open") | |
| 766 | app.buttons["None"].firstMatch.tap() | |
| 767 | } | |
| 768 | } | |