1import { describe, expect, it } from "vite-plus/test";
2import type { PullRequestReviewGuideStop } from "@t3tools/contracts";
3import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration";
4
5import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts";
6
7const patch = [
8 "diff --git a/src/a.ts b/src/a.ts",
9 "index 1111111..2222222 100644",
10 "--- a/src/a.ts",
11 "+++ b/src/a.ts",
12 "@@ -9,0 +10,5 @@ export const x = 1;",
13 "+added",
14 "@@ -30 +34,0 @@ function y() {",
15 "-removed",
16 "@@ -35,0 +40,20 @@",
17 "+added",
18 "diff --git a/src/gone.ts b/src/gone.ts",
19 "deleted file mode 100644",
20 "--- a/src/gone.ts",
21 "+++ /dev/null",
22 "@@ -1,20 +0,0 @@",
23 "-removed",
24 "diff --git a/docs/readme.md b/docs/readme.md",
25 "--- a/docs/readme.md",
26 "+++ b/docs/readme.md",
27 "@@ -2,0 +3 @@",
28 "+added",
29 "",
30].join("\n");
31const change = parseReviewGuideChange("src/a.ts\0src/gone.ts\0docs/readme.md\0", patch);
32
33const stop = (overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop => ({
34 title: "Stop",
35 summary: "",
36 attention: null,
37 excerpts: [],
38 files: [],
39 notes: [],
40 questions: [],
41 ...overrides,
42});
43
44const guide = (overrides: Partial<ReviewGuideGenerationResult>): ReviewGuideGenerationResult => ({
45 stops: [],
46 ...overrides,
47});
48
49const excerpt = (path: string, start: number, end: number, side: "new" | "old" = "new") => ({
50 path,
51 side,
52 start,
53 end,
54 caption: "",
55});
56
57describe("parseReviewGuideChange", () => {
58 it("reads a removed or added line that looks like a file header as a source line", () => {
59 const tricky = [
60 "diff --git a/notes.md b/notes.md",
61 "--- a/notes.md",
62 "+++ b/notes.md",
63 "@@ -3,1 +3,2 @@",
64 "--- a/old-heading",
65 "+++ b/other.ts",
66 "+tail",
67 "@@ -9,0 +10,1 @@",
68 "+later",
69 "",
70 ].join("\n");
71 const parsed = parseReviewGuideChange("notes.md\0", tricky);
72 expect([...parsed.hunks.keys()]).toEqual(["notes.md"]);
73 expect(parsed.hunks.get("notes.md")).toEqual({
74 new: [
75 { start: 3, end: 4 },
76 { start: 10, end: 10 },
77 ],
78 old: [{ start: 3, end: 3 }],
79 });
80 });
81
82 it("reads each side's hunks, keying a deleted file by its old path", () => {
83 expect(change.files).toEqual(["src/a.ts", "src/gone.ts", "docs/readme.md"]);
84 expect(change.hunks.get("src/a.ts")).toEqual({
85 new: [
86 { start: 10, end: 14 },
87 { start: 40, end: 59 },
88 ],
89 old: [{ start: 30, end: 30 }],
90 });
91 expect(change.hunks.get("src/gone.ts")).toEqual({ new: [], old: [{ start: 1, end: 20 }] });
92 expect(change.hunks.get("docs/readme.md")).toEqual({ new: [{ start: 3, end: 3 }], old: [] });
93 });
94
95 it("reads file names git quotes or ends with a tab", () => {
96 const unusual = [
97 "diff --git a/with space.txt b/with space.txt",
98 "--- a/with space.txt\t",
99 "+++ b/with space.txt\t",
100 "@@ -1,0 +2 @@",
101 'diff --git "a/tab\\tname.txt" "b/tab\\tname.txt"',
102 '--- "a/tab\\tname.txt"',
103 '+++ "b/tab\\tname.txt"',
104 "@@ -1,0 +2 @@",
105 'diff --git "a/back\\\\slash.txt" "b/back\\\\slash.txt"',
106 '--- "a/back\\\\slash.txt"',
107 '+++ "b/back\\\\slash.txt"',
108 "@@ -1,0 +2 @@",
109 'diff --git "a/gone\\tfile.txt" "b/gone\\tfile.txt"',
110 '--- "a/gone\\tfile.txt"',
111 "+++ /dev/null",
112 "@@ -1 +0,0 @@",
113 "",
114 ].join("\n");
115 const names = ["with space.txt", "tab\tname.txt", "back\\slash.txt", "gone\tfile.txt"];
116 const parsed = parseReviewGuideChange(names.join("\0"), unusual);
117 expect([...parsed.hunks.keys()]).toEqual(names);
118 expect(parsed.hunks.get("gone\tfile.txt")).toEqual({ new: [], old: [{ start: 1, end: 1 }] });
119 const result = normalizeReviewGuide(
120 guide({ stops: [stop({ excerpts: names.slice(0, 3).map((path) => excerpt(path, 1, 5)) })] }),
121 parsed,
122 );
123 expect(result.stops[0]?.excerpts).toEqual(names.slice(0, 3).map((path) => excerpt(path, 2, 2)));
124 });
125});
126
127describe("normalizeReviewGuide", () => {
128 it("clamps excerpts to changed lines, merges touching ones, and caps their length", () => {
129 const result = normalizeReviewGuide(
130 guide({
131 stops: [
132 stop({
133 excerpts: [
134 excerpt("src/a.ts", 5, 12),
135 excerpt("src/a.ts", 13, 14),
136 excerpt("src/a.ts", 38, 80),
137 excerpt("src/a.ts", 20, 25),
138 excerpt("src/gone.ts", 2, 8, "old"),
139 ],
140 files: [{ path: "docs/readme.md", note: "" }],
141 }),
142 ],
143 }),
144 change,
145 );
146 expect(result.stops[0]?.excerpts).toEqual([
147 excerpt("src/a.ts", 10, 14),
148 excerpt("src/a.ts", 40, 59),
149 excerpt("src/gone.ts", 2, 8, "old"),
150 ]);
151 expect(result.stops[0]?.files).toEqual([{ path: "docs/readme.md" }]);
152 expect(result.stops).toHaveLength(1);
153 });
154
155 it("merges excerpts that a later excerpt bridges into one", () => {
156 const result = normalizeReviewGuide(
157 guide({
158 stops: [
159 stop({
160 excerpts: [
161 excerpt("src/a.ts", 40, 41),
162 { ...excerpt("src/a.ts", 50, 51), caption: "Second" },
163 excerpt("src/a.ts", 41, 50),
164 ],
165 }),
166 ],
167 }),
168 change,
169 );
170 expect(result.stops[0]?.excerpts).toEqual([
171 { ...excerpt("src/a.ts", 40, 51), caption: "Second" },
172 ]);
173
174 const tailNote = { path: "src/a.ts", line: 40, title: "Tail", body: "Keep this note." };
175 const wide = normalizeReviewGuide(
176 guide({
177 stops: [
178 stop({
179 excerpts: [
180 excerpt("src/a.ts", 1, 25),
181 { ...excerpt("src/a.ts", 30, 45), caption: "Second" },
182 excerpt("src/a.ts", 24, 32),
183 ],
184 notes: [tailNote],
185 }),
186 ],
187 }),
188 parseReviewGuideChange(
189 "src/a.ts\0",
190 "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,60 @@\n+added",
191 ),
192 );
193 expect(wide.stops[0]?.excerpts).toEqual([{ ...excerpt("src/a.ts", 1, 25), caption: "Second" }]);
194 expect(wide.stops[0]?.notes).toEqual([]);
195 });
196
197 it("drops notes outside the stop's remaining excerpts", () => {
198 const result = normalizeReviewGuide(
199 guide({
200 stops: [
201 stop({
202 excerpts: [excerpt("src/a.ts", 5, 12)],
203 notes: [
204 { path: "src/a.ts", line: 11, title: "Inside", body: "" },
205 { path: "src/a.ts", line: 6, title: "Clamped away", body: "" },
206 { path: "src/other.ts", line: 11, title: "Other file", body: "" },
207 ],
208 }),
209 ],
210 }),
211 change,
212 );
213 expect(result.stops[0]?.notes.map((note) => note.title)).toEqual(["Inside"]);
214 });
215
216 it("drops excerpts and files outside the change", () => {
217 const result = normalizeReviewGuide(
218 guide({
219 stops: [
220 stop({
221 excerpts: [excerpt("src/unchanged.ts", 1, 5)],
222 files: [{ path: "src/a.ts", note: "edited" }, { path: "src/unchanged.ts" }],
223 }),
224 ],
225 }),
226 change,
227 );
228 expect(result.stops[0]?.excerpts).toEqual([]);
229 expect(result.stops[0]?.files).toEqual([{ path: "src/a.ts", note: "edited" }]);
230 });
231
232 it("puts changed files the tour left out into a closing stop", () => {
233 const result = normalizeReviewGuide(
234 guide({ stops: [stop({ excerpts: [excerpt("src/a.ts", 10, 12)] })] }),
235 change,
236 );
237 expect(result.stops.at(-1)).toMatchObject({
238 title: "Other files",
239 files: [{ path: "src/gone.ts" }, { path: "docs/readme.md" }],
240 });
241 });
242
243 it("lets stops show different parts of one file and lists an unshown file once", () => {
244 const result = normalizeReviewGuide(
245 guide({
246 stops: [
247 stop({
248 title: "Shape",
249 excerpts: [excerpt("src/a.ts", 10, 14)],
250 files: [{ path: "docs/readme.md" }],
251 }),
252 stop({
253 title: "Implementation",
254 excerpts: [excerpt("src/a.ts", 40, 50)],
255 files: [{ path: "src/a.ts" }, { path: "docs/readme.md" }, { path: "src/gone.ts" }],
256 }),
257 ],
258 }),
259 change,
260 );
261 expect(result.stops.map((s) => [s.title, s.excerpts.map((e) => e.start), s.files])).toEqual([
262 ["Shape", [10], [{ path: "docs/readme.md" }]],
263 ["Implementation", [40], [{ path: "src/gone.ts" }]],
264 ]);
265 });
266});