Skip to content

Commit d87d97e

Browse files
committed
test(vscode-ide-companion): 添加 DiffPreview 组件单元测试
- 覆盖 null 和空状态渲染行为,确保无效输入返回 null - 验证文件路径显示为可点击链接及其点击行为 - 测试 "Open" 按钮渲染及点击事件调用 wrpc.openFile - 测试 "View Diff" 按钮条件渲染和点击事件调用 wrpc.showDiffEditor - 验证输出文本渲染及空白处理,确保文本被修剪 - 测试 diff 行的正确渲染,包括新增、删除和上下文行的样式 - 处理和测试各种边界条件如长路径、特殊字符输出、截断 diff 和单行 diff - 使用模拟函数验证按钮点击逻辑和 wrpc 调用行为
1 parent a0b214f commit d87d97e

1 file changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
/**
2+
* Unit tests for DiffPreview component
3+
*
4+
* Tests cover:
5+
* - Null/empty state rendering
6+
* - File path display and Open button
7+
* - View Diff button (conditional on diff_preview)
8+
* - Output text rendering
9+
* - Diff line rendering (added, removed, context)
10+
* - Button click handlers (wrpc.openFile, wrpc.showDiffEditor)
11+
* - Edge cases (missing diff, empty output, truncated diffs)
12+
*/
13+
14+
import React from "react";
15+
import { render, screen, fireEvent } from "@testing-library/react";
16+
import { describe, it, expect, vi, beforeEach } from "vitest";
17+
import DiffPreview from "./DiffPreview";
18+
import type { FileToolMetadata } from "@/webview/components/bubbles/ToolBubble";
19+
20+
// ---------------------------------------------------------------------------
21+
// Mocks
22+
// ---------------------------------------------------------------------------
23+
24+
const mockOpenFileMutate = vi.fn<(input: { filePath: string; line: number }) => Promise<{ ok: boolean }>>();
25+
const mockShowDiffEditorMutate =
26+
vi.fn<(input: { filePath: string; diffPreview: string }) => Promise<{ ok: boolean }>>();
27+
28+
vi.mock("@/webview/wrpc", () => ({
29+
wrpc: {
30+
openFile: { mutate: (input: unknown) => mockOpenFileMutate(input as { filePath: string; line: number }) },
31+
showDiffEditor: {
32+
mutate: (input: unknown) => mockShowDiffEditorMutate(input as { filePath: string; diffPreview: string }),
33+
},
34+
},
35+
}));
36+
37+
vi.mock("@/webview/components/ui/button", () => ({
38+
Button: vi.fn(({ children, onClick, title, variant, size, ...props }: Record<string, unknown>) => {
39+
if (variant === "link") {
40+
return (
41+
<button data-testid="file-path-link" onClick={onClick as () => void} title={title as string} {...props}>
42+
{children as React.ReactNode}
43+
</button>
44+
);
45+
}
46+
return (
47+
<button data-testid="action-button" onClick={onClick as () => void} title={title as string} {...props}>
48+
{children as React.ReactNode}
49+
</button>
50+
);
51+
}),
52+
}));
53+
54+
// ---------------------------------------------------------------------------
55+
// Helpers
56+
// ---------------------------------------------------------------------------
57+
58+
function buildMetadata(overrides: Partial<FileToolMetadata> = {}): FileToolMetadata {
59+
return {
60+
file_path: "/src/app.ts",
61+
diff_preview: `--- a/src/app.ts
62+
+++ b/src/app.ts
63+
@@ -5,3 +5,3 @@
64+
const x = 1;
65+
-const y = 2;
66+
+const y = 3;
67+
return x + y;`,
68+
...overrides,
69+
};
70+
}
71+
72+
// ---------------------------------------------------------------------------
73+
// Tests
74+
// ---------------------------------------------------------------------------
75+
76+
describe("DiffPreview", () => {
77+
beforeEach(() => {
78+
vi.clearAllMocks();
79+
});
80+
81+
// ------------------------------------------------------------------
82+
// Null / empty state
83+
// ------------------------------------------------------------------
84+
85+
describe("null / empty state", () => {
86+
it("returns null when metadata is undefined", () => {
87+
const { container } = render(<DiffPreview metadata={undefined as unknown as FileToolMetadata} output="" />);
88+
expect(container).toBeEmptyDOMElement();
89+
});
90+
91+
it("returns null when file_path is undefined", () => {
92+
const { container } = render(<DiffPreview metadata={{ type: "edit" } as FileToolMetadata} output="" />);
93+
expect(container).toBeEmptyDOMElement();
94+
});
95+
96+
it("returns null when file_path is empty string", () => {
97+
const { container } = render(<DiffPreview metadata={{ file_path: "" }} output="" />);
98+
expect(container).toBeEmptyDOMElement();
99+
});
100+
});
101+
102+
// ------------------------------------------------------------------
103+
// File path rendering
104+
// ------------------------------------------------------------------
105+
106+
describe("file path rendering", () => {
107+
it("renders the file path as a clickable link", () => {
108+
render(<DiffPreview metadata={buildMetadata()} output="" />);
109+
const link = screen.getByTestId("file-path-link");
110+
expect(link).toBeInTheDocument();
111+
expect(link).toHaveTextContent("/src/app.ts");
112+
});
113+
114+
it('renders "File" label', () => {
115+
render(<DiffPreview metadata={buildMetadata()} output="" />);
116+
expect(screen.getByText("File")).toBeInTheDocument();
117+
});
118+
119+
it("clicking the file path link calls wrpc.openFile.mutate", () => {
120+
const metadata = buildMetadata();
121+
render(<DiffPreview metadata={metadata} output="" />);
122+
const link = screen.getByTestId("file-path-link");
123+
fireEvent.click(link);
124+
expect(mockOpenFileMutate).toHaveBeenCalledWith({
125+
filePath: metadata.file_path,
126+
line: 1,
127+
});
128+
});
129+
});
130+
131+
// ------------------------------------------------------------------
132+
// Open button
133+
// ------------------------------------------------------------------
134+
135+
describe('"Open" button', () => {
136+
it('renders "Open" button when file_path exists', () => {
137+
render(<DiffPreview metadata={buildMetadata()} output="" />);
138+
const buttons = screen.getAllByTestId("action-button");
139+
const openButton = buttons.find((b) => b.textContent === "Open");
140+
expect(openButton).toBeInTheDocument();
141+
});
142+
143+
it('"Open" button is visible even without diff_preview', () => {
144+
const metadata = buildMetadata({ diff_preview: undefined });
145+
render(<DiffPreview metadata={metadata} output="" />);
146+
const buttons = screen.getAllByTestId("action-button");
147+
const openButton = buttons.find((b) => b.textContent === "Open");
148+
expect(openButton).toBeInTheDocument();
149+
});
150+
151+
it('clicking "Open" button calls wrpc.openFile.mutate', () => {
152+
const metadata = buildMetadata();
153+
render(<DiffPreview metadata={metadata} output="" />);
154+
const buttons = screen.getAllByTestId("action-button");
155+
const openButton = buttons.find((b) => b.textContent === "Open");
156+
fireEvent.click(openButton!);
157+
expect(mockOpenFileMutate).toHaveBeenCalledWith({
158+
filePath: metadata.file_path,
159+
line: 1,
160+
});
161+
});
162+
});
163+
164+
// ------------------------------------------------------------------
165+
// "View Diff" button
166+
// ------------------------------------------------------------------
167+
168+
describe('"View Diff" button', () => {
169+
it('renders "View Diff" button when diff_preview exists', () => {
170+
render(<DiffPreview metadata={buildMetadata()} output="" />);
171+
const buttons = screen.getAllByTestId("action-button");
172+
const viewDiffButton = buttons.find((b) => b.textContent === "View Diff");
173+
expect(viewDiffButton).toBeInTheDocument();
174+
});
175+
176+
it('does NOT render "View Diff" button when diff_preview is undefined', () => {
177+
const metadata = buildMetadata({ diff_preview: undefined });
178+
render(<DiffPreview metadata={metadata} output="" />);
179+
const buttons = screen.getAllByTestId("action-button");
180+
const viewDiffButton = buttons.find((b) => b.textContent === "View Diff");
181+
expect(viewDiffButton).toBeUndefined();
182+
});
183+
184+
it('does NOT render "View Diff" button when diff_preview is empty string', () => {
185+
const metadata = buildMetadata({ diff_preview: "" });
186+
render(<DiffPreview metadata={metadata} output="" />);
187+
const buttons = screen.getAllByTestId("action-button");
188+
const viewDiffButton = buttons.find((b) => b.textContent === "View Diff");
189+
expect(viewDiffButton).toBeUndefined();
190+
});
191+
192+
it('clicking "View Diff" calls wrpc.showDiffEditor.mutate', () => {
193+
const metadata = buildMetadata();
194+
render(<DiffPreview metadata={metadata} output="" />);
195+
const buttons = screen.getAllByTestId("action-button");
196+
const viewDiffButton = buttons.find((b) => b.textContent === "View Diff");
197+
fireEvent.click(viewDiffButton!);
198+
expect(mockShowDiffEditorMutate).toHaveBeenCalledWith({
199+
filePath: metadata.file_path,
200+
diffPreview: metadata.diff_preview,
201+
});
202+
});
203+
});
204+
205+
// ------------------------------------------------------------------
206+
// Output text
207+
// ------------------------------------------------------------------
208+
209+
describe("output text", () => {
210+
it("renders output text when provided", () => {
211+
render(<DiffPreview metadata={buildMetadata()} output="File written successfully" />);
212+
expect(screen.getByText("File written successfully")).toBeInTheDocument();
213+
});
214+
215+
it("does not render output when empty", () => {
216+
render(<DiffPreview metadata={buildMetadata()} output="" />);
217+
// The output div only renders when output is truthy
218+
expect(screen.queryByTestId("file-path-link")).toBeInTheDocument();
219+
expect(screen.queryByText("File written successfully")).not.toBeInTheDocument();
220+
});
221+
222+
it("trims output text", () => {
223+
render(<DiffPreview metadata={buildMetadata()} output=" trimmed output " />);
224+
// The output area should show trimmed text
225+
expect(screen.getByText("trimmed output")).toBeInTheDocument();
226+
});
227+
});
228+
229+
// ------------------------------------------------------------------
230+
// Diff lines rendering
231+
// ------------------------------------------------------------------
232+
233+
describe("diff lines rendering", () => {
234+
it('renders "Changes" header when diff has lines', () => {
235+
render(<DiffPreview metadata={buildMetadata()} output="" />);
236+
expect(screen.getByText("Changes")).toBeInTheDocument();
237+
});
238+
239+
it("does not render diff section when no diff lines after filtering", () => {
240+
// Only header lines, no actual diff content
241+
const noContentDiff = `--- a/src/app.ts
242+
+++ b/src/app.ts
243+
@@ -1,1 +1,1 @@`;
244+
const metadata = buildMetadata({ diff_preview: noContentDiff });
245+
render(<DiffPreview metadata={metadata} output="" />);
246+
expect(screen.queryByText("Changes")).not.toBeInTheDocument();
247+
});
248+
249+
it("renders added lines with success styling", () => {
250+
render(<DiffPreview metadata={buildMetadata()} output="" />);
251+
// The "+" line should have success class
252+
const elements = document.querySelectorAll(".bg-success\\/10, [class*='bg-success']");
253+
expect(elements.length).toBeGreaterThan(0);
254+
});
255+
256+
it("renders removed lines with destructive styling", () => {
257+
render(<DiffPreview metadata={buildMetadata()} output="" />);
258+
// The "-" line should have destructive class
259+
const elements = document.querySelectorAll(".bg-destructive\\/10, [class*='bg-destructive']");
260+
expect(elements.length).toBeGreaterThan(0);
261+
});
262+
263+
it("renders context lines with muted styling", () => {
264+
render(<DiffPreview metadata={buildMetadata()} output="" />);
265+
// Context lines should have text-muted-foreground class
266+
const elements = document.querySelectorAll(".text-muted-foreground");
267+
// At least the "File" label and context line should exist
268+
expect(elements.length).toBeGreaterThanOrEqual(1);
269+
});
270+
271+
it("skips empty lines in diff", () => {
272+
const diffWithEmpty = `--- a/src/app.ts
273+
+++ b/src/app.ts
274+
@@ -1,3 +1,3 @@
275+
276+
const x = 1;
277+
-const y = 2;
278+
+const y = 3;
279+
`;
280+
const metadata = buildMetadata({ diff_preview: diffWithEmpty });
281+
render(<DiffPreview metadata={metadata} output="" />);
282+
// Should still render without crashing
283+
expect(screen.getByText("Changes")).toBeInTheDocument();
284+
});
285+
286+
it("renders prefix indicator for added/removed lines", () => {
287+
const diff = `--- a/test.ts
288+
+++ b/test.ts
289+
@@ -1,2 +1,2 @@
290+
-old line
291+
+new line`;
292+
const metadata = buildMetadata({ diff_preview: diff });
293+
render(<DiffPreview metadata={metadata} output="" />);
294+
295+
// "+" prefix for added lines
296+
const plusSignElements = screen.getAllByText("+");
297+
// There should be at least one "+" sign (from the added line prefix)
298+
const plusPrefix = plusSignElements.find((el) => el.textContent === "+");
299+
expect(plusPrefix).toBeInTheDocument();
300+
301+
// "-" prefix for removed lines
302+
const minusPrefix = screen.queryByText("-");
303+
expect(minusPrefix).toBeInTheDocument();
304+
});
305+
});
306+
307+
// ------------------------------------------------------------------
308+
// Edge cases
309+
// ------------------------------------------------------------------
310+
311+
describe("edge cases", () => {
312+
it("handles file with very long path", () => {
313+
const longPath = "/very/long/path/to/a/file/that/is/deeply/nested/in/the/project/structure/app.ts";
314+
const metadata = buildMetadata({ file_path: longPath });
315+
render(<DiffPreview metadata={metadata} output="" />);
316+
expect(screen.getByText(longPath)).toBeInTheDocument();
317+
});
318+
319+
it("handles output with special characters", () => {
320+
const specialOutput = "<div>HTML content</div>";
321+
render(<DiffPreview metadata={buildMetadata()} output={specialOutput} />);
322+
expect(screen.getByText(specialOutput)).toBeInTheDocument();
323+
});
324+
325+
it("handles diff_preview with truncation marker", () => {
326+
const truncatedDiff = `--- a/large.ts
327+
+++ b/large.ts
328+
@@ -1,40 +1,40 @@
329+
-line 1
330+
-line 2
331+
+new line 1
332+
+new line 2
333+
...`;
334+
const metadata = buildMetadata({ diff_preview: truncatedDiff });
335+
render(<DiffPreview metadata={metadata} output="" />);
336+
// Should render without errors
337+
expect(screen.getByText("Changes")).toBeInTheDocument();
338+
});
339+
340+
it("handles single-line diff (one line added)", () => {
341+
const singleDiff = `--- a/file.ts
342+
+++ b/file.ts
343+
@@ -3,1 +3,1 @@
344+
-old
345+
+new`;
346+
const metadata = buildMetadata({ diff_preview: singleDiff });
347+
render(<DiffPreview metadata={metadata} output="" />);
348+
expect(screen.getByText("Changes")).toBeInTheDocument();
349+
});
350+
});
351+
});

0 commit comments

Comments
 (0)