| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; |
| 3 | import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration"; |
| 4 | |
| 5 | import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts"; |
| 6 | |
| 7 | // `git diff --unified=0` of: src/a.ts with lines 10-14 and 40-59 added and an old line 30 |
| 8 | // removed; src/gone.ts deleted (old lines 1-20); docs/readme.md with line 3 added. |
| 9 | const patch = [ |
| 10 | "diff --git a/src/a.ts b/src/a.ts", |
| 11 | "index 1111111..2222222 100644", |
| 12 | "--- a/src/a.ts", |
| 13 | "+++ b/src/a.ts", |
| 14 | "@@ -9,0 +10,5 @@ export const x = 1;", |
| 15 | "+added", |
| 16 | "@@ -30 +34,0 @@ function y() {", |
| 17 | "-removed", |
| 18 | "@@ -35,0 +40,20 @@", |
| 19 | "+added", |
| 20 | "diff --git a/src/gone.ts b/src/gone.ts", |
| 21 | "deleted file mode 100644", |
| 22 | "--- a/src/gone.ts", |
| 23 | "+++ /dev/null", |
| 24 | "@@ -1,20 +0,0 @@", |
| 25 | "-removed", |
| 26 | "diff --git a/docs/readme.md b/docs/readme.md", |
| 27 | "--- a/docs/readme.md", |
| 28 | "+++ b/docs/readme.md", |
| 29 | "@@ -2,0 +3 @@", |
| 30 | "+added", |
| 31 | "", |
| 32 | ].join("\n"); |
| 33 | const change = parseReviewGuideChange("src/a.ts\0src/gone.ts\0docs/readme.md\0", patch); |
| 34 | |
| 35 | const stop = (overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop => ({ |
| 36 | title: "Stop", |
| 37 | summary: "", |
| 38 | attention: null, |
| 39 | excerpts: [], |
| 40 | files: [], |
| 41 | notes: [], |
| 42 | questions: [], |
| 43 | ...overrides, |
| 44 | }); |
| 45 | |
| 46 | const guide = (overrides: Partial<ReviewGuideGenerationResult>): ReviewGuideGenerationResult => ({ |
| 47 | stops: [], |
| 48 | ...overrides, |
| 49 | }); |
| 50 | |
| 51 | const excerpt = (path: string, start: number, end: number, side: "new" | "old" = "new") => ({ |
| 52 | path, |
| 53 | side, |
| 54 | start, |
| 55 | end, |
| 56 | caption: "", |
| 57 | }); |
| 58 | |
| 59 | describe("parseReviewGuideChange", () => { |
| 60 | it("reads a removed or added line that looks like a file header as a source line", () => { |
| 61 | const tricky = [ |
| 62 | "diff --git a/notes.md b/notes.md", |
| 63 | "--- a/notes.md", |
| 64 | "+++ b/notes.md", |
| 65 | "@@ -3,1 +3,2 @@", |
| 66 | "--- a/old-heading", |
| 67 | "+++ b/other.ts", |
| 68 | "+tail", |
| 69 | "@@ -9,0 +10,1 @@", |
| 70 | "+later", |
| 71 | "", |
| 72 | ].join("\n"); |
| 73 | const parsed = parseReviewGuideChange("notes.md\0", tricky); |
| 74 | expect([...parsed.hunks.keys()]).toEqual(["notes.md"]); |
| 75 | expect(parsed.hunks.get("notes.md")).toEqual({ |
| 76 | new: [ |
| 77 | { start: 3, end: 4 }, |
| 78 | { start: 10, end: 10 }, |
| 79 | ], |
| 80 | old: [{ start: 3, end: 3 }], |
| 81 | }); |
| 82 | }); |
| 83 | |
| 84 | it("reads each side's hunks, keying a deleted file by its old path", () => { |
| 85 | expect(change.files).toEqual(["src/a.ts", "src/gone.ts", "docs/readme.md"]); |
| 86 | expect(change.hunks.get("src/a.ts")).toEqual({ |
| 87 | new: [ |
| 88 | { start: 10, end: 14 }, |
| 89 | { start: 40, end: 59 }, |
| 90 | ], |
| 91 | old: [{ start: 30, end: 30 }], |
| 92 | }); |
| 93 | expect(change.hunks.get("src/gone.ts")).toEqual({ new: [], old: [{ start: 1, end: 20 }] }); |
| 94 | expect(change.hunks.get("docs/readme.md")).toEqual({ new: [{ start: 3, end: 3 }], old: [] }); |
| 95 | }); |
| 96 | }); |
| 97 | |
| 98 | describe("normalizeReviewGuide", () => { |
| 99 | it("clamps excerpts to changed lines, merges touching ones, and caps their length", () => { |
| 100 | const result = normalizeReviewGuide( |
| 101 | guide({ |
| 102 | stops: [ |
| 103 | stop({ |
| 104 | excerpts: [ |
| 105 | // Starts on context before the hunk: clamped to the hunk. |
| 106 | excerpt("src/a.ts", 5, 12), |
| 107 | // Touches the previous one once clamped: merged. |
| 108 | excerpt("src/a.ts", 13, 14), |
| 109 | // Spans the whole second hunk and beyond: clamped to it. |
| 110 | excerpt("src/a.ts", 38, 80), |
| 111 | // On no changed line: dropped. |
| 112 | excerpt("src/a.ts", 20, 25), |
| 113 | // Removed code shown from the old side. |
| 114 | excerpt("src/gone.ts", 2, 8, "old"), |
| 115 | ], |
| 116 | files: [{ path: "docs/readme.md", note: "" }], |
| 117 | }), |
| 118 | ], |
| 119 | }), |
| 120 | change, |
| 121 | ); |
| 122 | expect(result.stops[0]?.excerpts).toEqual([ |
| 123 | excerpt("src/a.ts", 10, 14), |
| 124 | excerpt("src/a.ts", 40, 59), |
| 125 | excerpt("src/gone.ts", 2, 8, "old"), |
| 126 | ]); |
| 127 | expect(result.stops[0]?.files).toEqual([{ path: "docs/readme.md" }]); |
| 128 | expect(result.stops).toHaveLength(1); |
| 129 | }); |
| 130 | |
| 131 | it("keeps a long excerpt to its first 25 lines", () => { |
| 132 | const long = parseReviewGuideChange( |
| 133 | "big.ts\0", |
| 134 | "--- a/big.ts\n+++ b/big.ts\n@@ -0,0 +1,100 @@\n", |
| 135 | ); |
| 136 | const result = normalizeReviewGuide( |
| 137 | guide({ stops: [stop({ excerpts: [excerpt("big.ts", 1, 100)] })] }), |
| 138 | long, |
| 139 | ); |
| 140 | expect(result.stops[0]?.excerpts).toEqual([excerpt("big.ts", 1, 25)]); |
| 141 | }); |
| 142 | |
| 143 | it("drops notes outside the stop's remaining excerpts", () => { |
| 144 | const result = normalizeReviewGuide( |
| 145 | guide({ |
| 146 | stops: [ |
| 147 | stop({ |
| 148 | excerpts: [excerpt("src/a.ts", 5, 12)], |
| 149 | notes: [ |
| 150 | { path: "src/a.ts", line: 11, title: "Inside", body: "" }, |
| 151 | { path: "src/a.ts", line: 6, title: "Clamped away", body: "" }, |
| 152 | { path: "src/other.ts", line: 11, title: "Other file", body: "" }, |
| 153 | ], |
| 154 | }), |
| 155 | ], |
| 156 | }), |
| 157 | change, |
| 158 | ); |
| 159 | expect(result.stops[0]?.notes.map((note) => note.title)).toEqual(["Inside"]); |
| 160 | }); |
| 161 | |
| 162 | it("drops excerpts and files outside the change", () => { |
| 163 | const result = normalizeReviewGuide( |
| 164 | guide({ |
| 165 | stops: [ |
| 166 | stop({ |
| 167 | excerpts: [excerpt("src/unchanged.ts", 1, 5)], |
| 168 | files: [{ path: "src/a.ts", note: "edited" }, { path: "src/unchanged.ts" }], |
| 169 | }), |
| 170 | ], |
| 171 | }), |
| 172 | change, |
| 173 | ); |
| 174 | expect(result.stops[0]?.excerpts).toEqual([]); |
| 175 | expect(result.stops[0]?.files).toEqual([{ path: "src/a.ts", note: "edited" }]); |
| 176 | }); |
| 177 | |
| 178 | it("puts changed files the tour left out into a closing stop", () => { |
| 179 | const result = normalizeReviewGuide( |
| 180 | guide({ stops: [stop({ excerpts: [excerpt("src/a.ts", 10, 12)] })] }), |
| 181 | change, |
| 182 | ); |
| 183 | expect(result.stops.at(-1)).toMatchObject({ |
| 184 | title: "Other files", |
| 185 | files: [{ path: "src/gone.ts" }, { path: "docs/readme.md" }], |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | it("lets stops show different parts of one file and lists an unshown file once", () => { |
| 190 | const result = normalizeReviewGuide( |
| 191 | guide({ |
| 192 | stops: [ |
| 193 | stop({ |
| 194 | title: "Shape", |
| 195 | excerpts: [excerpt("src/a.ts", 10, 14)], |
| 196 | files: [{ path: "docs/readme.md" }], |
| 197 | }), |
| 198 | stop({ |
| 199 | title: "Implementation", |
| 200 | excerpts: [excerpt("src/a.ts", 40, 50)], |
| 201 | files: [{ path: "src/a.ts" }, { path: "docs/readme.md" }, { path: "src/gone.ts" }], |
| 202 | }), |
| 203 | ], |
| 204 | }), |
| 205 | change, |
| 206 | ); |
| 207 | expect(result.stops.map((s) => [s.title, s.excerpts.map((e) => e.start), s.files])).toEqual([ |
| 208 | ["Shape", [10], [{ path: "docs/readme.md" }]], |
| 209 | ["Implementation", [40], [{ path: "src/gone.ts" }]], |
| 210 | ]); |
| 211 | }); |
| 212 | }); |