import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private let issueShowJSON = """ {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\ "author":"krz","body":"Build it.","created_at":"2026-08-20T10:00:00.000Z",\ "reactions":[{"reaction":"+1","count":2,"me":true},{"reaction":"eyes","count":1,"me":false}],\ "comments":[{"id":7,"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z",\ "reactions":[{"reaction":"heart","count":1,"me":false}]}]},"exit_code":0} """ private let mrShowJSON = """ {"protocol_version":1,"data":{"number":3,"title":"t","state":"open","author":"krz",\ "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z",\ "reactions":[{"reaction":"rocket","count":1,"me":true}],\ "comments":[{"id":9,"author":"cmc","body":"lgtm","created_at":"2026-08-22T09:00:00.000Z"},\ {"id":10,"author":"cmc","body":"queued the merge","kind":"system","created_at":"2026-08-22T09:01:00.000Z"},\ {"id":11,"author":"system","body":"retargeted","created_at":"2026-08-22T09:02:00.000Z"}]},\ "exit_code":0} """ private let ok = #"{"protocol_version":1,"data":{"reaction":"+1"},"exit_code":0}"# struct ReactionModelTests { @Test func theVocabularyIsTheServersInItsOrder() { #expect(Reaction.all.map(\.name) == ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"]) #expect(Reaction.emoji(for: "hooray") == "🎉") // An unknown name from a newer server still draws. #expect(Reaction.emoji(for: "sparkles") == "sparkles") #expect(Reaction.spoken(for: "+1") == "thumbs up") } @Test func argvReactsToTheItemOrAComment() { #expect(Reaction.argv(noun: "issue", repo: "krz/gitbay", number: 11, comment: nil, name: "+1", remove: false) == ["issue", "react", "krz/gitbay", "11", "+1"]) #expect(Reaction.argv(noun: "mr", repo: "krz/gitbay", number: 3, comment: 9, name: "eyes", remove: true) == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes", "--remove"]) } /// Every name the server knows is offered, and the ones already given /// are offered too: picking one of those takes it back. @Test func tappingAReactionYouGaveRemovesIt() { let given = [ReactionCount(reaction: "+1", count: 2, me: true), ReactionCount(reaction: "eyes", count: 1, me: false)] #expect(Reaction.remove("+1", given: given)) #expect(!Reaction.remove("eyes", given: given)) #expect(!Reaction.remove("heart", given: given)) } } @MainActor struct IssueReactionTests { @Test func issueShowDecodesReactionsOnTheIssueAndItsComments() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShowJSON)) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() let issue = try #require(model.state.value) #expect(issue.reactions == [ReactionCount(reaction: "+1", count: 2, me: true), ReactionCount(reaction: "eyes", count: 1, me: false)]) #expect(issue.comments?.first?.commentID == 7) #expect(issue.comments?.first?.reactions?.first?.reaction == "heart") } @Test func reactingToACommentSendsItsIDThenReloads() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: ok, match: "cmd")) stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() await model.react("heart", comment: 7) let post = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "--comment", "7", "heart"]) #expect(stub.seen.filter { $0.url.absoluteString.contains("argv=show") }.count == 2) } @Test func reactingWithOneYouGaveTakesItBack() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: ok, match: "cmd")) stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show")) let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) await model.load() await model.react("+1", comment: nil) let post = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "+1", "--remove"]) } } @MainActor struct MRReactionTests { @Test func mrShowDecodesReactionsAndReactingSendsTheNoun() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: ok, match: "cmd")) stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 3) await model.load() let mr = try #require(model.state.value) #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)]) #expect(mr.comments?.first?.commentID == 9) #expect(mr.comments?.first?.reactions == nil) #expect(mr.comments?.first?.isSystem == false) #expect(mr.comments?[1].isSystem == true) // An older server sends no kind; its system lines are authored "system". #expect(mr.comments?.last?.isSystem == true) await model.react("eyes", comment: 9) let post = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(post) == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes"]) } }