1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { PullRequestReviewGuideStop } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 excerptExpansionOldContents,
7 excerptRows,
8 fileDiffRows,
9 letterStopNotes,
10 rowsToPatch,
11 stepTourPosition,
12} from "./reviewGuideTour.js";
13
14const PATCH = `diff --git a/src/tray.ts b/src/tray.ts
15--- a/src/tray.ts
16+++ b/src/tray.ts
17@@ -10,5 +10,6 @@ function tray()
18 const a = 1;
19-const size = pick();
20+const size = "xs";
21+const variant = "tray";
22 const b = 2;
23 const c = 3;
24 const d = 4;
25@@ -40,3 +41,2 @@ function footer()
26 keep();
27-dropped();
28 tail();
29`;
30
31function parse(patch: string) {
32 const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0];
33 if (file === undefined) throw new Error("patch did not parse");
34 return file;
35}
36
37const rows = fileDiffRows(parse(PATCH));
38
39function stop(overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop {
40 return {
41 title: "Stop",
42 summary: "",
43 attention: null,
44 excerpts: [],
45 files: [],
46 notes: [],
47 questions: [],
48 ...overrides,
49 };
50}
51
52describe("stepTourPosition", () => {
53 it("holds the tour to its first and last stops", () => {
54 expect(stepTourPosition(0, -1, 3)).toBe(0);
55 expect(stepTourPosition(0, 1, 3)).toBe(1);
56 expect(stepTourPosition(2, 1, 3)).toBe(2);
57 });
58});
59
60describe("fileDiffRows", () => {
61 it("numbers both sides through context, deletions and additions", () => {
62 expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([
63 ["context", 10, 10],
64 ["deletion", 11, null],
65 ["addition", null, 11],
66 ["addition", null, 12],
67 ["context", 12, 13],
68 ["context", 13, 14],
69 ["context", 14, 15],
70 ["context", 40, 41],
71 ["deletion", 41, null],
72 ["context", 42, 42],
73 ]);
74 });
75});
76
77describe("excerptRows", () => {
78 it("brings the replaced lines along with an excerpt that starts on an addition", () => {
79 const shown = excerptRows(rows, { side: "new", start: 11, end: 12 });
80 expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]);
81 });
82
83 it("selects old-side lines for a removal and stops at the hunk boundary", () => {
84 const shown = excerptRows(rows, { side: "old", start: 41, end: 41 });
85 expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]);
86 });
87
88 it("returns nothing for a range outside the diff", () => {
89 expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]);
90 });
91});
92
93describe("rowsToPatch", () => {
94 it("cuts a slice that parses back to the same lines and numbers", () => {
95 const shown = excerptRows(rows, { side: "new", start: 10, end: 13 });
96 const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown));
97 expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual(
98 shown.map((row) => [row.kind, row.oldLine, row.newLine]),
99 );
100 });
101
102 it("starts a hunk per source hunk so separate hunks stay separate", () => {
103 const shown = excerptRows(rows, { side: "old", start: 14, end: 42 });
104 const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown);
105 expect(patch.match(/^@@ /gm)).toHaveLength(2);
106 expect(patch).toContain("@@ -14,1 +15,1 @@");
107 expect(patch).toContain("@@ -40,3 +41,2 @@");
108 });
109});
110
111describe("excerptExpansionOldContents", () => {
112 // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the
113 // second hunk, which removed a line.
114 const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`);
115 newFile.splice(
116 9,
117 6,
118 "const a = 1;\n",
119 'const size = "xs";\n',
120 'const variant = "tray";\n',
121 "const b = 2;\n",
122 "const c = 3;\n",
123 "const d = 4;\n",
124 );
125 newFile.splice(40, 2, "keep();\n", "tail();\n");
126 const newContents = newFile.join("");
127
128 it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => {
129 const shown = excerptRows(rows, { side: "new", start: 11, end: 12 });
130 const old = excerptExpansionOldContents(newContents, shown);
131 if (old === null) throw new Error("expected contents");
132 const oldLines = old.split("\n");
133 // The excerpt's old side sits at its real old line.
134 expect(oldLines[10]).toBe("const size = pick();");
135 // Around it, the new file's lines, so the two sides agree outside the excerpt.
136 expect(oldLines[9]).toBe("const a = 1;");
137 expect(oldLines[11]).toBe("const b = 2;");
138 // One line replaced by two: the old side is a line shorter, everything after lines up.
139 expect(oldLines.length).toBe(newContents.split("\n").length - 1);
140 });
141
142 it("does not try to expand an excerpt that spans hunks", () => {
143 const shown = excerptRows(rows, { side: "old", start: 14, end: 42 });
144 expect(excerptExpansionOldContents(newContents, shown)).toBeNull();
145 });
146});
147
148describe("letterStopNotes", () => {
149 it("letters notes in guide order and pins each to the excerpt that holds its line", () => {
150 const { lettered, byExcerpt } = letterStopNotes(
151 stop({
152 excerpts: [
153 { path: "a.ts", side: "new", start: 1, end: 5, caption: "" },
154 { path: "b.ts", side: "new", start: 10, end: 12, caption: "" },
155 ],
156 notes: [
157 { path: "b.ts", line: 11, title: "First", body: "" },
158 { path: "a.ts", line: 2, title: "Second", body: "" },
159 { path: "a.ts", line: 2, title: "Third", body: "" },
160 { path: "c.ts", line: 1, title: "Unanchored", body: "" },
161 ],
162 }),
163 );
164 expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]);
165 expect(byExcerpt).toEqual([
166 [
167 {
168 line: 2,
169 markers: [
170 { marker: "B", title: "Second" },
171 { marker: "C", title: "Third" },
172 ],
173 },
174 ],
175 [{ line: 11, markers: [{ marker: "A", title: "First" }] }],
176 ]);
177 });
178});