/** * @file LaneDiffView.test.tsx * @description Pins the side-by-side renderer: two independently scrolling * columns (a long line must be reachable, not painted over its neighbour) * whose horizontal scroll is mirrored, so the two halves of a line stay * opposite each other. Also pins the clamped mirroring (a narrow column may * not drag the wide one back) and the row windowing of large diffs. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { LaneDiffView, LaneFileView, ROW_PX, WINDOW_THRESHOLD, analyseHunk, visibleRange, } from "../LaneDiffView"; import type { LaneChangeFile } from "../../../lib/types"; const FILE: LaneChangeFile = { path: "server/lib/git-ops.js", status: " M", untracked: false, added: 1, deleted: 1, binary: false, hunks: [ { header: "@@ -1,2 +1,2 @@", context: "", oldStart: 1, newStart: 1, lines: [ { type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` }, { type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` }, ], }, ], }; /** jsdom has no layout: give a scroller the extents a browser would. */ function layout( el: HTMLElement, g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number } ) { for (const [k, v] of Object.entries(g)) Object.defineProperty(el, k, { value: v, configurable: true }); } const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 }; describe("LaneDiffView — side by side", () => { it("gives each column its own scroller", () => { render(); expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto"); expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto"); }); it("keeps an empty filler row one line tall, so the columns stay in step", () => { // Two lines removed, none added: the right column's two rows are fillers. const file = fileWith([ { type: "del", oldLine: 1, newLine: null, text: "gone one" }, { type: "del", oldLine: 2, newLine: null, text: "gone two" }, ]); render(); const fillers = [ ...screen .getByTestId("lane-diff-split-right") .querySelectorAll("div.flex.w-max"), ]; expect(fillers).toHaveLength(2); for (const row of fillers) expect(row.style.minHeight).toBe("16px"); }); it("caps a review diff only when it windows; the single-file view fills its parent", () => { // Unwindowed, a review-list diff grows so the PANEL is the one vertical // scroller (nested 60vh boxes made hunk navigation land nowhere); a // windowed one keeps the cap because windowing needs its own scroller. const { rerender } = render(); expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]"); const big: LaneChangeFile = { ...FILE, hunks: [ { ...FILE.hunks![0]!, lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({ type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i}`, })), }, ], }; rerender(); expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]"); rerender(); expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1"); expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]"); }); it("mirrors one column's horizontal scroll onto the other", () => { render(); const left = screen.getByTestId("lane-diff-split-left"); const right = screen.getByTestId("lane-diff-split-right"); layout(left, ROOMY); layout(right, ROOMY); left.scrollLeft = 320; fireEvent.scroll(left); expect(right.scrollLeft).toBe(320); right.scrollLeft = 0; fireEvent.scroll(right); expect(left.scrollLeft).toBe(0); }); it("keeps the split columns on the same row vertically too", () => { render(); const left = screen.getByTestId("lane-diff-split-left"); const right = screen.getByTestId("lane-diff-split-right"); layout(left, ROOMY); layout(right, ROOMY); left.scrollTop = 480; fireEvent.scroll(left); expect(right.scrollTop).toBe(480); right.scrollTop = 40; fireEvent.scroll(right); expect(left.scrollTop).toBe(40); }); it("clamps the mirrored offset to the other column's own extent", () => { render(); const left = screen.getByTestId("lane-diff-split-left"); const right = screen.getByTestId("lane-diff-split-right"); layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 }); layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 }); right.scrollLeft = 600; right.scrollTop = 250; fireEvent.scroll(right); expect(left.scrollLeft).toBe(300); expect(left.scrollTop).toBe(100); }); it("does not let the narrow column's clamped echo drag the wide one back", () => { render(); const left = screen.getByTestId("lane-diff-split-left"); const right = screen.getByTestId("lane-diff-split-right"); layout(left, { scrollWidth: 500, clientWidth: 200 }); layout(right, { scrollWidth: 1000, clientWidth: 200 }); right.scrollLeft = 600; fireEvent.scroll(right); // A browser fires the narrow column's scroll for the clamped write. fireEvent.scroll(left); expect(right.scrollLeft).toBe(600); // A real move of the narrow column still carries the wide one. left.scrollLeft = 100; fireEvent.scroll(left); expect(right.scrollLeft).toBe(100); }); it("keeps the unified layout on one scroller", () => { render(); expect(screen.queryByTestId("lane-diff-split-left")).toBeNull(); expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto"); }); }); function fileWith(lines: NonNullable[number]["lines"]): LaneChangeFile { return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] }; } const ROWS = fileWith([ { type: "ctx", oldLine: 1, newLine: 1, text: "keep()" }, { type: "del", oldLine: 2, newLine: null, text: "const x = 1;" }, { type: "add", oldLine: null, newLine: 2, text: "const x = 2;" }, ]); /** The unified row whose text contains `needle`. */ function rowOf(container: HTMLElement, needle: string): HTMLElement { const row = [...container.querySelectorAll("div.flex.w-max")].find((r) => r.textContent?.includes(needle) ); if (!row) throw new Error(`no row for ${needle}`); return row; } describe("LaneDiffView — row styling", () => { // A line added or removed whole: nothing in common with its pair. const WHOLE = fileWith([ { type: "ctx", oldLine: 1, newLine: 1, text: "keep()" }, { type: "del", oldLine: 2, newLine: null, text: "abc" }, { type: "add", oldLine: null, newLine: 2, text: "xyz" }, ]); it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => { const { container } = render(); const add = rowOf(container, "xyz"); const del = rowOf(container, "abc"); const ctx = rowOf(container, "keep()"); expect(add.className).toContain("bg-status-success/20"); expect(add.className).toContain("--status-success"); expect(del.className).toContain("bg-status-danger/20"); expect(del.className).toContain("--status-danger"); expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/); }); it("tints a line edited in place blue on both sides", () => { const { container } = render(); for (const text of ["x = 1", "x = 2"]) { const row = rowOf(container, text); expect(row.className).toContain("bg-diff-mod/20"); expect(row.className).not.toMatch(/status-(success|danger)/); } }); it("colours the +/- marker bold in its status colour, context marker muted", () => { const { container } = render(); const marker = (row: HTMLElement) => row.querySelector("span.select-none:not(.w-10)")!; const plus = marker(rowOf(container, "xyz")); const minus = marker(rowOf(container, "abc")); const ctx = marker(rowOf(container, "keep()")); expect(plus.textContent).toBe("+"); expect(plus.className).toContain("font-bold"); expect(plus.className).toContain("text-status-success"); expect(minus.textContent).toBe("-"); expect(minus.className).toContain("text-status-danger"); expect(ctx.className).toContain("text-fg-muted"); expect(ctx.className).not.toContain("font-bold"); }); it("emphasises only the changed span of a paired del/add", () => { const { container } = render(); const emph = [...container.querySelectorAll("span.bg-diff-mod\\/40")].map( (e) => e.textContent ); expect(emph).toEqual(["1", "2"]); }); it("does not emphasise a line that changed completely", () => { const { container } = render( ); expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull(); }); it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => { render( ); const rows = (id: string) => [ ...screen.getByTestId(id).querySelectorAll("div.flex.w-max"), ]; const [right] = rows("lane-diff-split-right"); const [left] = rows("lane-diff-split-left"); expect(right!.className).toContain("bg-status-success/20"); expect(left!.className).not.toMatch(/status-(success|danger)/); }); }); describe("analyseHunk", () => { const hunk = { header: "@@ -1,4 +1,4 @@", context: "", oldStart: 1, newStart: 1, lines: [ { type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" }, { type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" }, { type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" }, { type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" }, { type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" }, ], }; it("pairs each del/add with its emphasis, keyed by line index", () => { const a = analyseHunk(hunk); expect(a.rows).toHaveLength(3); expect(a.emph.get(1)).toEqual([6, 7]); expect(a.emph.get(2)).toEqual([6, 7]); expect(a.emph.get(3)).toEqual([6, 7]); expect(a.emph.get(4)).toEqual([6, 7]); expect(a.emph.has(0)).toBe(false); }); it("computes once per hunk, so both split columns and re-renders share it", () => { expect(analyseHunk(hunk)).toBe(analyseHunk(hunk)); }); }); describe("visibleRange", () => { it("covers the viewport plus overscan on both sides", () => { expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 }); expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 }); }); it("clamps a scrollTop past the content to the last full viewport", () => { expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 }); expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 }); }); it("never runs past a short list", () => { expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 }); expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 }); }); }); function bigFile(n: number): LaneChangeFile { const lines = Array.from({ length: n }, (_, i) => i % 2 ? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` } : { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` } ); lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` }; return fileWith(lines); } const rowsIn = (el: HTMLElement) => [...el.querySelectorAll("div.flex.w-max")]; describe("LaneDiffView — windowing", () => { it("renders every row below the threshold, with no spacers", () => { const { container } = render( ); expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10); expect(container.querySelector("[data-diff-spacer]")).toBeNull(); }); it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => { const { container } = render(); const rows = rowsIn(container); expect(rows.length).toBeGreaterThan(0); expect(rows.length).toBeLessThan(300); const spacers = [...container.querySelectorAll("[data-diff-spacer]")]; const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0); // header + 5000 lines, every item one fixed row tall const rendered = container.querySelectorAll("[data-diff-item]").length; expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX); }); it("moves the window with the scroll", () => { const { container } = render(); const view = screen.getByTestId("lane-diff-view"); expect(container.textContent).not.toContain("line 3000;"); view.scrollTop = 3000 * ROW_PX; fireEvent.scroll(view); expect(container.textContent).toContain("line 3000;"); expect(container.textContent).not.toContain("line 10;"); }); it("starts windowing where the reader is when a diff grows past the threshold", () => { const { container, rerender } = render(); const view = screen.getByTestId("lane-diff-view"); view.scrollTop = 350 * ROW_PX; fireEvent.scroll(view); // Expanding context replaces the file in place, same mounted view. rerender(); expect(container.textContent).toContain("line 350;"); }); it("starts from the top again after a round trip through split", () => { const { container, rerender } = render(); const view = screen.getByTestId("lane-diff-view"); view.scrollTop = 3000 * ROW_PX; fireEvent.scroll(view); rerender(); // In split the outer box is no scroller; the browser drops its offset. screen.getByTestId("lane-diff-view").scrollTop = 0; rerender(); expect(container.textContent).toContain("line 5;"); }); it("holds the horizontal extent at the longest line, rendered or not", () => { const { container } = render(); const view = screen.getByTestId("lane-diff-view"); view.scrollTop = 3000 * ROW_PX; fireEvent.scroll(view); const content = container.querySelector("[data-diff-content]")!; // the 300-w line 7 is off screen now, and still sets the width expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`); }); it("windows both split columns over the same row range", () => { render(); const left = screen.getByTestId("lane-diff-split-left"); const right = screen.getByTestId("lane-diff-split-right"); layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 }); layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 }); right.scrollTop = 2000 * ROW_PX; fireEvent.scroll(right); expect(left.scrollTop).toBe(2000 * ROW_PX); const l = rowsIn(left); const r = rowsIn(right); expect(l.length).toBeLessThan(300); expect(l).toHaveLength(r.length); expect(right.textContent).toContain("line 2001;"); // same rows: the right column's add has its filler row on the left expect(left.textContent).toContain("line 2000;"); }); it("windows a whole-file read too", () => { const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`); const { container } = render(); expect(rowsIn(container).length).toBeLessThan(300); const view = screen.getByTestId("lane-file-view"); view.scrollTop = 4000 * ROW_PX; fireEvent.scroll(view); expect(container.textContent).toContain("row 4000"); }); }); describe("LaneDiffView — hunk review bar navigation", () => { const hunk = (n: number) => ({ header: `@@ -${n},1 +${n},1 @@`, context: "", oldStart: n, newStart: n, lines: [ { type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` }, { type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` }, ], }); const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] }; // jsdom lays nothing out: give the ONE real scroller a scrollable box and // put hunk k's `@@` row k×100px below its top, wherever it has scrolled to. function renderInScroller() { const view = render(
} />
); const scroller = screen.getByTestId("scroller"); Object.defineProperty(scroller, "scrollHeight", { value: 1000 }); Object.defineProperty(scroller, "clientHeight", { value: 100 }); scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; for (const el of view.container.querySelectorAll("[data-change-index]")) { const k = Number(el.dataset.changeIndex); el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect; } return scroller; } it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => { const scroller = renderInScroller(); const next = screen.getAllByRole("button", { name: "Next change" }); const prev = screen.getAllByRole("button", { name: "Previous change" }); fireEvent.click(next[0]!); // bar 1 → hunk 2 expect(scroller.scrollTop).toBe(100); fireEvent.click(next[1]!); // bar 2 → hunk 3 expect(scroller.scrollTop).toBe(200); fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1 expect(scroller.scrollTop).toBe(100); }); it("counts the hunks and disables the arrows at either end", () => { renderInScroller(); const bars = screen.getAllByTestId("lane-hunk-bar"); expect(bars.map((b) => b.textContent)).toEqual([ expect.stringContaining("1 of 3"), expect.stringContaining("2 of 3"), expect.stringContaining("3 of 3"), ]); expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled(); expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled(); }); it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => { const merged: LaneChangeFile = { ...FILE, hunks: [ { header: "@@ -1,6 +1,6 @@", context: "", oldStart: 1, newStart: 1, lines: [ { type: "del" as const, oldLine: 1, newLine: null, text: "a" }, { type: "add" as const, oldLine: null, newLine: 1, text: "A" }, { type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" }, { type: "del" as const, oldLine: 3, newLine: null, text: "c" }, { type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" }, { type: "add" as const, oldLine: null, newLine: 4, text: "E" }, { type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" }, ], }, ], }; const seen: number[] = []; for (const layout of ["unified", "split"] as const) { const { unmount } = render( { seen.push(c.block); return ; }} /> ); expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([ expect.stringContaining("1 of 3"), expect.stringContaining("2 of 3"), expect.stringContaining("3 of 3"), ]); unmount(); } expect(seen).toEqual([0, 1, 2, 0, 1, 2]); }); it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => { render(); expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]"); }); }); describe("LaneDiffView — line comments", () => { it("writes a comment on a new line and hands over `: — `", () => { const sent: string[] = []; let delivered = false; render( { sent.push(text); return delivered; }} /> ); fireEvent.click(screen.getByTestId("diff-comment-line-1")); fireEvent.change(screen.getByRole("textbox"), { target: { value: "rename this" } }); fireEvent.submit(screen.getByTestId("diff-comment-box")); expect(sent).toEqual(["server/lib/git-ops.js:1 — rename this"]); // No pane took it: the box stays, with the reason. expect(screen.getByRole("alert")).toBeInTheDocument(); delivered = true; fireEvent.submit(screen.getByTestId("diff-comment-box")); expect(screen.queryByTestId("diff-comment-box")).toBeNull(); }); it("docks the box under the diff once its line scrolls out of a windowed diff", () => { render( true} />); const btn = screen.getByTestId("diff-comment-line-1"); fireEvent.click(btn); expect(screen.getByTestId("diff-comment-box").style.position).toBe("fixed"); // What windowing does to a row scrolled out of range. btn.remove(); fireEvent.scroll(screen.getByTestId("lane-diff-view")); expect(screen.getByTestId("diff-comment-box").style.position).toBe(""); }); it("offers no comment button without onComment", () => { render(); expect(screen.queryByTestId("diff-comment-line-1")).toBeNull(); }); });