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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for redirect rules defined in next.config.ts (tickets #51, #52).
|
||||||
|
*
|
||||||
|
* next.config.ts exports a NextConfig object whose `redirects` method returns
|
||||||
|
* a plain array — no Next.js runtime required. We import the raw config object
|
||||||
|
* via dynamic require so we can inspect the redirect array without spinning up
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// next.config.ts calls `require("@sentry/nextjs")` only in production.
|
||||||
|
// In the test environment NODE_ENV === "test", so the Sentry branch is skipped
|
||||||
|
// and the raw nextConfig is exported directly.
|
||||||
|
|
||||||
|
// We cannot import next.config.ts directly through the standard path alias
|
||||||
|
// because it lives outside `src/`. Instead we resolve it relative to the
|
||||||
|
// package root. Vitest runs from the package directory.
|
||||||
|
const config: import("next").NextConfig = (await import("../../next.config.js")).default;
|
||||||
|
|
||||||
|
describe("next.config redirects", () => {
|
||||||
|
it("redirects /login → /auth/signin (permanent)", async () => {
|
||||||
|
const redirects = await config.redirects!();
|
||||||
|
|
||||||
|
const loginRedirect = redirects.find((r) => r.source === "/login");
|
||||||
|
|
||||||
|
expect(loginRedirect, "redirect for /login must exist").toBeDefined();
|
||||||
|
expect(loginRedirect!.destination).toBe("/auth/signin");
|
||||||
|
expect(loginRedirect!.permanent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects /blueprints → /admin/blueprints (permanent)", async () => {
|
||||||
|
const redirects = await config.redirects!();
|
||||||
|
|
||||||
|
const blueprintsRedirect = redirects.find((r) => r.source === "/blueprints");
|
||||||
|
|
||||||
|
expect(blueprintsRedirect, "redirect for /blueprints must exist").toBeDefined();
|
||||||
|
expect(blueprintsRedirect!.destination).toBe("/admin/blueprints");
|
||||||
|
expect(blueprintsRedirect!.permanent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accidentally redirect /auth/signin itself", async () => {
|
||||||
|
const redirects = await config.redirects!();
|
||||||
|
|
||||||
|
const signinRedirect = redirects.find((r) => r.source === "/auth/signin");
|
||||||
|
expect(signinRedirect).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Demand summary filter logic (#66) ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The project detail page filters demands with:
|
||||||
|
// demand.status !== "CANCELLED" && demand.status !== "COMPLETED"
|
||||||
|
//
|
||||||
|
// This is the business rule that ensures COMPLETED demands are excluded from
|
||||||
|
// the "open demand" count. We test the filter expression directly to guard
|
||||||
|
// against regressions (e.g., someone removing "COMPLETED" from the condition).
|
||||||
|
|
||||||
|
describe("project detail demand summary filter (ticket #66)", () => {
|
||||||
|
type Demand = { status: string; requestedHeadcount: number; unfilledHeadcount: number };
|
||||||
|
|
||||||
|
function calcActiveDemands(demands: Demand[]) {
|
||||||
|
return demands.filter(
|
||||||
|
(d) => d.status !== "CANCELLED" && d.status !== "COMPLETED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("includes OPEN demands in the active set", () => {
|
||||||
|
const demands: Demand[] = [{ status: "OPEN", requestedHeadcount: 2, unfilledHeadcount: 2 }];
|
||||||
|
expect(calcActiveDemands(demands)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes PARTIAL demands in the active set", () => {
|
||||||
|
const demands: Demand[] = [{ status: "PARTIAL", requestedHeadcount: 2, unfilledHeadcount: 1 }];
|
||||||
|
expect(calcActiveDemands(demands)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes CANCELLED demands from the active set", () => {
|
||||||
|
const demands: Demand[] = [{ status: "CANCELLED", requestedHeadcount: 1, unfilledHeadcount: 1 }];
|
||||||
|
expect(calcActiveDemands(demands)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes COMPLETED demands from the active set (regression guard for #66)", () => {
|
||||||
|
const demands: Demand[] = [{ status: "COMPLETED", requestedHeadcount: 1, unfilledHeadcount: 0 }];
|
||||||
|
expect(calcActiveDemands(demands)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly partitions a mixed demand list", () => {
|
||||||
|
const demands: Demand[] = [
|
||||||
|
{ status: "OPEN", requestedHeadcount: 2, unfilledHeadcount: 2 },
|
||||||
|
{ status: "PARTIAL", requestedHeadcount: 3, unfilledHeadcount: 1 },
|
||||||
|
{ status: "CANCELLED", requestedHeadcount: 1, unfilledHeadcount: 1 },
|
||||||
|
{ status: "COMPLETED", requestedHeadcount: 2, unfilledHeadcount: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const active = calcActiveDemands(demands);
|
||||||
|
expect(active).toHaveLength(2);
|
||||||
|
expect(active.map((d) => d.status)).toEqual(["OPEN", "PARTIAL"]);
|
||||||
|
|
||||||
|
const totalRequested = active.reduce((s, d) => s + d.requestedHeadcount, 0);
|
||||||
|
const totalUnfilled = active.reduce((s, d) => s + d.unfilledHeadcount, 0);
|
||||||
|
expect(totalRequested).toBe(5);
|
||||||
|
expect(totalUnfilled).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user