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
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.
9const 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");
33const change = parseReviewGuideChange("src/a.ts\0src/gone.ts\0docs/readme.md\0", patch);
34
35const 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
46const guide = (overrides: Partial<ReviewGuideGenerationResult>): ReviewGuideGenerationResult => ({
47 stops: [],
48 ...overrides,
49});
50
51const excerpt = (path: string, start: number, end: number, side: "new" | "old" = "new") => ({
52 path,
53 side,
54 start,
55 end,
56 caption: "",
57});
58
59describe("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
98describe("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});