428519335d
- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The 2f39f4e rewrite had left a
bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
(sessions matched to the lane by longest path-boundary cwd), instead of
every lane's.
- The Changes panel's git line and its actions are one compact row in one
card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
leaving an empty frame where the git row used to be.
161 lines
6.5 KiB
TypeScript
161 lines
6.5 KiB
TypeScript
/**
|
||
* @file LaneGitHeader.test.tsx
|
||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||
* head, subject and uncommitted counts, the per-file hover panel behind the
|
||
* 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, useLaneGitFacts } from "../LaneGitHeader";
|
||
import { api } from "../../../lib/api";
|
||
|
||
vi.mock("../../../lib/api", () => ({
|
||
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, subject and uncommitted counts", 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");
|
||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||
});
|
||
|
||
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 counts on hover, with each one's line delta", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<Harness 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(<Harness 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(<Harness laneId={1} />);
|
||
|
||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|
||
});
|