Covers IntersectionObserver placeholder rendering for 300-file diffs,
single-file bypass, sidebar ref registration, expandAll batch fetching
(25 files → 3 batches of 10), and all FileCard lazy-load states:
default collapsed, loading, success with HunkRows, error+retry, binary,
no-hunks, detail-prop pre-expanded, and collapse/re-expand UX.
Cherry-picks viewport virtualization implementation commit (f804cb19)
onto this branch so the tests run against the actual new code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
7.8 KiB
TypeScript
271 lines
7.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import "@testing-library/jest-dom/vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
import { FileCard } from "./FileCard";
|
|
import type { FileDiff, FileDiffDetail } from "./types";
|
|
|
|
// ── Module mocks ──────────────────────────────────────────────────────────────
|
|
|
|
vi.mock("./HunkRows", () => ({
|
|
HunkRows: ({ hunk }: { hunk: { header: string } }) => (
|
|
<tr data-testid="hunk-row">
|
|
<td>{hunk.header}</td>
|
|
</tr>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./use-syntax-highlight", () => ({
|
|
useHighlightedFile: () => null,
|
|
}));
|
|
|
|
// Hoist mocks so they can be referenced in vi.mock factories
|
|
const { mockGetFileDiff, mockParseUnifiedDiff } = vi.hoisted(() => ({
|
|
mockGetFileDiff: vi.fn(),
|
|
mockParseUnifiedDiff: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/trpc", () => ({
|
|
trpc: {
|
|
getFileDiff: {
|
|
useQuery: (
|
|
input: unknown,
|
|
opts: { enabled: boolean; staleTime?: number },
|
|
) => mockGetFileDiff(input, opts),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("./parse-diff", () => ({
|
|
parseUnifiedDiff: (rawDiff: string) => mockParseUnifiedDiff(rawDiff),
|
|
}));
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function makeFile(overrides: Partial<FileDiff> = {}): FileDiff {
|
|
return {
|
|
oldPath: "src/foo.ts",
|
|
newPath: "src/foo.ts",
|
|
status: "modified",
|
|
additions: 10,
|
|
deletions: 5,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const defaultProps = {
|
|
phaseId: "phase-1",
|
|
commitMode: false,
|
|
commentsByLine: new Map(),
|
|
onAddComment: vi.fn(),
|
|
onResolveComment: vi.fn(),
|
|
onUnresolveComment: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockGetFileDiff.mockReturnValue({
|
|
data: undefined,
|
|
isLoading: false,
|
|
isError: false,
|
|
refetch: vi.fn(),
|
|
});
|
|
// Default: return empty parse result
|
|
mockParseUnifiedDiff.mockReturnValue([]);
|
|
});
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("FileCard", () => {
|
|
it("starts collapsed and does not enable getFileDiff query", () => {
|
|
render(<FileCard file={makeFile()} {...defaultProps} />);
|
|
|
|
// Query must be called with enabled: false while card is collapsed
|
|
expect(mockGetFileDiff).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
filePath: encodeURIComponent("src/foo.ts"),
|
|
}),
|
|
expect.objectContaining({ enabled: false }),
|
|
);
|
|
|
|
// No hunk rows rendered in the collapsed state
|
|
expect(screen.queryByTestId("hunk-row")).toBeNull();
|
|
});
|
|
|
|
it("enables query and shows loading spinner when expanded", () => {
|
|
mockGetFileDiff.mockReturnValue({
|
|
data: undefined,
|
|
isLoading: true,
|
|
isError: false,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
render(<FileCard file={makeFile()} {...defaultProps} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
|
|
// After expanding, query should be called with enabled: true
|
|
expect(mockGetFileDiff).toHaveBeenLastCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ enabled: true }),
|
|
);
|
|
|
|
// Loading spinner should be visible
|
|
expect(screen.getByText(/Loading diff/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders HunkRows when query succeeds", async () => {
|
|
mockGetFileDiff.mockReturnValue({
|
|
data: {
|
|
binary: false,
|
|
rawDiff:
|
|
"diff --git a/src/foo.ts b/src/foo.ts\n@@ -1,3 +1,3 @@\n context\n",
|
|
},
|
|
isLoading: false,
|
|
isError: false,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
mockParseUnifiedDiff.mockReturnValue([
|
|
{
|
|
oldPath: "src/foo.ts",
|
|
newPath: "src/foo.ts",
|
|
status: "modified",
|
|
additions: 0,
|
|
deletions: 0,
|
|
hunks: [
|
|
{
|
|
header: "@@ -1,3 +1,3 @@",
|
|
oldStart: 1,
|
|
oldCount: 3,
|
|
newStart: 1,
|
|
newCount: 3,
|
|
lines: [],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
render(<FileCard file={makeFile()} {...defaultProps} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("hunk-row")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows error state with Retry button; clicking retry calls refetch", () => {
|
|
const refetch = vi.fn();
|
|
mockGetFileDiff.mockReturnValue({
|
|
data: undefined,
|
|
isLoading: false,
|
|
isError: true,
|
|
refetch,
|
|
});
|
|
|
|
render(<FileCard file={makeFile()} {...defaultProps} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
|
|
expect(screen.getByText(/Failed to load diff/i)).toBeInTheDocument();
|
|
const retryBtn = screen.getByRole("button", { name: /retry/i });
|
|
fireEvent.click(retryBtn);
|
|
expect(refetch).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("shows binary message on expand and does not enable getFileDiff query", () => {
|
|
render(<FileCard file={makeFile({ status: "binary" })} {...defaultProps} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
|
|
expect(screen.getByText(/Binary file/i)).toBeInTheDocument();
|
|
|
|
// Query must never be enabled for binary files
|
|
expect(mockGetFileDiff).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ enabled: false }),
|
|
);
|
|
});
|
|
|
|
it("shows No content changes when parsed hunks array is empty", async () => {
|
|
mockGetFileDiff.mockReturnValue({
|
|
data: {
|
|
binary: false,
|
|
rawDiff: "diff --git a/src/foo.ts b/src/foo.ts\nsome content\n",
|
|
},
|
|
isLoading: false,
|
|
isError: false,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
mockParseUnifiedDiff.mockReturnValue([
|
|
{
|
|
oldPath: "src/foo.ts",
|
|
newPath: "src/foo.ts",
|
|
status: "modified",
|
|
additions: 0,
|
|
deletions: 0,
|
|
hunks: [],
|
|
},
|
|
]);
|
|
|
|
render(<FileCard file={makeFile()} {...defaultProps} />);
|
|
fireEvent.click(screen.getByRole("button"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/No content changes/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders pre-parsed hunks from detail prop without fetching", () => {
|
|
const detail: FileDiffDetail = {
|
|
oldPath: "src/foo.ts",
|
|
newPath: "src/foo.ts",
|
|
status: "modified",
|
|
additions: 5,
|
|
deletions: 2,
|
|
hunks: [
|
|
{
|
|
header: "@@ -1 +1 @@",
|
|
oldStart: 1,
|
|
oldCount: 1,
|
|
newStart: 1,
|
|
newCount: 1,
|
|
lines: [],
|
|
},
|
|
],
|
|
};
|
|
|
|
render(<FileCard file={makeFile()} detail={detail} {...defaultProps} />);
|
|
|
|
// Should start expanded because detail prop is provided
|
|
expect(screen.getByTestId("hunk-row")).toBeInTheDocument();
|
|
|
|
// Query must not be enabled when detail prop is present
|
|
expect(mockGetFileDiff).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ enabled: false }),
|
|
);
|
|
});
|
|
|
|
it("does not refetch when collapsing and re-expanding", () => {
|
|
// Simulate data already available (as if previously fetched and cached)
|
|
mockGetFileDiff.mockReturnValue({
|
|
data: { binary: false, rawDiff: "" },
|
|
isLoading: false,
|
|
isError: false,
|
|
refetch: vi.fn(),
|
|
});
|
|
|
|
render(<FileCard file={makeFile()} {...defaultProps} />);
|
|
const headerBtn = screen.getByRole("button");
|
|
|
|
// Expand: query enabled, data shown immediately (no loading)
|
|
fireEvent.click(headerBtn);
|
|
expect(screen.queryByText(/Loading diff/i)).toBeNull();
|
|
|
|
// Collapse
|
|
fireEvent.click(headerBtn);
|
|
|
|
// Re-expand: should not enter loading state (data still available)
|
|
fireEvent.click(headerBtn);
|
|
expect(screen.queryByText(/Loading diff/i)).toBeNull();
|
|
});
|
|
});
|