Ports the vanilla spawn form/modal to src/components/spawn/: SpawnModal, SpawnForm, DirAutocomplete, BranchPicker, and a useFastForward hook for the one-keystroke spawn. Pure logic modules (spawnForm.ts's buildSpawnRequest, fastForward.ts, spawnSimilar.ts, projectList.ts, toolIcon.ts) are reused as-is rather than re-derived. Exports supportsStructuredView from spawnForm.ts so the React form can share the structured-view-eligibility check with the vanilla one.
110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { useState } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { DirAutocomplete } from "./DirAutocomplete.js";
|
|
|
|
vi.mock("../../api.js", () => ({
|
|
listDir: vi.fn(),
|
|
}));
|
|
|
|
import { listDir } from "../../api.js";
|
|
|
|
const mockListDir = vi.mocked(listDir);
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function Harness({ initial = "" }: { initial?: string }) {
|
|
const [value, setValue] = useState(initial);
|
|
return <DirAutocomplete value={value} onChange={setValue} placeholder="cwd" />;
|
|
}
|
|
|
|
// `fireEvent.focus`/`.blur` dispatch the event without moving the browser's
|
|
// real focus, so `document.activeElement` (which the dropdown's fetch guards
|
|
// against) never changes — calling the DOM method directly does both.
|
|
function focus(el: HTMLElement): void {
|
|
act(() => el.focus());
|
|
}
|
|
function blur(el: HTMLElement): void {
|
|
act(() => el.blur());
|
|
}
|
|
|
|
describe("DirAutocomplete", () => {
|
|
it("lists entries matching the trailing segment on focus", async () => {
|
|
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
|
|
render(<Harness initial="/home/n/al" />);
|
|
|
|
focus(screen.getByPlaceholderText("cwd"));
|
|
|
|
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
|
|
expect(screen.queryByRole("option", { name: "beta/" })).toBeNull();
|
|
});
|
|
|
|
it("Tab commits the highlighted entry and does not close the field", async () => {
|
|
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
|
render(<Harness initial="/home/n/a" />);
|
|
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
|
|
focus(input);
|
|
await screen.findByRole("option", { name: "alpha/" });
|
|
|
|
fireEvent.keyDown(input, { key: "Tab" });
|
|
|
|
expect(input.value).toBe("/home/n/alpha/");
|
|
});
|
|
|
|
it("arrows move the highlight and Enter accepts it without submitting", async () => {
|
|
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
|
|
render(<Harness initial="/home/n/" />);
|
|
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
|
|
focus(input);
|
|
await screen.findByRole("option", { name: "alpha/" });
|
|
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
const notPrevented = fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(input.value).toBe("/home/n/beta/");
|
|
expect(notPrevented).toBe(false); // preventDefault called -> event "processed"
|
|
});
|
|
|
|
it("Escape closes the dropdown and stops the key from bubbling", async () => {
|
|
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
|
const onKeyDown = vi.fn();
|
|
render(
|
|
<div onKeyDown={onKeyDown}>
|
|
<Harness initial="/home/n/a" />
|
|
</div>,
|
|
);
|
|
const input = screen.getByPlaceholderText("cwd");
|
|
focus(input);
|
|
await screen.findByRole("option", { name: "alpha/" });
|
|
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
|
|
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
|
|
expect(onKeyDown).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not reopen on refocus until the operator types again", async () => {
|
|
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
|
render(<Harness initial="/home/n/a" />);
|
|
const input = screen.getByPlaceholderText("cwd");
|
|
focus(input);
|
|
await screen.findByRole("option", { name: "alpha/" });
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
|
|
|
|
blur(input);
|
|
focus(input);
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(screen.queryByRole("option")).toBeNull();
|
|
|
|
fireEvent.change(input, { target: { value: "/home/n/al" } });
|
|
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
|
|
});
|
|
});
|