1import { describe, expect, it } from "vite-plus/test";
2
3import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts";
4import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
5
6import {
7 applyReviewDiffMetadata,
8 buildReviewParsedDiff,
9 buildReviewSectionItems,
10 getDefaultReviewSectionId,
11 getReviewFilePreviewState,
12 getReviewSectionIdForCheckpoint,
13 type ReviewRenderableFile,
14} from "./reviewModel";
15
16function makeCheckpoint(
17 input: Partial<ThreadCheckpointSummary> &
18 Pick<ThreadCheckpointSummary, "runId" | "checkpointTurnCount" | "completedAt">,
19): ThreadCheckpointSummary {
20 return {
21 checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,
22 status: "ready",
23 files: [],
24 assistantMessageId: MessageId.make(`msg-${input.checkpointTurnCount}`),
25 ...input,
26 };
27}
28
29function makeRenderableFile(
30 input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,
31): ReviewRenderableFile {
32 return {
33 id: input.path,
34 cacheKey: input.path,
35 previousPath: null,
36 changeType: "new",
37 additions: 0,
38 deletions: 0,
39 languageHint: null,
40 additionLines: [],
41 deletionLines: [],
42 rows: [],
43 ...input,
44 };
45}
46
47describe("buildReviewSectionItems", () => {
48 it("keeps one chip per checkpoint and appends git sources", () => {
49 const checkpoints = [
50 makeCheckpoint({
51 runId: RunId.make("run-1"),
52 checkpointTurnCount: 1,
53 completedAt: "2026-04-01T00:00:00.000Z",
54 }),
55 makeCheckpoint({
56 runId: RunId.make("run-2"),
57 checkpointTurnCount: 2,
58 completedAt: "2026-04-02T00:00:00.000Z",
59 }),
60 ];
61 const gitSections: ReviewDiffPreviewSource[] = [
62 {
63 id: "working-tree",
64 kind: "working-tree",
65 title: "Uncommitted",
66 baseRef: "HEAD",
67 headRef: null,
68 diff: "diff --git a/a.ts b/a.ts",
69 diffHash: "hash-dirty",
70 truncated: false,
71 },
72 {
73 id: "branch-range",
74 kind: "branch-range",
75 title: "Changes vs main",
76 baseRef: "main",
77 headRef: "feature",
78 diff: "diff --git a/a.ts b/a.ts",
79 diffHash: "hash-base",
80 truncated: false,
81 },
82 ];
83
84 const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]!);
85 const items = buildReviewSectionItems({
86 checkpoints,
87 gitSections,
88 turnDiffById: {
89 [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts",
90 },
91 loadingTurnIds: {
92 [getReviewSectionIdForCheckpoint(checkpoints[1]!)]: true,
93 },
94 loadingGitSections: false,
95 });
96
97 expect(items.map((item) => item.id)).toEqual([
98 "turn:2",
99 "turn:1",
100 "git:working-tree",
101 "git:branch-range",
102 ]);
103 expect(items[0]).toMatchObject({ isLoading: true, diff: null });
104 expect(items[1]).toMatchObject({
105 isLoading: false,
106 diff: expect.stringContaining("loaded.ts"),
107 });
108 expect(getDefaultReviewSectionId(items)).toBe("git:branch-range");
109 });
110
111 it("falls back to the first turn without git sections", () => {
112 const items = buildReviewSectionItems({
113 checkpoints: [
114 makeCheckpoint({
115 runId: RunId.make("run-1"),
116 checkpointTurnCount: 1,
117 completedAt: "2026-04-01T00:00:00.000Z",
118 }),
119 ],
120 gitSections: [],
121 turnDiffById: {},
122 loadingTurnIds: {},
123 loadingGitSections: false,
124 });
125
126 expect(getDefaultReviewSectionId(items)).toBe("turn:1");
127 });
128
129 it("holds the Changes place while git preview is loading", () => {
130 const items = buildReviewSectionItems({
131 checkpoints: [],
132 gitSections: [],
133 turnDiffById: {},
134 loadingTurnIds: {},
135 loadingGitSections: true,
136 });
137
138 expect(items).toEqual([
139 expect.objectContaining({
140 id: "git:branch-range",
141 kind: "branch-range",
142 title: "Changes",
143 diff: null,
144 isLoading: true,
145 }),
146 ]);
147 expect(getDefaultReviewSectionId(items)).toBe("git:branch-range");
148 });
149});
150
151describe("buildReviewParsedDiff", () => {
152 it.each([
153 ["a/example.ts", "a/example.ts"],
154 ["b/example.ts", "b/example.ts"],
155 ["a/before.ts", "b/after.ts"],
156 ])("preserves repository paths from %s to %s", (previousPath, path) => {
157 const parsed = buildReviewParsedDiff(
158 [
159 `diff --git a/${previousPath} b/${path}`,
160 ...(previousPath === path
161 ? []
162 : ["similarity index 50%", `rename from ${previousPath}`, `rename to ${path}`]),
163 `--- a/${previousPath}`,
164 `+++ b/${path}`,
165 "@@ -1 +1 @@",
166 "-before",
167 "+after",
168 ].join("\n"),
169 "repository-paths",
170 );
171 expect(parsed.kind).toBe("files");
172 if (parsed.kind !== "files") return;
173 expect(parsed.files[0]).toMatchObject({
174 path,
175 previousPath: previousPath === path ? null : previousPath,
176 additions: 1,
177 deletions: 1,
178 });
179 });
180
181 it("builds renderable rows from a unified patch", () => {
182 const parsed = buildReviewParsedDiff(
183 [
184 "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts",
185 "index 1111111..2222222 100644",
186 "--- a/apps/mobile/src/a.ts",
187 "+++ b/apps/mobile/src/a.ts",
188 "@@ -1,2 +1,3 @@",
189 "-const before = 1;",
190 "+const after = 2;",
191 "+console.log(after);",
192 " return true;",
193 ].join("\n"),
194 "unit",
195 );
196
197 expect(parsed.kind).toBe("files");
198 if (parsed.kind !== "files") {
199 return;
200 }
201
202 expect(parsed.fileCount).toBe(1);
203 expect(parsed.additions).toBe(2);
204 expect(parsed.deletions).toBe(1);
205 expect(parsed.files[0]).toMatchObject({
206 path: "apps/mobile/src/a.ts",
207 additions: 2,
208 deletions: 1,
209 });
210 expect(parsed.files[0]?.rows).toEqual([
211 expect.objectContaining({ kind: "hunk", header: "@@ -1,2 +1,3 @@" }),
212 expect.objectContaining({
213 kind: "line",
214 change: "delete",
215 oldLineNumber: 1,
216 newLineNumber: null,
217 content: "const before = 1;",
218 comparison: { change: "add", tokenIndex: 0 },
219 }),
220 expect.objectContaining({
221 kind: "line",
222 change: "add",
223 oldLineNumber: null,
224 newLineNumber: 1,
225 content: "const after = 2;",
226 comparison: { change: "delete", tokenIndex: 0 },
227 }),
228 expect.objectContaining({
229 kind: "line",
230 change: "add",
231 oldLineNumber: null,
232 newLineNumber: 2,
233 content: "console.log(after);",
234 comparison: null,
235 }),
236 expect.objectContaining({
237 kind: "line",
238 change: "context",
239 oldLineNumber: 2,
240 newLineNumber: 3,
241 content: "return true;",
242 comparison: null,
243 }),
244 ]);
245 });
246
247 it("treats truncated patches as partial diffs instead of failing", () => {
248 const parsed = buildReviewParsedDiff(
249 [
250 "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts",
251 "index 1111111..2222222 100644",
252 "--- a/apps/mobile/src/a.ts",
253 "+++ b/apps/mobile/src/a.ts",
254 "@@ -1 +1,2 @@",
255 " const before = 1;",
256 "+const after = 2;",
257 "",
258 "[truncated]",
259 ].join("\n"),
260 "unit-truncated",
261 );
262
263 expect(parsed.kind).toBe("files");
264 if (parsed.kind !== "files") {
265 return;
266 }
267
268 expect(parsed.notice).toContain("server size cap");
269 expect(parsed.fileCount).toBe(1);
270 expect(parsed.files[0]?.rows[0]).toMatchObject({
271 kind: "hunk",
272 header: "@@ -1,1 +1,2 @@",
273 });
274 });
275
276 it("suppresses preview for non-text file formats", () => {
277 const preview = getReviewFilePreviewState(
278 makeRenderableFile({
279 path: "apps/mobile/assets/icon.png",
280 }),
281 );
282
283 expect(preview).toMatchObject({
284 kind: "suppressed",
285 reason: "non-text",
286 title: "Non-text file",
287 actionLabel: null,
288 });
289 });
290
291 it("suppresses large diffs until explicitly requested", () => {
292 const preview = getReviewFilePreviewState(
293 makeRenderableFile({
294 path: "apps/mobile/src/big.ts",
295 rows: Array.from({ length: 401 }, (_, index) => ({
296 kind: "line" as const,
297 id: `line-${index}`,
298 change: "add" as const,
299 oldLineNumber: null,
300 newLineNumber: index + 1,
301 content: `const line${index} = ${index};`,
302 additionTokenIndex: index,
303 deletionTokenIndex: null,
304 comparison: null,
305 })),
306 }),
307 );
308
309 expect(preview).toMatchObject({
310 kind: "suppressed",
311 reason: "large",
312 title: "Large diff",
313 actionLabel: "Load diff",
314 });
315 });
316});
317
318describe("applyReviewDiffMetadata", () => {
319 it("uses complete counts even when the preview contains only part of one file", () => {
320 const parsed = buildReviewParsedDiff(
321 [
322 "diff --git a/large.txt b/large.txt",
323 "--- a/large.txt",
324 "+++ b/large.txt",
325 "@@ -1 +1 @@",
326 "-before",
327 "+after",
328 ].join("\n"),
329 "partial",
330 );
331 const result = applyReviewDiffMetadata(parsed, {
332 truncated: true,
333 files: [
334 { path: "large.txt", previousPath: null, additions: 4000, deletions: 3000 },
335 { path: "unseen.txt", previousPath: null, additions: 100, deletions: 20 },
336 ],
337 });
338 expect(result.kind).toBe("files");
339 if (result.kind !== "files") return;
340 expect(result.fileCount).toBe(2);
341 expect(result.additions).toBe(4100);
342 expect(result.deletions).toBe(3020);
343 expect(result.files[0]?.additions).toBe(4000);
344 expect(result.notice).toContain("Counts include all changes");
345 expect(applyReviewDiffMetadata(parsed, null)).toEqual(parsed);
346 });
347});