| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contracts"; |
| 4 | import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; |
| 5 | |
| 6 | import { |
| 7 | applyReviewDiffMetadata, |
| 8 | buildReviewParsedDiff, |
| 9 | buildReviewSectionItems, |
| 10 | getDefaultReviewSectionId, |
| 11 | getReviewFilePreviewState, |
| 12 | getReviewSectionIdForCheckpoint, |
| 13 | type ReviewRenderableFile, |
| 14 | } from "./reviewModel"; |
| 15 | |
| 16 | function 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 | |
| 29 | function 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 | |
| 47 | describe("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 | |
| 151 | describe("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 | |
| 318 | describe("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 | }); |