Commit 664ba82d6a
Verified · cmc
Layout: unified · split
gitbay/Issues/IssueDetailViewModel.swift +10
| @@ -73,6 +73,16 @@ final class IssueDetailViewModel { | |||
| 73 | await perform(argv, stdin: text) | 73 | await perform(argv, stdin: text) |
| 74 | } | 74 | } |
| 75 | 75 | ||
| 76 | /// React to the issue, or to one of its comments. A reaction the | ||
| 77 | /// account already gave is taken back. | ||
| 78 | func react(_ name: String, comment: Int64?) async { | ||
| 79 | let given = comment == nil | ||
| 80 | ? state.value?.reactions | ||
| 81 | : state.value?.comments?.first { $0.commentID == comment }?.reactions | ||
| 82 | await perform(Reaction.argv(noun: "issue", repo: repoPath, number: number, comment: comment, | ||
| 83 | name: name, remove: Reaction.remove(name, given: given))) | ||
| 84 | } | ||
| 85 | |||
| 76 | func close() async { | 86 | func close() async { |
| 77 | await perform(["issue", "close"] + ref) | 87 | await perform(["issue", "close"] + ref) |
| 78 | } | 88 | } |
gitbay/Issues/IssueModels.swift +7 −2
| @@ -33,10 +33,11 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable { | |||
| 33 | let body: String? | 33 | let body: String? |
| 34 | let bodyFormat: String? | 34 | let bodyFormat: String? |
| 35 | let createdAt: Date | 35 | let createdAt: Date |
| 36 | let reactions: [ReactionCount]? | ||
| 36 | let comments: [Comment]? | 37 | let comments: [Comment]? |
| 37 | 38 | ||
| 38 | enum CodingKeys: String, CodingKey { | 39 | enum CodingKeys: String, CodingKey { |
| 39 | case number, title, state, author, milestone, labels, assignees, body, comments | 40 | case number, title, state, author, milestone, labels, assignees, body, reactions, comments |
| 40 | case bodyFormat = "body_format" | 41 | case bodyFormat = "body_format" |
| 41 | case createdAt = "created_at" | 42 | case createdAt = "created_at" |
| 42 | } | 43 | } |
| @@ -44,13 +45,17 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable { | |||
| 44 | var isOpen: Bool { state == "open" } | 45 | var isOpen: Bool { state == "open" } |
| 45 | 46 | ||
| 46 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { | 47 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { |
| 48 | /// What `--comment` names. Absent from a server older than v1.39.0. | ||
| 49 | let commentID: Int64? | ||
| 47 | let author: String | 50 | let author: String |
| 48 | let body: String | 51 | let body: String |
| 49 | let bodyFormat: String? | 52 | let bodyFormat: String? |
| 50 | let createdAt: Date | 53 | let createdAt: Date |
| 54 | let reactions: [ReactionCount]? | ||
| 51 | 55 | ||
| 52 | enum CodingKeys: String, CodingKey { | 56 | enum CodingKeys: String, CodingKey { |
| 53 | case author, body | 57 | case author, body, reactions |
| 58 | case commentID = "id" | ||
| 54 | case bodyFormat = "body_format" | 59 | case bodyFormat = "body_format" |
| 55 | case createdAt = "created_at" | 60 | case createdAt = "created_at" |
| 56 | } | 61 | } |
gitbay/Issues/Reaction.swift added +49
| @@ -0,0 +1,49 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One reaction on an issue, a merge request, or one of their comments, | ||
| 4 | /// as `issue show` and `mr show` carry it. | ||
| 5 | nonisolated struct ReactionCount: Decodable, Sendable, Hashable, Identifiable { | ||
| 6 | let reaction: String | ||
| 7 | let count: Int | ||
| 8 | /// The signed-in account gave this one. | ||
| 9 | let me: Bool | ||
| 10 | |||
| 11 | var id: String { reaction } | ||
| 12 | } | ||
| 13 | |||
| 14 | /// The reaction vocabulary, named as the server stores it. The order is | ||
| 15 | /// the server's display order, so the picker reads the way the web's does. | ||
| 16 | nonisolated struct Reaction: Sendable, Hashable, Identifiable { | ||
| 17 | let name: String | ||
| 18 | let emoji: String | ||
| 19 | |||
| 20 | var id: String { name } | ||
| 21 | |||
| 22 | static let all: [Reaction] = [ | ||
| 23 | .init(name: "+1", emoji: "👍"), .init(name: "-1", emoji: "👎"), | ||
| 24 | .init(name: "laugh", emoji: "😄"), .init(name: "hooray", emoji: "🎉"), | ||
| 25 | .init(name: "confused", emoji: "😕"), .init(name: "heart", emoji: "❤️"), | ||
| 26 | .init(name: "rocket", emoji: "🚀"), .init(name: "eyes", emoji: "👀"), | ||
| 27 | ] | ||
| 28 | |||
| 29 | /// A name a newer server added draws as its name rather than vanishing. | ||
| 30 | static func emoji(for name: String) -> String { | ||
| 31 | all.first { $0.name == name }?.emoji ?? name | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Picking a reaction the account already gave takes it back, the way | ||
| 35 | /// the web's toggle does. | ||
| 36 | static func remove(_ name: String, given: [ReactionCount]?) -> Bool { | ||
| 37 | given?.contains { $0.reaction == name && $0.me } ?? false | ||
| 38 | } | ||
| 39 | |||
| 40 | /// `issue react` / `mr react <owner/name> <n> [--comment <id>] <reaction> [--remove]`. | ||
| 41 | static func argv(noun: String, repo: String, number: Int64, comment: Int64?, | ||
| 42 | name: String, remove: Bool) -> [String] { | ||
| 43 | var argv = [noun, "react", repo, String(number)] | ||
| 44 | if let comment { argv += ["--comment", String(comment)] } | ||
| 45 | argv.append(name) | ||
| 46 | if remove { argv.append("--remove") } | ||
| 47 | return argv | ||
| 48 | } | ||
| 49 | } | ||
gitbay/MRs/MRDetailViewModel.swift +10
| @@ -114,6 +114,16 @@ final class MRDetailViewModel { | |||
| 114 | await perform(argv, stdin: text) | 114 | await perform(argv, stdin: text) |
| 115 | } | 115 | } |
| 116 | 116 | ||
| 117 | /// React to the merge request, or to one of its comments. A reaction the | ||
| 118 | /// account already gave is taken back. | ||
| 119 | func react(_ name: String, comment: Int64?) async { | ||
| 120 | let given = comment == nil | ||
| 121 | ? state.value?.reactions | ||
| 122 | : state.value?.comments?.first { $0.commentID == comment }?.reactions | ||
| 123 | await perform(Reaction.argv(noun: "mr", repo: repoPath, number: number, comment: comment, | ||
| 124 | name: name, remove: Reaction.remove(name, given: given))) | ||
| 125 | } | ||
| 126 | |||
| 117 | func merge(strategy: String? = nil) async { | 127 | func merge(strategy: String? = nil) async { |
| 118 | var argv = ["mr", "merge"] + ref | 128 | var argv = ["mr", "merge"] + ref |
| 119 | if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } | 129 | if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } |
gitbay/MRs/MRModels.swift +8 −2
| @@ -99,6 +99,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |||
| 99 | let checksCombined: String? | 99 | let checksCombined: String? |
| 100 | let unresolvedThreads: Int? | 100 | let unresolvedThreads: Int? |
| 101 | let commits: [MRCommit]? | 101 | let commits: [MRCommit]? |
| 102 | let reactions: [ReactionCount]? | ||
| 102 | let comments: [MRComment]? | 103 | let comments: [MRComment]? |
| 103 | let reviews: [Review]? | 104 | let reviews: [Review]? |
| 104 | /// Open but not asking: does not merge, does not appear in a review queue. | 105 | /// Open but not asking: does not merge, does not appear in a review queue. |
| @@ -111,7 +112,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |||
| 111 | let gates: Gates? | 112 | let gates: Gates? |
| 112 | 113 | ||
| 113 | enum CodingKeys: String, CodingKey { | 114 | enum CodingKeys: String, CodingKey { |
| 114 | case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates | 115 | case number, title, state, author, source, body, milestone, labels, checks, commits, reactions, comments, reviews, draft, stacked, gates |
| 115 | case bodyFormat = "body_format" | 116 | case bodyFormat = "body_format" |
| 116 | case targetRef = "target_ref" | 117 | case targetRef = "target_ref" |
| 117 | case headSHA = "head_sha" | 118 | case headSHA = "head_sha" |
| @@ -150,6 +151,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |||
| 150 | checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined) | 151 | checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined) |
| 151 | unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads) | 152 | unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads) |
| 152 | commits = try container.decodeIfPresent([MRCommit].self, forKey: .commits) | 153 | commits = try container.decodeIfPresent([MRCommit].self, forKey: .commits) |
| 154 | reactions = try container.decodeIfPresent([ReactionCount].self, forKey: .reactions) | ||
| 153 | comments = try container.decodeIfPresent([MRComment].self, forKey: .comments) | 155 | comments = try container.decodeIfPresent([MRComment].self, forKey: .comments) |
| 154 | reviews = try container.decodeIfPresent([Review].self, forKey: .reviews) | 156 | reviews = try container.decodeIfPresent([Review].self, forKey: .reviews) |
| 155 | draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false | 157 | draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false |
| @@ -240,13 +242,17 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |||
| 240 | } | 242 | } |
| 241 | 243 | ||
| 242 | nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable { | 244 | nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable { |
| 245 | /// What `--comment` names. Absent from a server older than v1.39.0. | ||
| 246 | let commentID: Int64? | ||
| 243 | let author: String | 247 | let author: String |
| 244 | let body: String | 248 | let body: String |
| 245 | let bodyFormat: String? | 249 | let bodyFormat: String? |
| 246 | let createdAt: Date | 250 | let createdAt: Date |
| 251 | let reactions: [ReactionCount]? | ||
| 247 | 252 | ||
| 248 | enum CodingKeys: String, CodingKey { | 253 | enum CodingKeys: String, CodingKey { |
| 249 | case author, body | 254 | case author, body, reactions |
| 255 | case commentID = "id" | ||
| 250 | case bodyFormat = "body_format" | 256 | case bodyFormat = "body_format" |
| 251 | case createdAt = "created_at" | 257 | case createdAt = "created_at" |
| 252 | } | 258 | } |
gitbay/Views/Issues/IssueView.swift +12 −2
| @@ -29,11 +29,15 @@ struct IssueView: View { | |||
| 29 | } | 29 | } |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | if let body = issue.body, !body.isEmpty { | 32 | Section { |
| 33 | Section { | 33 | if let body = issue.body, !body.isEmpty { |
| 34 | FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath) | 34 | FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath) |
| 35 | .padding(.vertical, 4) | 35 | .padding(.vertical, 4) |
| 36 | } | 36 | } |
| 37 | ReactionBar(reactions: issue.reactions ?? [], disabled: model.working, | ||
| 38 | idPrefix: "issue-reaction") { name in | ||
| 39 | Task { await model.react(name, comment: nil) } | ||
| 40 | } | ||
| 37 | } | 41 | } |
| 38 | 42 | ||
| 39 | triageSection(issue) | 43 | triageSection(issue) |
| @@ -169,6 +173,12 @@ struct IssueView: View { | |||
| 169 | } | 173 | } |
| 170 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) | 174 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) |
| 171 | .font(.gbSans(.subheadline)) | 175 | .font(.gbSans(.subheadline)) |
| 176 | if let id = comment.commentID { | ||
| 177 | ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, | ||
| 178 | idPrefix: "issue-comment-\(id)-reaction") { name in | ||
| 179 | Task { await model.react(name, comment: id) } | ||
| 180 | } | ||
| 181 | } | ||
| 172 | } | 182 | } |
| 173 | .padding(.vertical, 2) | 183 | .padding(.vertical, 2) |
| 174 | } | 184 | } |
gitbay/Views/MRs/MRView.swift +12 −2
| @@ -35,11 +35,15 @@ struct MRView: View { | |||
| 35 | } | 35 | } |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | if let body = mr.body, !body.isEmpty { | 38 | Section { |
| 39 | Section { | 39 | if let body = mr.body, !body.isEmpty { |
| 40 | FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath) | 40 | FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath) |
| 41 | .padding(.vertical, 4) | 41 | .padding(.vertical, 4) |
| 42 | } | 42 | } |
| 43 | ReactionBar(reactions: mr.reactions ?? [], disabled: model.working, | ||
| 44 | idPrefix: "mr-reaction") { name in | ||
| 45 | Task { await model.react(name, comment: nil) } | ||
| 46 | } | ||
| 43 | } | 47 | } |
| 44 | 48 | ||
| 45 | if let gates = mr.gates, mr.isOpen { | 49 | if let gates = mr.gates, mr.isOpen { |
| @@ -535,6 +539,12 @@ struct MRView: View { | |||
| 535 | } | 539 | } |
| 536 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) | 540 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) |
| 537 | .font(.gbSans(.subheadline)) | 541 | .font(.gbSans(.subheadline)) |
| 542 | if let id = comment.commentID { | ||
| 543 | ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, | ||
| 544 | idPrefix: "mr-comment-\(id)-reaction") { name in | ||
| 545 | Task { await model.react(name, comment: id) } | ||
| 546 | } | ||
| 547 | } | ||
| 538 | } | 548 | } |
| 539 | .padding(.vertical, 2) | 549 | .padding(.vertical, 2) |
| 540 | } | 550 | } |
gitbay/Views/Shared/ReactionBar.swift added +75
| @@ -0,0 +1,75 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The reactions on an issue, a merge request or a comment: one chip per | ||
| 4 | /// reaction given, tinted when it is the account's own, and a menu of the | ||
| 5 | /// whole vocabulary. Tapping a chip toggles the account's reaction; the | ||
| 6 | /// command decides, and the screen reloads from what it says. | ||
| 7 | struct ReactionBar: View { | ||
| 8 | |||
| 9 | let reactions: [ReactionCount] | ||
| 10 | let disabled: Bool | ||
| 11 | /// Names the bar's controls: `<idPrefix>-<reaction>` and `<idPrefix>-add`. | ||
| 12 | let idPrefix: String | ||
| 13 | let react: (String) -> Void | ||
| 14 | |||
| 15 | var body: some View { | ||
| 16 | HStack(spacing: 6) { | ||
| 17 | ForEach(reactions) { given in | ||
| 18 | Button { | ||
| 19 | react(given.reaction) | ||
| 20 | } label: { | ||
| 21 | HStack(spacing: 3) { | ||
| 22 | Text(Reaction.emoji(for: given.reaction)) | ||
| 23 | Text("\(given.count)") | ||
| 24 | .monospacedDigit() | ||
| 25 | .foregroundStyle(given.me ? Color.gbAccent : .secondary) | ||
| 26 | } | ||
| 27 | .font(.gbSans(.caption)) | ||
| 28 | .padding(.horizontal, 7) | ||
| 29 | .padding(.vertical, 3) | ||
| 30 | .background((given.me ? Color.gbAccent : .secondary).opacity(0.07), in: gbChipShape) | ||
| 31 | .overlay(gbChipShape.stroke((given.me ? Color.gbAccent : .secondary).opacity(0.35), lineWidth: 1)) | ||
| 32 | .frame(minHeight: 44) | ||
| 33 | .contentShape(Rectangle()) | ||
| 34 | } | ||
| 35 | .buttonStyle(.borderless) | ||
| 36 | .disabled(disabled) | ||
| 37 | .accessibilityLabel(label(for: given)) | ||
| 38 | .accessibilityIdentifier("\(idPrefix)-\(given.reaction)") | ||
| 39 | } | ||
| 40 | Menu { | ||
| 41 | ForEach(Reaction.all) { reaction in | ||
| 42 | Button { | ||
| 43 | react(reaction.name) | ||
| 44 | } label: { | ||
| 45 | // The account's own reaction carries the menu's checkmark; | ||
| 46 | // picking it again takes it back. | ||
| 47 | if Reaction.remove(reaction.name, given: reactions) { | ||
| 48 | Label("\(reaction.emoji) \(reaction.name)", systemImage: "checkmark") | ||
| 49 | } else { | ||
| 50 | Text("\(reaction.emoji) \(reaction.name)") | ||
| 51 | } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } label: { | ||
| 55 | Image(systemName: "face.smiling") | ||
| 56 | .foregroundStyle(.secondary) | ||
| 57 | .frame(minWidth: 44, minHeight: 44) | ||
| 58 | .contentShape(Rectangle()) | ||
| 59 | } | ||
| 60 | .menuStyle(.borderlessButton) | ||
| 61 | // A menu in a list row takes the accent; the icon is a quiet | ||
| 62 | // affordance beside the chips, not a link. | ||
| 63 | .tint(.secondary) | ||
| 64 | .disabled(disabled) | ||
| 65 | .accessibilityLabel("Add reaction") | ||
| 66 | .accessibilityIdentifier("\(idPrefix)-add") | ||
| 67 | Spacer(minLength: 0) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | private func label(for given: ReactionCount) -> String { | ||
| 72 | let count = given.count == 1 ? "1 reaction" : "\(given.count) reactions" | ||
| 73 | return "\(given.reaction), \(count)\(given.me ? ", including yours" : "")" | ||
| 74 | } | ||
| 75 | } | ||
gitbayTests/ReactionTests.swift added +132
| @@ -0,0 +1,132 @@ | |||
| 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 issueShowJSON = """ | ||
| 21 | {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\ | ||
| 22 | "author":"krz","body":"Build it.","created_at":"2026-08-20T10:00:00.000Z",\ | ||
| 23 | "reactions":[{"reaction":"+1","count":2,"me":true},{"reaction":"eyes","count":1,"me":false}],\ | ||
| 24 | "comments":[{"id":7,"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z",\ | ||
| 25 | "reactions":[{"reaction":"heart","count":1,"me":false}]}]},"exit_code":0} | ||
| 26 | """ | ||
| 27 | |||
| 28 | private let mrShowJSON = """ | ||
| 29 | {"protocol_version":1,"data":{"number":3,"title":"t","state":"open","author":"krz",\ | ||
| 30 | "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z",\ | ||
| 31 | "reactions":[{"reaction":"rocket","count":1,"me":true}],\ | ||
| 32 | "comments":[{"id":9,"author":"cmc","body":"lgtm","created_at":"2026-08-22T09:00:00.000Z"}]},\ | ||
| 33 | "exit_code":0} | ||
| 34 | """ | ||
| 35 | |||
| 36 | private let ok = #"{"protocol_version":1,"data":{"reaction":"+1"},"exit_code":0}"# | ||
| 37 | |||
| 38 | struct ReactionModelTests { | ||
| 39 | |||
| 40 | @Test func theVocabularyIsTheServersInItsOrder() { | ||
| 41 | #expect(Reaction.all.map(\.name) == ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"]) | ||
| 42 | #expect(Reaction.emoji(for: "hooray") == "🎉") | ||
| 43 | // An unknown name from a newer server still draws. | ||
| 44 | #expect(Reaction.emoji(for: "sparkles") == "sparkles") | ||
| 45 | } | ||
| 46 | |||
| 47 | @Test func argvReactsToTheItemOrAComment() { | ||
| 48 | #expect(Reaction.argv(noun: "issue", repo: "krz/gitbay", number: 11, comment: nil, name: "+1", remove: false) | ||
| 49 | == ["issue", "react", "krz/gitbay", "11", "+1"]) | ||
| 50 | #expect(Reaction.argv(noun: "mr", repo: "krz/gitbay", number: 3, comment: 9, name: "eyes", remove: true) | ||
| 51 | == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes", "--remove"]) | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Every name the server knows is offered, and the ones already given | ||
| 55 | /// are offered too: picking one of those takes it back. | ||
| 56 | @Test func tappingAReactionYouGaveRemovesIt() { | ||
| 57 | let given = [ReactionCount(reaction: "+1", count: 2, me: true), ReactionCount(reaction: "eyes", count: 1, me: false)] | ||
| 58 | #expect(Reaction.remove("+1", given: given)) | ||
| 59 | #expect(!Reaction.remove("eyes", given: given)) | ||
| 60 | #expect(!Reaction.remove("heart", given: given)) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | @MainActor | ||
| 65 | struct IssueReactionTests { | ||
| 66 | |||
| 67 | @Test func issueShowDecodesReactionsOnTheIssueAndItsComments() async throws { | ||
| 68 | let (client, stub) = try makeClient() | ||
| 69 | stub.enqueue(.init(status: 200, json: issueShowJSON)) | ||
| 70 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | ||
| 71 | |||
| 72 | await model.load() | ||
| 73 | |||
| 74 | let issue = try #require(model.state.value) | ||
| 75 | #expect(issue.reactions == [ReactionCount(reaction: "+1", count: 2, me: true), | ||
| 76 | ReactionCount(reaction: "eyes", count: 1, me: false)]) | ||
| 77 | #expect(issue.comments?.first?.commentID == 7) | ||
| 78 | #expect(issue.comments?.first?.reactions?.first?.reaction == "heart") | ||
| 79 | } | ||
| 80 | |||
| 81 | @Test func reactingToACommentSendsItsIDThenReloads() async throws { | ||
| 82 | let (client, stub) = try makeClient() | ||
| 83 | stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) | ||
| 84 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 85 | stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) | ||
| 86 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | ||
| 87 | await model.load() | ||
| 88 | |||
| 89 | await model.react("heart", comment: 7) | ||
| 90 | |||
| 91 | let post = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 92 | #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "--comment", "7", "heart"]) | ||
| 93 | #expect(stub.seen.filter { $0.url.absoluteString.contains("argv=show") }.count == 2) | ||
| 94 | } | ||
| 95 | |||
| 96 | @Test func reactingWithOneYouGaveTakesItBack() async throws { | ||
| 97 | let (client, stub) = try makeClient() | ||
| 98 | stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) | ||
| 99 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 100 | stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) | ||
| 101 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | ||
| 102 | await model.load() | ||
| 103 | |||
| 104 | await model.react("+1", comment: nil) | ||
| 105 | |||
| 106 | let post = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 107 | #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "+1", "--remove"]) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | @MainActor | ||
| 112 | struct MRReactionTests { | ||
| 113 | |||
| 114 | @Test func mrShowDecodesReactionsAndReactingSendsTheNoun() async throws { | ||
| 115 | let (client, stub) = try makeClient() | ||
| 116 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | ||
| 117 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 118 | stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) | ||
| 119 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 3) | ||
| 120 | await model.load() | ||
| 121 | |||
| 122 | let mr = try #require(model.state.value) | ||
| 123 | #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)]) | ||
| 124 | #expect(mr.comments?.first?.commentID == 9) | ||
| 125 | #expect(mr.comments?.first?.reactions == nil) | ||
| 126 | |||
| 127 | await model.react("eyes", comment: 9) | ||
| 128 | |||
| 129 | let post = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 130 | #expect(try argvOf(post) == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes"]) | ||
| 131 | } | ||
| 132 | } | ||