Files
PodTui/tests/pane-resize.test.tsx
Michael Freno 132d2079f7 fix(pane): 3-column grab zones with cursor offset
Splitter strips widen to border +/- 1 help-padded column so the thin
border is easy to target; mousedown records the cursor's offset from
the border column and drag subtracts it, so the border tracks the
cursor instead of jumping. Padded columns must never overlap
interactive content (rect-based hit grid). Dead one-off render
harness scripts/_hv.ts removed. Tests cover far/inner-edge grabs
and no-resize on padded-column clicks.
2026-09-06 18:46:42 -04:00

210 lines
7.4 KiB
TypeScript

/**
* pane-resize.test.tsx — dragging the center column's borders actually
* resizes the panes in a rendered PaneRow.
*
* Each pane renders a long run of a unique character (P / C / V). A line
* where all three meet encodes the boundary columns directly: the current
* pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its
* content starts one column in — `leftPx + 1`. Hence
* `leftPx = firstC - 1`, `rightPx = firstV`.
*
* The grab zones overlay each border: 3 columns wide, the border plus one
* help-padded column each side (left zone at [left-1, left+1], right zone
* at [right-2, right)). The test presses inside a zone and drags across
* the row — the drag bubbles to the row container which moves the split,
* so the panes must re-render at the new columns.
*/
import { test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { usePaneLayout } from "../src/stores/pane-layout";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-"));
process.env.XDG_CONFIG_HOME = configHome;
type Span = { text: string };
type Frame = { cols: number; lines: { spans: Span[] }[] };
interface BoundCols {
left: number;
right: number;
}
function readBounds(frame: Frame): BoundCols {
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
if (!line) throw new Error("pane row did not render");
return { left: line.indexOf("C") - 1, right: line.indexOf("V") };
}
async function renderRow(panes: 2 | 3 = 3) {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
panes={panes}
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
mockMouse: {
drag: (a: number, b: number, c: number, d: number) => Promise<void>;
click: (a: number, b: number) => Promise<void>;
};
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
return setup;
}
/** Reset the shared store to the default split for a deterministic start. */
function resetSplits() {
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
}
const cleanups: (() => void)[] = [];
afterAll(() => {
for (const c of cleanups) c();
// Restore the default split so a later file sharing this process (bun
// test reuses the module registry) renders the default layout.
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
rmSync(configHome, { recursive: true, force: true });
});
test("panes render at the default 20/70 split", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("dragging the left border resizes parent vs current", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the left zone (border at 20 → zone covers 19-21) and drag
// toward the middle of the row.
await setup.mockMouse.drag(20, 5, 45, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBeGreaterThanOrEqual(44);
expect(after.left).toBeLessThanOrEqual(46);
// Pushing the left border to 45 would shrink the current pane below its
// 30-col minimum (45..70 = 25), so the right border is forced right to
// 75, keeping the current pane at exactly 30 and absorbing the overflow
// in the preview.
expect(after.right).toBe(75);
});
test("dragging the right border resizes current vs preview", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the right zone (border at 69 → zone covers 68-70) and drag
// toward the right edge of the row.
await setup.mockMouse.drag(69, 5, 90, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.right).toBeGreaterThanOrEqual(84);
expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15
expect(after.left).toBe(20);
});
test("grabbing the left zone from its far edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column LEFT of the border (x=19, border at 20 → offset -1)
// and drag to 37. The border must track the grab, landing at 38 (37 + 1),
// not at 37. Without the grab offset it would jump one column.
await setup.mockMouse.drag(19, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(38);
expect(after.right).toBe(70);
});
test("grabbing the left zone from its inner edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column RIGHT of the border (x=21, border at 20 → offset +1)
// and drag to 37. The border lands at 36 (37 - 1), not 37.
await setup.mockMouse.drag(21, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(36);
expect(after.right).toBe(70);
});
test("a click inside a padded grab zone (off the border) does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// A bare click (no drag) on the help-padded column beside the border
// must not move the split — only an actual drag does.
await setup.mockMouse.click(19, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("a plain click away from the borders does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
await setup.mockMouse.click(5, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("2-pane rows offer no right border (current fills the row)", async () => {
const setup = await renderRow(2);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
for (let i = 0; i < 10; i++) await setup.renderOnce();
// No 'V' pane: the line runs to the screen edge.
const frame = setup.captureSpans();
const { left } = readBounds(frame);
expect(left).toBe(20);
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
expect(line?.includes("V")).toBe(false);
});