import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "ink-testing-library";
import { Text, useInput } from "ink";
import {
useSelectableList,
type UseSelectableListOptions,
} from "../src/hooks/useSelectableList.js";
/** Let ink flush an async stdin keypress / re-render before asserting. */
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
/**
* Harness that surfaces the hook's state as rendered text and forwards
* keypresses to setSelection so the hook can be driven from a test.
* Pressing a digit (0-9) selects that index; "x" selects index 99.
*/
function Harness({
itemCount,
visibleCount,
options,
}: {
itemCount: number;
visibleCount: number;
options?: UseSelectableListOptions;
}) {
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
itemCount,
visibleCount,
options,
);
useInput((input) => {
if (input === "x") setSelection(99);
else if (/^[0-9]$/.test(input)) setSelection(Number(input));
});
return (
sel={selectedIndex} first={firstVisible}
);
}
describe("useSelectableList", () => {
it("starts at index 0 with firstVisible 0", () => {
const { lastFrame } = render();
expect(lastFrame()).toBe("sel=0 first=0");
});
it("keeps firstVisible when selecting within the visible window", async () => {
const { lastFrame, stdin } = render(
,
);
stdin.write("3");
await tick();
expect(lastFrame()).toBe("sel=3 first=0");
});
it("scrolls firstVisible forward when selection passes the window end", async () => {
const { lastFrame, stdin } = render(
,
);
stdin.write("6");
await tick();
// selected >= first + visibleCount => first = 6 - 5 + 1 = 2
expect(lastFrame()).toBe("sel=6 first=2");
});
it("scrolls firstVisible backward when selection moves before the window", async () => {
const { lastFrame, stdin } = render(
,
);
stdin.write("8"); // first becomes 4
await tick();
expect(lastFrame()).toBe("sel=8 first=4");
stdin.write("2"); // selected < first => first = selected = 2
await tick();
expect(lastFrame()).toBe("sel=2 first=2");
});
it("resets selection to 0 when resetWhen changes", async () => {
const { lastFrame, stdin, rerender } = render(
,
);
stdin.write("7");
await tick();
expect(lastFrame()).toBe("sel=7 first=3");
rerender(
,
);
await tick();
expect(lastFrame()).toBe("sel=0 first=0");
});
it("does not reset when resetWhen is undefined", async () => {
const { lastFrame, stdin, rerender } = render(
,
);
stdin.write("4");
await tick();
rerender();
await tick();
expect(lastFrame()).toBe("sel=4 first=0");
});
it("clamps selection when the list shrinks below the selected index", async () => {
const { lastFrame, stdin, rerender } = render(
,
);
stdin.write("8");
await tick();
expect(lastFrame()).toBe("sel=8 first=4");
rerender();
await tick();
// itemCount > 0 && selected >= itemCount => newIndex = 2, first clamps to 2
expect(lastFrame()).toBe("sel=2 first=2");
});
it("does not clamp when the list is empty", async () => {
const { lastFrame, stdin, rerender } = render(
,
);
stdin.write("8");
await tick();
rerender();
await tick();
// itemCount === 0 => no clamp, selection retained
expect(lastFrame()).toBe("sel=8 first=4");
});
});