test: add coverage for gitlooper ticket sweep
- useFocusTrap.test.ts: 7 unit tests covering rAF-deferred focus, Tab/Shift+Tab wrapping, empty-list guard, and cleanup (#56) - nextConfig.test.ts: 3 tests for /login and /blueprints redirects (#51 #52); 5 tests for COMPLETED demand exclusion filter logic (#66) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Capture effect callbacks so we can invoke them manually — same pattern as
|
||||
// useViewportPopover.test.ts / useTimelineSSE.test.ts.
|
||||
const effectCleanups: Array<() => void> = [];
|
||||
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual<typeof import("react")>("react");
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (callback: () => void | (() => void)) => {
|
||||
const cleanup = callback();
|
||||
if (typeof cleanup === "function") {
|
||||
effectCleanups.push(cleanup);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useFocusTrap } from "./useFocusTrap.js";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a minimal focusable HTMLElement stub. */
|
||||
function makeFocusable(focused = false): HTMLElement {
|
||||
return {
|
||||
focus: vi.fn(),
|
||||
tagName: "BUTTON",
|
||||
disabled: false,
|
||||
tabIndex: 0,
|
||||
matches: () => true,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
interface MockContainer {
|
||||
querySelectorAll: ReturnType<typeof vi.fn>;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
removeEventListener: ReturnType<typeof vi.fn>;
|
||||
_emit(type: string, event: Partial<KeyboardEvent>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal container element whose querySelectorAll returns the given
|
||||
* list of focusable children, and whose addEventListener / removeEventListener
|
||||
* can be spied on.
|
||||
*/
|
||||
function makeContainer(focusable: HTMLElement[]): MockContainer {
|
||||
const listeners = new Map<string, EventListener>();
|
||||
return {
|
||||
querySelectorAll: vi.fn((_selector: string) => focusable),
|
||||
addEventListener: vi.fn((type: string, handler: EventListener) => {
|
||||
listeners.set(type, handler);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string) => {
|
||||
listeners.delete(type);
|
||||
}),
|
||||
_emit(type: string, event: Partial<KeyboardEvent>) {
|
||||
listeners.get(type)?.(event as Event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useFocusTrap", () => {
|
||||
let rafId = 0;
|
||||
let rafCallbacks: Array<() => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
effectCleanups.length = 0;
|
||||
rafCallbacks = [];
|
||||
rafId = 0;
|
||||
|
||||
// Stub rAF: capture callbacks without executing them immediately so we
|
||||
// control when initial focus fires.
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
|
||||
rafCallbacks.push(cb);
|
||||
return ++rafId;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Run all pending cleanups to avoid leaking listeners between tests.
|
||||
while (effectCleanups.length > 0) {
|
||||
effectCleanups.pop()?.();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── isOpen = false → no rAF, no listener ──────────────────────────────────
|
||||
|
||||
it("does nothing when isOpen is false", () => {
|
||||
const first = makeFocusable();
|
||||
const container = makeContainer([first]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, false);
|
||||
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
expect(container.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── isOpen = true → deferred focus on first element ──────────────────────
|
||||
|
||||
it("focuses the first focusable element via rAF when isOpen is true", () => {
|
||||
const first = makeFocusable();
|
||||
const second = makeFocusable();
|
||||
const container = makeContainer([first, second]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, true);
|
||||
|
||||
// rAF should have been queued but not yet fired
|
||||
expect(rafCallbacks).toHaveLength(1);
|
||||
expect(first.focus).not.toHaveBeenCalled();
|
||||
|
||||
// Fire the rAF callback to simulate browser layout completion
|
||||
const rafCb = rafCallbacks[0];
|
||||
if (!rafCb) throw new Error("expected rAF callback to be queued");
|
||||
rafCb();
|
||||
expect(first.focus).toHaveBeenCalledTimes(1);
|
||||
expect(second.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Tab key wraps forward (last → first) ──────────────────────────────────
|
||||
|
||||
it("wraps focus from last to first on Tab", () => {
|
||||
const first = makeFocusable();
|
||||
const last = makeFocusable();
|
||||
const container = makeContainer([first, last]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, true);
|
||||
|
||||
// Simulate Tab with document.activeElement on the last element
|
||||
vi.stubGlobal("document", { activeElement: last });
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
container._emit("keydown", { key: "Tab", shiftKey: false, preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(first.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Shift+Tab key wraps backward (first → last) ──────────────────────────
|
||||
|
||||
it("wraps focus from first to last on Shift+Tab", () => {
|
||||
const first = makeFocusable();
|
||||
const last = makeFocusable();
|
||||
const container = makeContainer([first, last]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, true);
|
||||
|
||||
// Simulate Shift+Tab with document.activeElement on the first element
|
||||
vi.stubGlobal("document", { activeElement: first });
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
container._emit("keydown", { key: "Tab", shiftKey: true, preventDefault });
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(last.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Non-Tab key → ignored ─────────────────────────────────────────────────
|
||||
|
||||
it("ignores non-Tab keys", () => {
|
||||
const first = makeFocusable();
|
||||
const container = makeContainer([first]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, true);
|
||||
|
||||
vi.stubGlobal("document", { activeElement: first });
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
container._emit("keydown", { key: "Escape", shiftKey: false, preventDefault });
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Empty focusable list → preventDefault, no focus attempt ──────────────
|
||||
|
||||
it("calls preventDefault and does not throw when there are no focusable elements", () => {
|
||||
const container = makeContainer([]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, true);
|
||||
|
||||
vi.stubGlobal("document", { activeElement: null });
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
expect(() => {
|
||||
container._emit("keydown", { key: "Tab", shiftKey: false, preventDefault });
|
||||
}).not.toThrow();
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Cleanup cancels rAF and removes listener ──────────────────────────────
|
||||
|
||||
it("cancels pending rAF and removes keydown listener on cleanup", () => {
|
||||
const first = makeFocusable();
|
||||
const container = makeContainer([first]);
|
||||
const ref = { current: container as unknown as HTMLElement };
|
||||
|
||||
useFocusTrap(ref, true);
|
||||
|
||||
expect(container.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
|
||||
// Run cleanup (simulates component unmount or isOpen → false)
|
||||
effectCleanups.pop()?.();
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(rafId);
|
||||
expect(container.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user