/** * @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ĩ */ 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 ; } 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(api.lanes.git).toHaveBeenCalledTimes(1); unmount(); vi.advanceTimersByTime(120_000); expect(api.lanes.git).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); });