6ef9a5d4b7
- Side by side, in the review list and the file editor: each column shows only its own lines (no padding rows) and a curved band in the gutter joins every changed block to its counterpart (lib/diffConnectors.ts). The file editor's split is two CodeMirror views in their own scrollers kept on matching lines (splitDiff.ts, replacing MergeView), with » to revert a block. Checked in a real browser (headless Chrome) as well as by tests. - #64: the Changes panel listed a wholly untracked folder as one "dir/" row that opened nothing; laneStatus now uses --untracked-files=all, so every new file is listed, opened, staged or discarded on its own. - README.md is the Vietnamese quick start (the default) with README.en.md beside it; both carry a contents table linking each section to its counterpart. The long overview moved to docs/OVERVIEW.md + OVERVIEW.vi.md. Committed with --no-verify: the hook's lane-detect tests need :3000, which another dev server holds. Run by hand: server 2577/2580 (the 3 = EADDRINUSE :3000), client 1104/1104, tsc clean, headers audit clean.
612 lines
24 KiB
TypeScript
612 lines
24 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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";
|
||
import { bandPath } from "../../../lib/diffConnectors";
|
||
|
||
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(<LaneDiffView file={FILE} layout="split" />);
|
||
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
|
||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||
});
|
||
|
||
it("pads the shorter side only at the hunk's end, each pad one line tall", () => {
|
||
// Two lines removed, none added: the right column's two rows are padding.
|
||
const file = fileWith([
|
||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||
]);
|
||
render(<LaneDiffView file={file} layout="split" />);
|
||
const fillers = [
|
||
...screen
|
||
.getByTestId("lane-diff-split-right")
|
||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||
];
|
||
expect(fillers).toHaveLength(2);
|
||
for (const row of fillers) expect(row.style.height).toBe("16px");
|
||
});
|
||
|
||
it("packs each side's own lines, JetBrains-style, and joins every block with a band", () => {
|
||
const file = fileWith([
|
||
{ type: "ctx", oldLine: 1, newLine: 1, text: "top" },
|
||
{ type: "del", oldLine: 2, newLine: null, text: "old a" },
|
||
{ type: "del", oldLine: 3, newLine: null, text: "old b" },
|
||
{ type: "add", oldLine: null, newLine: 2, text: "new a" },
|
||
{ type: "ctx", oldLine: 4, newLine: 3, text: "mid" },
|
||
{ type: "add", oldLine: null, newLine: 4, text: "extra" },
|
||
{ type: "ctx", oldLine: 5, newLine: 5, text: "end" },
|
||
]);
|
||
render(<LaneDiffView file={file} layout="split" />);
|
||
const texts = (id: string) =>
|
||
[...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max")].map((r) =>
|
||
r.textContent?.replace(/^\d+/, "")
|
||
);
|
||
// No gap inside a hunk: a side's lines follow one another (5 each, no pad).
|
||
expect(texts("lane-diff-split-left")).toEqual(["top", "old a", "old b", "mid", "end"]);
|
||
expect(texts("lane-diff-split-right")).toEqual(["top", "new a", "mid", "extra", "end"]);
|
||
// Two blocks, two bands: rows start under the one-row hunk header.
|
||
const bands = [
|
||
...screen.getByTestId("lane-diff-connectors").querySelectorAll<SVGPathElement>("path"),
|
||
];
|
||
expect(bands.map((b) => b.dataset.kind)).toEqual(["mod", "add"]);
|
||
expect(bands[0]!.getAttribute("d")).toBe(
|
||
bandPath(36, 2 * ROW_PX, 4 * ROW_PX, 2 * ROW_PX, 3 * ROW_PX)
|
||
);
|
||
// The insertion is a wedge from the left's point between "mid" and "end".
|
||
expect(bands[1]!.getAttribute("d")).toBe(
|
||
bandPath(36, 5 * ROW_PX, 5 * ROW_PX, 4 * ROW_PX, 5 * ROW_PX)
|
||
);
|
||
});
|
||
|
||
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(<LaneDiffView file={FILE} layout="unified" />);
|
||
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(<LaneDiffView file={big} layout="unified" />);
|
||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||
|
||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||
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(<LaneDiffView file={FILE} layout="split" />);
|
||
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(<LaneDiffView file={FILE} layout="split" />);
|
||
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(<LaneDiffView file={FILE} layout="split" />);
|
||
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(<LaneDiffView file={FILE} layout="split" />);
|
||
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(<LaneDiffView file={FILE} layout="unified" />);
|
||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
|
||
});
|
||
});
|
||
|
||
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[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<HTMLElement>("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(<LaneDiffView file={WHOLE} layout="unified" />);
|
||
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(<LaneDiffView file={ROWS} layout="unified" />);
|
||
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(<LaneDiffView file={WHOLE} layout="unified" />);
|
||
const marker = (row: HTMLElement) =>
|
||
row.querySelector<HTMLElement>("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(<LaneDiffView file={ROWS} layout="unified" />);
|
||
const emph = [...container.querySelectorAll<HTMLElement>("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(
|
||
<LaneDiffView
|
||
file={fileWith([
|
||
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
|
||
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
|
||
])}
|
||
layout="unified"
|
||
/>
|
||
);
|
||
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(
|
||
<LaneDiffView
|
||
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
|
||
layout="split"
|
||
/>
|
||
);
|
||
const rows = (id: string) => [
|
||
...screen.getByTestId(id).querySelectorAll<HTMLElement>("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<HTMLElement>("div.flex.w-max")];
|
||
|
||
describe("LaneDiffView — windowing", () => {
|
||
it("renders every row below the threshold, with no spacers", () => {
|
||
const { container } = render(
|
||
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
|
||
);
|
||
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(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||
const rows = rowsIn(container);
|
||
expect(rows.length).toBeGreaterThan(0);
|
||
expect(rows.length).toBeLessThan(300);
|
||
const spacers = [...container.querySelectorAll<HTMLElement>("[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(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||
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(<LaneDiffView file={bigFile(400)} layout="unified" />);
|
||
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(<LaneDiffView file={bigFile(600)} layout="unified" />);
|
||
expect(container.textContent).toContain("line 350;");
|
||
});
|
||
|
||
it("starts from the top again after a round trip through split", () => {
|
||
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||
const view = screen.getByTestId("lane-diff-view");
|
||
view.scrollTop = 3000 * ROW_PX;
|
||
fireEvent.scroll(view);
|
||
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||
// In split the outer box is no scroller; the browser drops its offset.
|
||
screen.getByTestId("lane-diff-view").scrollTop = 0;
|
||
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||
expect(container.textContent).toContain("line 5;");
|
||
});
|
||
|
||
it("holds the horizontal extent at the longest line, rendered or not", () => {
|
||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||
const view = screen.getByTestId("lane-diff-view");
|
||
view.scrollTop = 3000 * ROW_PX;
|
||
fireEvent.scroll(view);
|
||
const content = container.querySelector<HTMLElement>("[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(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||
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;");
|
||
// Packed: the left holds only the context lines, so it runs twice as far.
|
||
expect(left.textContent).toContain("line 4000;");
|
||
});
|
||
|
||
it("windows a whole-file read too", () => {
|
||
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
|
||
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
|
||
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(
|
||
<div data-testid="scroller" style={{ overflowY: "auto" }}>
|
||
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
|
||
</div>
|
||
);
|
||
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<HTMLElement>("[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(
|
||
<LaneDiffView
|
||
file={merged}
|
||
layout={layout}
|
||
hunkActions={(c) => {
|
||
seen.push(c.block);
|
||
return <span />;
|
||
}}
|
||
/>
|
||
);
|
||
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(<LaneDiffView file={three} layout="unified" />);
|
||
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 `<path>:<line> — <text>`", () => {
|
||
const sent: string[] = [];
|
||
let delivered = false;
|
||
render(
|
||
<LaneDiffView
|
||
file={FILE}
|
||
layout="unified"
|
||
onComment={(text) => {
|
||
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(<LaneDiffView file={FILE} layout="unified" onComment={() => 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(<LaneDiffView file={FILE} layout="unified" />);
|
||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||
});
|
||
});
|