1import { describe, expect, it, vi } from "vite-plus/test";
2import {
3 DEFAULT_MOBILE_THEME_ID,
4 getMobileThemeVariables,
5 MOBILE_THEME_IDS,
6 type MobileThemeAppearance,
7 type MobileThemeId,
8} from "../../lib/mobileTheme";
9import { readDefaultMobileThemeVariables } from "../../lib/mobileTheme.test-support";
10
11import {
12 buildNativeReviewDiffData,
13 buildNativeReviewSnippetRows,
14 createNativeReviewDiffTheme,
15 getCachedNativeReviewDiffData,
16 type BuildNativeReviewDiffDataInput,
17} from "./nativeReviewDiffAdapter";
18import type { ReviewInlineComment } from "./reviewCommentSelection";
19import { buildReviewParsedDiff } from "./reviewModel";
20import * as ReviewWordDiffs from "./reviewWordDiffs";
21
22const parsedDiff = buildReviewParsedDiff(
23 [
24 "diff --git a/example.ts b/example.ts",
25 "--- a/example.ts",
26 "+++ b/example.ts",
27 "@@ -1 +1 @@",
28 "-const before = 1;",
29 "+const after = 2;",
30 ].join("\n"),
31 "native-review-cache-test",
32);
33
34describe("buildNativeReviewSnippetRows", () => {
35 it("preserves selected code and change types without inventing line numbers", () => {
36 const rows = buildNativeReviewSnippetRows({
37 id: "selection",
38 diff: " unchanged\r\n- before\r\n+ after\r\n",
39 });
40 expect(
41 rows.map((row) => [row.content, row.change, row.oldLineNumber, row.newLineNumber]),
42 ).toEqual([
43 [" unchanged", "context", null, null],
44 [" before", "delete", null, null],
45 [" after", "add", null, null],
46 ]);
47 });
48
49 it("leaves full patches, unrecognized text, and non-diff code to their existing renderers", () => {
50 for (const diff of ["@@ -1 +1 @@\n-old\n+new", "--- a/file\n+++ b/file", "plain text", ""]) {
51 expect(buildNativeReviewSnippetRows({ id: "selection", diff })).toEqual([]);
52 }
53 expect(
54 buildNativeReviewSnippetRows({ id: "code", diff: "+value", fenceLanguage: "typescript" }),
55 ).toEqual([]);
56 });
57});
58
59function makeComment(text: string): ReviewInlineComment {
60 return {
61 id: "comment-1",
62 sectionId: "git:working-tree",
63 sectionTitle: "Dirty worktree",
64 filePath: "example.ts",
65 startIndex: 0,
66 endIndex: 0,
67 rangeLabel: "-1",
68 text,
69 diff: "@@ -1,1 +1,0 @@\n-const before = 1;",
70 };
71}
72
73function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) {
74 return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput;
75}
76
77function filesPatch(paths: ReadonlyArray<string>) {
78 return paths
79 .map((path) =>
80 [
81 `diff --git a/${path} b/${path}`,
82 `--- a/${path}`,
83 `+++ b/${path}`,
84 "@@ -1,2 +1,2 @@",
85 '-const first = renderPanel({ label: "before", enabled: true });',
86 '-const second = renderPanel({ label: "before", enabled: true });',
87 '+const first = renderPanel({ label: "after", enabled: true });',
88 '+const second = renderPanel({ label: "after", enabled: true });',
89 ].join("\n"),
90 )
91 .join("\n");
92}
93
94function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {
95 return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"
96 ? readDefaultMobileThemeVariables(appearance)
97 : getMobileThemeVariables(themeId, appearance);
98}
99
100function contrastRatio(first: string, second: string): number {
101 const luminance = (hex: string) => {
102 const [red, green, blue] = [1, 3, 5].map((offset) => {
103 const channel = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
104 return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
105 });
106 return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
107 };
108 const a = luminance(first);
109 const b = luminance(second);
110 return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
111}
112
113describe("getCachedNativeReviewDiffData", () => {
114 it.each([true, false])(
115 "preserves available diff rows before a notice (has excerpt: %s)",
116 (hasExcerpt) => {
117 if (parsedDiff.kind !== "files") throw new Error("Expected a parsed file diff");
118 const notice = "This file preview was truncated.";
119 const result = buildNativeReviewDiffData({
120 parsedDiff: {
121 ...parsedDiff,
122 files: parsedDiff.files.map((file) => ({
123 ...file,
124 rows: hasExcerpt ? file.rows : [],
125 notice,
126 })),
127 },
128 });
129 const original = buildNativeReviewDiffData({ parsedDiff });
130 expect(result.rows.slice(0, -1)).toEqual(
131 hasExcerpt ? original.rows : original.rows.filter((row) => row.kind === "file"),
132 );
133 expect(result.rows.at(-1)).toMatchObject({ kind: "notice", text: notice });
134 },
135 );
136
137 it("reuses the row model for equivalent empty comment arrays", () => {
138 const first = getCachedNativeReviewDiffData(buildInput([]));
139 const second = getCachedNativeReviewDiffData(buildInput([]));
140
141 expect(second).toBe(first);
142 });
143
144 it("reuses equivalent comment contents and invalidates changed comments", () => {
145 const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
146 const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")]));
147 const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")]));
148
149 expect(equivalent).toBe(first);
150 expect(changed).not.toBe(first);
151 expect(changed.rows.find((row) => row.kind === "comment")?.commentText).toBe("Changed");
152 });
153
154 it("reuses source rows and word matching when a file comment changes", () => {
155 const diff = buildReviewParsedDiff(filesPatch(["example.ts", "second.ts"]), "comment-reuse");
156 const matchWords = vi.spyOn(ReviewWordDiffs, "computeWordAltDiffRanges");
157 try {
158 const base = getCachedNativeReviewDiffData({ parsedDiff: diff });
159 expect(matchWords).toHaveBeenCalledTimes(4);
160 expect(base.rows.filter((row) => row.wordDiffRanges?.length)).toHaveLength(8);
161 const firstComment = makeComment("First file comment");
162 const secondComment = {
163 ...makeComment("Second file comment"),
164 id: "comment-2",
165 filePath: "second.ts",
166 };
167 const first = getCachedNativeReviewDiffData({
168 parsedDiff: diff,
169 comments: [firstComment, secondComment],
170 });
171 const changed = getCachedNativeReviewDiffData({
172 parsedDiff: diff,
173 comments: [{ ...firstComment, text: "Changed first comment" }, secondComment],
174 });
175
176 expect(matchWords).toHaveBeenCalledTimes(4);
177 expect(changed.files).toBe(base.files);
178 expect(changed.commentTargetsByRowId).toBe(base.commentTargetsByRowId);
179 expect(changed.rowIdByCommentLineId).toBe(base.rowIdByCommentLineId);
180 expect(changed.rows.find((row) => row.id === secondComment.id)).toBe(
181 first.rows.find((row) => row.id === secondComment.id),
182 );
183 const sourceRows = changed.rows.filter((row) => row.kind !== "comment");
184 for (const [index, row] of sourceRows.entries()) {
185 expect(row).toBe(base.rows[index]);
186 }
187 const removed = getCachedNativeReviewDiffData({ parsedDiff: diff, comments: [] });
188 expect(removed.rows).toEqual(base.rows);
189 expect(matchWords).toHaveBeenCalledTimes(4);
190 expect(first.rows.find((row) => row.id === firstComment.id)?.commentText).toBe(
191 "First file comment",
192 );
193 } finally {
194 matchWords.mockRestore();
195 }
196 });
197
198 it("updates comment titles and locations without changing source coordinates", () => {
199 const diff = buildReviewParsedDiff(filesPatch(["example.ts", "second.ts"]), "comment-metadata");
200 const comment = makeComment("Review this line");
201 const first = getCachedNativeReviewDiffData({ parsedDiff: diff, comments: [comment] });
202 const renamed = getCachedNativeReviewDiffData({
203 parsedDiff: diff,
204 comments: [{ ...comment, sectionTitle: "Branch comparison" }],
205 });
206 expect(renamed.rows.find((row) => row.id === comment.id)?.commentSectionTitle).toBe(
207 "Branch comparison",
208 );
209 const moved = getCachedNativeReviewDiffData({
210 parsedDiff: diff,
211 comments: [{ ...comment, filePath: "second.ts", endIndex: 99, rangeLabel: "+2" }],
212 });
213 const commentIndex = moved.rows.findIndex((row) => row.id === comment.id);
214 expect(moved.rows[commentIndex]).toMatchObject({
215 kind: "comment",
216 filePath: "second.ts",
217 commentRangeLabel: "+2",
218 });
219 expect(moved.rows[commentIndex - 1]).toMatchObject({
220 kind: "line",
221 change: "add",
222 newLineNumber: 2,
223 });
224 expect(moved.rows.filter((row) => row.kind !== "comment")).toEqual(
225 first.rows.filter((row) => row.kind !== "comment"),
226 );
227 expect(first.rows.find((row) => row.id === comment.id)?.commentSectionTitle).toBe(
228 "Dirty worktree",
229 );
230 });
231
232 it("keeps cached IDs and targets local to each parsed section and file layout", () => {
233 const original = filesPatch(["example.ts", "second.ts"]);
234 const variants = [
235 buildReviewParsedDiff(original, "first-section"),
236 buildReviewParsedDiff(original, "second-section"),
237 buildReviewParsedDiff(
238 filesPatch(["inserted.ts", "example.ts", "second.ts"]),
239 "first-section",
240 ),
241 buildReviewParsedDiff(filesPatch(["second.ts", "example.ts"]), "first-section"),
242 buildReviewParsedDiff(filesPatch(["example.ts"]), "first-section"),
243 buildReviewParsedDiff(
244 original.replaceAll("@@ -1,2 +1,2 @@", "@@ -7,2 +12,2 @@"),
245 "first-section",
246 ),
247 ];
248 for (const diff of [...variants, variants[0]!]) {
249 const input = { parsedDiff: diff, comments: [makeComment("Coordinates")] };
250 expect(getCachedNativeReviewDiffData(input)).toEqual(buildNativeReviewDiffData(input));
251 }
252 });
253});
254
255describe("createNativeReviewDiffTheme", () => {
256 it("serializes every native color as cross-platform opaque hex", () => {
257 for (const themeId of MOBILE_THEME_IDS) {
258 for (const appearance of ["light", "dark"] as const) {
259 const theme = createNativeReviewDiffTheme(
260 appearance,
261 themeId,
262 appTheme(themeId, appearance),
263 );
264 for (const color of Object.values(theme)) {
265 expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i);
266 }
267 expect(
268 contrastRatio(theme.hunkText, theme.hunkBackground),
269 `${themeId}/${appearance} hunk text`,
270 ).toBeGreaterThanOrEqual(4.5);
271 }
272 }
273 });
274
275 it.each(["light", "dark"] as const)(
276 "preserves Material You RGBA hex channels and composites alpha in %s",
277 (appearance) => {
278 const variables = {
279 ...appTheme("material-you", appearance),
280 "--color-screen": "#101214FF",
281 "--color-md-code-bg": "#20222480",
282 "--color-md-code-text": "#E3E2E6FF",
283 "--color-foreground-muted": "#C7C5D080",
284 "--color-border": "#44464F80",
285 "--color-primary": "#A8C7FAFF",
286 };
287 const theme = createNativeReviewDiffTheme(appearance, "material-you", variables);
288 expect(theme.background).toBe("#181a1c");
289 expect(theme.headerBackground).toBe(theme.background);
290 expect(theme.text).toBe("#e3e2e6");
291 expect(theme.mutedText).toBe("#707076");
292 expect(theme.border).toBe("#2e3036");
293 expect(contrastRatio(theme.hunkText, theme.hunkBackground)).toBeGreaterThanOrEqual(4.5);
294 for (const color of Object.values(theme)) {
295 expect(color).toMatch(/^#[\da-f]{6}$/i);
296 }
297 },
298 );
299
300 it("uses the selected app palette for native code surfaces", () => {
301 const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark"));
302 const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark"));
303
304 expect(iris.background).not.toBe(standard.background);
305 expect(iris.hunkText).not.toBe(standard.hunkText);
306 expect(iris.addBar).toBe(standard.addBar);
307 expect(iris.deleteBar).toBe(standard.deleteBar);
308 });
309});