daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
187 lines
7.8 KiB
TypeScript
187 lines
7.8 KiB
TypeScript
/**
|
||
* @file LaneGitHeader.test.tsx
|
||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||
* head, subject and uncommitted counts (no hover there — the panel lists the
|
||
* files below), the per-file hover panel behind the closed rail's vertical
|
||
* counts, that a lane switch drops the previous lane's facts at once, and that
|
||
* an unreadable repo renders nothing rather than an error.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||
import { LaneGitHeader, LaneGitRailCounts, useLaneGitFacts } from "../LaneGitHeader";
|
||
import { api } from "../../../lib/api";
|
||
|
||
vi.mock("../../../lib/api", () => ({
|
||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||
}));
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||
});
|
||
|
||
/** The panel's own wiring: one facts read feeding the header. */
|
||
function Harness({ laneId }: { laneId: number }) {
|
||
const [git, refresh] = useLaneGitFacts(laneId);
|
||
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
|
||
}
|
||
|
||
describe("LaneGitHeader", () => {
|
||
const facts = {
|
||
available: true as const,
|
||
branch: "feat/rename-metric",
|
||
head: "9b3e74a",
|
||
subject: "free-text rule mode in the form",
|
||
dirty: 2,
|
||
untracked: 1,
|
||
files: [
|
||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||
],
|
||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||
// been pushed reports nulls rather than zeros.
|
||
hasRemote: true,
|
||
upstream: null,
|
||
ahead: null,
|
||
behind: null,
|
||
};
|
||
|
||
it("renders the live branch, head and subject", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<Harness laneId={1} />);
|
||
|
||
const block = await screen.findByTestId("lane-git");
|
||
expect(block.textContent).toContain("feat/rename-metric");
|
||
expect(block.textContent).toContain("9b3e74a");
|
||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||
});
|
||
|
||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
const { rerender } = render(<Harness laneId={1} />);
|
||
await screen.findByText(/feat\/rename-metric/);
|
||
|
||
// The next lane's read never lands: nothing of lane 1's git may linger.
|
||
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
|
||
rerender(<Harness laneId={2} />);
|
||
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
|
||
});
|
||
|
||
it("names the files behind the rail's counts on hover, with each one's line delta", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<LaneGitRailCounts laneId={1} />);
|
||
|
||
const counts = await screen.findByTestId("lane-git-counts");
|
||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||
|
||
fireEvent.mouseEnter(counts);
|
||
const panel = screen.getByTestId("lane-git-files");
|
||
expect(panel.textContent).toContain("alerts.js");
|
||
expect(panel.textContent).toContain("+12");
|
||
expect(panel.textContent).toContain("−3");
|
||
// Totals across the listed files.
|
||
expect(panel.textContent).toContain("+13");
|
||
// An untracked file has no delta to report and says so rather than "+0".
|
||
expect(panel.textContent).toContain("new");
|
||
expect(panel.textContent).toContain("3 files changed");
|
||
|
||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||
// it — the panel is portalled to the body, away from its trigger.
|
||
fireEvent.mouseLeave(counts);
|
||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||
});
|
||
|
||
it("stays open while the pointer is inside the panel", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<LaneGitRailCounts laneId={1} />);
|
||
|
||
const counts = await screen.findByTestId("lane-git-counts");
|
||
fireEvent.mouseEnter(counts);
|
||
fireEvent.mouseLeave(counts);
|
||
// The pointer landed on the panel before the pending close fired.
|
||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||
|
||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||
});
|
||
|
||
it("says how many files the server-side cap left out", async () => {
|
||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||
render(<LaneGitRailCounts laneId={1} />);
|
||
|
||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
|
||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||
});
|
||
|
||
it("opens the Changes panel from the file list's “more files”", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||
const onShowAll = vi.fn();
|
||
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||
|
||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||
expect(onShowAll).toHaveBeenCalledOnce();
|
||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||
});
|
||
|
||
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<Harness laneId={1} />);
|
||
await screen.findByTestId("lane-git");
|
||
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
|
||
});
|
||
|
||
it("draws the rail's counts vertical", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<LaneGitRailCounts laneId={1} />);
|
||
expect((await screen.findByTestId("lane-git-counts")).className).toContain("vertical-rl");
|
||
});
|
||
|
||
it("omits the uncommitted line when the tree is clean", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||
render(<Harness laneId={1} />);
|
||
const block = await screen.findByTestId("lane-git");
|
||
expect(block.textContent).not.toContain("modified");
|
||
});
|
||
|
||
it("renders nothing and no error when git is unavailable", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||
render(<Harness laneId={2} />);
|
||
|
||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
|
||
expect(screen.queryByTestId("lane-git-header")).toBeNull();
|
||
});
|
||
|
||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||
render(<Harness laneId={3} />);
|
||
|
||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||
expect(screen.queryByText(/network down/)).toBeNull();
|
||
});
|
||
|
||
it("stops polling once the panel unmounts", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||
const { unmount } = render(<Harness laneId={1} />);
|
||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||
unmount();
|
||
vi.advanceTimersByTime(120_000);
|
||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
});
|