// @vitest-environment happy-dom import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; vi.mock("./api.js", () => ({ listDir: vi.fn() })); import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js"; import { listDir } from "./api.js"; const listDirMock = vi.mocked(listDir); let input: HTMLInputElement; let nextField: HTMLInputElement; let docKeydown: ReturnType; let ac: DirAutocompleteHandle | undefined; function popup(): HTMLUListElement { return document.querySelector(".dir-ac") as HTMLUListElement; } function items(): HTMLElement[] { return [...popup().querySelectorAll(".dir-ac-item")]; } async function type(value: string): Promise { input.focus(); input.value = value; input.dispatchEvent(new Event("input", { bubbles: true })); await vi.advanceTimersByTimeAsync(100); // past the fetch debounce } /** Dispatch a cancelable, bubbling keydown on the input; report whether the * default browser behaviour (focus move, form submit) was suppressed. */ function press(key: string): { defaultPrevented: boolean } { const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); input.dispatchEvent(evt); return { defaultPrevented: evt.defaultPrevented }; } beforeEach(() => { vi.useFakeTimers(); document.body.replaceChildren(); input = document.createElement("input"); nextField = document.createElement("input"); document.body.append(input, nextField); docKeydown = vi.fn(); document.addEventListener("keydown", docKeydown); listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false }); }); afterEach(() => { ac?.destroy(); document.removeEventListener("keydown", docKeydown); vi.useRealTimers(); }); describe("dirAutocomplete dismissal", () => { it("Escape closes the popup, keeps the typed value and does not propagate", async () => { ac = attachDirAutocomplete(input); await type("/tmp/pro"); expect(popup().hidden).toBe(false); const { defaultPrevented } = press("Escape"); expect(defaultPrevented).toBe(true); expect(popup().hidden).toBe(true); expect(input.value).toBe("/tmp/pro"); expect(document.activeElement).toBe(input); expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire }); it("blur dismisses the popup", async () => { ac = attachDirAutocomplete(input); await type("/tmp/pro"); expect(popup().hidden).toBe(false); input.blur(); await vi.advanceTimersByTimeAsync(150); expect(popup().hidden).toBe(true); }); it("does not reopen on refocus until the operator types again", async () => { ac = attachDirAutocomplete(input); await type("/tmp/pro"); input.blur(); await vi.advanceTimersByTimeAsync(150); input.focus(); await vi.advanceTimersByTimeAsync(150); expect(popup().hidden).toBe(true); await type("/tmp/ot"); expect(popup().hidden).toBe(false); }); }); describe("dirAutocomplete keyboard model", () => { it("Tab commits the highlighted entry and lets focus move to the next field", async () => { ac = attachDirAutocomplete(input); await type("/tmp/pro"); // highlight defaults to the first match: proj-a nextField.focus(); const { defaultPrevented } = press("Tab"); expect(input.value).toBe("/tmp/proj-a/"); expect(defaultPrevented).toBe(false); // native tab navigation proceeds expect(document.activeElement).toBe(nextField); // focus was not yanked back }); it("Tab with nothing highlighted just moves on", async () => { ac = attachDirAutocomplete(input); await type("/tmp/nope-matches-nothing"); expect(popup().hidden).toBe(true); const { defaultPrevented } = press("Tab"); expect(defaultPrevented).toBe(false); expect(input.value).toBe("/tmp/nope-matches-nothing"); }); it("arrows move the highlight and Enter commits it without submitting", async () => { ac = attachDirAutocomplete(input); await type("/tmp/"); press("ArrowDown"); expect(items()[1].classList.contains("is-active")).toBe(true); const enter = press("Enter"); expect(enter.defaultPrevented).toBe(true); // no form submit expect(input.value).toBe("/tmp/proj-b/"); }); }); describe("dirAutocomplete opening", () => { it("still opens on focus when not previously dismissed", async () => { ac = attachDirAutocomplete(input); input.focus(); await vi.advanceTimersByTimeAsync(50); expect(popup().hidden).toBe(false); expect(listDirMock).toHaveBeenCalled(); }); });