gitbayTests/SuggestionTests.swift

v1.14.1
gitbay-ios/gitbayTests/SuggestionTests.swift history · blame · raw

187 lines · 8252 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5private 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
 15private func posted(_ stub: StubProtocol.Box) throws -> ([String], String?) {
 16    let seen = try #require(stub.seen.first { $0.method == "POST" })
 17    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
 18    return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
 19}
 20
 21private let diffText = """
 22    diff --git a/main.go b/main.go
 23    --- a/main.go
 24    +++ b/main.go
 25    @@ -10,4 +10,4 @@ func main() {
 26     \tname := os.Args[1]
 27    -\tlog.Print("starting")
 28    +\tlog.Printf("starting %s", name)
 29     \trun(name)
 30     }
 31    """
 32
 33private let mrShowJSON = """
 34    {"protocol_version":1,"data":{"number":4,"title":"t","state":"open","author":"krz",\
 35    "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z"},\
 36    "exit_code":0}
 37    """
 38
 39private let threadsJSON = """
 40    {"protocol_version":1,"data":[{"id":9,"path":"main.go","side":"new","start_line":11,"line":12,\
 41    "stale":false,"suggestion":{"path":"main.go","start_line":11,"end_line":12,"commit":"abc",\
 42    "original":"\\tlog.Printf(\\"starting %s\\", name)\\n\\trun(name)\\n",\
 43    "replacement":"\\tlog.Printf(\\"starting %q\\", name)\\n\\trun(name)\\n",\
 44    "outdated":false,"apply":"server"},\
 45    "comments":[{"id":9,"author":"cmc","body":"quote it","created_at":"2026-08-22T09:00:00.000Z"}]}],\
 46    "exit_code":0}
 47    """
 48
 49private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
 50
 51private func hunk() throws -> (UnifiedDiff.File, UnifiedDiff.Hunk) {
 52    let file = try #require(UnifiedDiff.parse(diffText).files.first)
 53    return (file, try #require(file.hunks.first))
 54}
 55
 56struct DiffCommentDraftTests {
 57
 58    @Test func aNewSideLineAnchorsOnTheNewFileAndCanSuggest() throws {
 59        let (file, hunk) = try hunk()
 60        let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
 61        let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
 62
 63        #expect(draft.canSuggest)
 64        #expect(draft.line == 12)
 65        // The removed line has no new-side number and is not a candidate.
 66        #expect(draft.candidates.map(\.number) == [10, 11, 12])
 67        #expect(draft.firstLine == 10)
 68        #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 12)
 69            == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12", "--file", "-"])
 70        #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11)
 71            == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12",
 72                "--start-line", "11", "--file", "-"])
 73    }
 74
 75    @Test func theSuggestionBlockCarriesTheRangesCurrentText() throws {
 76        let (file, hunk) = try hunk()
 77        let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
 78        let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
 79
 80        #expect(draft.suggestion(from: 11) == "```suggestion\n\tlog.Printf(\"starting %s\", name)\n\trun(name)\n```\n")
 81    }
 82
 83    @Test func aRemovedLineAnchorsOnTheOldSideAndCannotSuggest() throws {
 84        let (file, hunk) = try hunk()
 85        let target = try #require(hunk.lines.first { $0.kind == .deletion })
 86        let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
 87
 88        #expect(!draft.canSuggest)
 89        #expect(draft.candidates.map(\.number) == [10, 11])
 90        #expect(draft.argv(repo: "krz/gitbay", number: 4, startLine: 11)
 91            == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "11", "--old", "--file", "-"])
 92    }
 93}
 94
 95struct SuggestionReviewFixTests {
 96
 97    /// The server checks --path against the diff's names, which list a
 98    /// renamed file under its new name only, so both sides send it.
 99    @Test func aRemovedLineInARenamedFileSendsTheNewPath() throws {
100        let diff = UnifiedDiff.parse("""
101            diff --git a/old.go b/new.go
102            --- a/old.go
103            +++ b/new.go
104            @@ -1,2 +1,2 @@
105            -a
106            +b
107             c
108            """)
109        let file = try #require(diff.files.first)
110        let hunk = try #require(file.hunks.first)
111        let removed = try #require(hunk.lines.first { $0.kind == .deletion })
112        #expect(DiffCommentDraft(file: file, hunk: hunk, line: removed)?.path == "new.go")
113    }
114
115    /// Moving the start of the range rebuilds an untouched suggestion so it
116    /// covers the range, and leaves an edited one alone.
117    @Test func movingTheRangeStartRebuildsAnUntouchedSuggestion() throws {
118        let (file, hunk) = try hunk()
119        let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
120        let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
121        let untouched = draft.suggestion(from: 12)
122
123        #expect(draft.rebuilt(untouched, from: 12, to: 11) == draft.suggestion(from: 11))
124        #expect(draft.rebuilt("my own words", from: 12, to: 11) == "my own words")
125    }
126
127    /// The thread draws the suggestion as replaced and proposed lines, so
128    /// the comment shows the rest of its text without the block.
129    @Test func theBlockIsLeftOutOfTheCommentShownBesideTheSuggestion() {
130        #expect(Suggestion.withoutBlock("Say who.\n```suggestion\n    return x\n```\n") == "Say who.")
131        #expect(Suggestion.withoutBlock("```suggestion\n```\n") == "")
132        #expect(Suggestion.withoutBlock("no block here") == "no block here")
133    }
134}
135
136@MainActor
137struct SuggestionViewModelTests {
138
139    private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
140        let (client, stub) = try makeClient()
141        stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
142        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff"))
143        stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
144        stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=revisions"))
145        let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 4)
146        await model.load()
147        return (model, stub)
148    }
149
150    @Test func threadsDecodeTheirRangeAndSuggestion() async throws {
151        let (model, _) = try await loaded()
152
153        let thread = try #require(model.threads.first)
154        #expect(thread.startLine == 11)
155        let suggestion = try #require(thread.suggestion)
156        #expect(suggestion.appliesOnServer)
157        #expect(!suggestion.outdated)
158        #expect(Suggestion.lines(suggestion.replacement) == ["\tlog.Printf(\"starting %q\", name)", "\trun(name)"])
159        #expect(Suggestion.lines("") == [])
160    }
161
162    @Test func applyingASuggestionNamesItsThread() async throws {
163        let (model, stub) = try await loaded()
164        stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
165
166        await model.applySuggestion(try #require(model.threads.first))
167
168        let (argv, _) = try posted(stub)
169        #expect(argv == ["mr", "apply-suggestion", "krz/gitbay", "4", "9"])
170    }
171
172    @Test func aNewCommentSendsItsAnchorAndBodyOnStdin() async throws {
173        let (model, stub) = try await loaded()
174        stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
175        let (file, hunk) = try hunk()
176        let target = try #require(hunk.lines.first { $0.newNumber == 12 && $0.kind == .context })
177        let draft = try #require(DiffCommentDraft(file: file, hunk: hunk, line: target))
178
179        let sent = await model.diffComment(draft, startLine: 11, body: "quote it")
180
181        #expect(sent)
182        let (argv, stdin) = try posted(stub)
183        #expect(argv == ["mr", "diff-comment", "krz/gitbay", "4", "--path", "main.go", "--line", "12",
184                         "--start-line", "11", "--file", "-"])
185        #expect(stdin == "quote it")
186    }
187}