Files
kandev/apps/web/components/task-create-dialog-pill.test.tsx
T
2026-09-11 15:18:10 +00:00

461 lines
15 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactNode } from "react";
import type { Branch } from "@/lib/types/http";
const TOOLTIP_ROOT_TEST_ID = "tooltip-root";
vi.mock("@kandev/ui/tooltip", async () => {
const React = await import("react");
const TooltipContext = React.createContext<((open: boolean) => void) | undefined>(undefined);
return {
Tooltip: ({
open,
onOpenChange,
children,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) => {
return (
<TooltipContext.Provider value={onOpenChange}>
<div data-testid={TOOLTIP_ROOT_TEST_ID} data-open={String(open)}>
{children}
</div>
</TooltipContext.Provider>
);
},
TooltipTrigger: ({
children,
onPointerEnter: triggerPointerEnter,
onPointerLeave: triggerPointerLeave,
onFocus: triggerFocus,
onBlur: triggerBlur,
...triggerProps
}: {
children: ReactNode;
onPointerEnter?: React.PointerEventHandler;
onPointerLeave?: React.PointerEventHandler;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;
[key: string]: unknown;
}) => {
const onOpenChange = React.useContext(TooltipContext);
const child = React.Children.only(children) as React.ReactElement<{
onPointerEnter?: React.PointerEventHandler;
onPointerLeave?: React.PointerEventHandler;
onFocus?: React.FocusEventHandler;
onBlur?: React.FocusEventHandler;
}>;
return React.cloneElement(child, {
...triggerProps,
onPointerEnter: (event) => {
triggerPointerEnter?.(event);
child.props.onPointerEnter?.(event);
onOpenChange?.(true);
},
onPointerLeave: (event) => {
triggerPointerLeave?.(event);
child.props.onPointerLeave?.(event);
onOpenChange?.(false);
},
onFocus: (event) => {
triggerFocus?.(event);
child.props.onFocus?.(event);
onOpenChange?.(true);
},
onBlur: (event) => {
triggerBlur?.(event);
child.props.onBlur?.(event);
onOpenChange?.(false);
},
});
},
TooltipContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="tooltip-content" className={className}>
{children}
</div>
),
};
});
import { Pill, type PillOption } from "./task-create-dialog-pill";
import {
branchOptionValue,
branchToOption,
computeBranchPlaceholder,
sortBranches,
} from "./branch-picker-options";
const CREATE_REPOSITORY = "Create new repository";
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function localBranch(name: string): Branch {
return { name, type: "local" } as Branch;
}
function remoteBranch(name: string, remote = "origin"): Branch {
return { name, type: "remote", remote } as Branch;
}
const TOOLTIP_PILL_VALUE = "kandev";
const REPOSITORY_TOOLTIP = "Repository · ~/kandev";
function renderTooltipPill({
value = TOOLTIP_PILL_VALUE,
options = [],
tooltip = REPOSITORY_TOOLTIP,
}: {
value?: string;
options?: PillOption[];
tooltip?: string;
} = {}) {
render(
<Pill
icon={<span aria-hidden="true" />}
value={value}
placeholder="repository"
options={options}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
tooltip={tooltip}
/>,
);
}
describe("sortBranches", () => {
it("lifts main before master before develop before other branches", () => {
const sorted = sortBranches([
localBranch("feature/a"),
localBranch("develop"),
localBranch("master"),
localBranch("main"),
]);
expect(sorted.map((b) => b.name)).toEqual(["main", "master", "develop", "feature/a"]);
});
it("puts local before remote when both have the same preferred name", () => {
const sorted = sortBranches([remoteBranch("main"), localBranch("main")]);
expect(sorted.map((b) => b.type)).toEqual(["local", "remote"]);
});
it("leaves non-preferred branches in their original relative order", () => {
const sorted = sortBranches([
localBranch("feature/zeta"),
localBranch("feature/alpha"),
localBranch("feature/middle"),
]);
expect(sorted.map((b) => b.name)).toEqual(["feature/zeta", "feature/alpha", "feature/middle"]);
});
it("does not mutate the input array", () => {
const input = [localBranch("feature/a"), localBranch("main")];
const snapshot = input.map((b) => b.name);
sortBranches(input);
expect(input.map((b) => b.name)).toEqual(snapshot);
});
});
describe("grouped pill options", () => {
it("keeps branch policies before branches even when a branch is selected", async () => {
render(
<Pill
icon={<span aria-hidden="true" />}
value="main"
placeholder="branch"
options={[
{ value: "main", label: "main", group: "branches", groupLabel: "Branches" },
{
value: "policy:feature",
label: "Feature policy",
group: "policies",
groupLabel: "Branch policies",
},
]}
onSelect={vi.fn()}
searchPlaceholder="Search branches..."
emptyMessage="No branches"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /main/i }));
const policies = await screen.findByText("Branch policies");
const branches = await screen.findByText("Branches");
expect(
Boolean(policies.compareDocumentPosition(branches) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
});
});
describe("branchToOption keywords", () => {
it("uses the qualified value for remote branches", () => {
expect(branchOptionValue(remoteBranch("main"))).toBe("origin/main");
expect(branchToOption(remoteBranch("main")).value).toBe("origin/main");
expect(branchOptionValue(localBranch("main"))).toBe("main");
});
it("tags branch options for the grouped selector", () => {
expect(branchToOption(localBranch("main"))).toMatchObject({
group: "branches",
groupLabel: expect.any(String),
});
});
function keywords(b: Branch): string[] {
return branchToOption(b).keywords ?? [];
}
it("includes the leaf segment for slash-prefixed branches", () => {
expect(keywords(localBranch("feat/scope/thing"))).toContain("thing");
});
it("splits on slashes, dots, underscores, and hyphens", () => {
const kw = keywords(localBranch("feat/scope.thing_with-dash"));
expect(kw).toEqual(expect.arrayContaining(["feat", "scope", "thing", "with", "dash"]));
});
it("includes the remote name when present", () => {
expect(keywords(remoteBranch("main", "upstream"))).toContain("upstream");
});
it("dedupes repeated segments", () => {
const kw = keywords(localBranch("foo/foo"));
expect(kw.filter((k) => k === "foo")).toHaveLength(1);
});
});
describe("computeBranchPlaceholder", () => {
it("returns 'branch' when no repo is selected", () => {
expect(computeBranchPlaceholder(false, false, 0)).toBe("branch");
});
it("returns 'loading…' while branches are loading", () => {
expect(computeBranchPlaceholder(true, true, 0)).toBe("loading…");
});
it("returns 'no branches' when loaded but the list is empty", () => {
expect(computeBranchPlaceholder(true, false, 0)).toBe("no branches");
});
it("returns 'branch' as the default with options available", () => {
expect(computeBranchPlaceholder(true, false, 3)).toBe("branch");
});
});
describe("Pill tooltip", () => {
it("ignores tooltip open requests during the initial mount frame", async () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 1),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill();
await waitFor(() => {
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("false");
});
});
it("makes the disabled tooltip wrapper keyboard focusable", () => {
render(
<Pill
icon={<span aria-hidden="true" />}
value=""
placeholder="branch"
options={[]}
onSelect={vi.fn()}
searchPlaceholder="Search branches..."
emptyMessage="No branches"
disabled
disabledReason="Select a repository first"
/>,
);
const tooltipTrigger = screen.getByLabelText("Select a repository first");
expect(tooltipTrigger.getAttribute("tabindex")).toBe("0");
expect(tooltipTrigger.querySelector("[aria-hidden='true'] button")).not.toBeNull();
});
it("allows the first later mouse hover when the pointer left for the picker", async () => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
fireEvent.click(trigger);
fireEvent.pointerLeave(trigger);
fireEvent.click(await screen.findByRole("option", { name: "repo-one" }));
fireEvent.pointerEnter(trigger);
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
});
it("keeps touch selection tooltip suppression through focus restoration", async () => {
const frameCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
frameCallbacks.shift()?.(0);
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
fireEvent.pointerEnter(trigger, { pointerType: "touch" });
fireEvent.click(trigger);
const option = await screen.findByRole("option", { name: "repo-one" });
fireEvent.pointerDown(option, { pointerType: "touch" });
fireEvent.click(option);
fireEvent.pointerLeave(trigger, { pointerType: "touch" });
frameCallbacks.shift()?.(0);
frameCallbacks.shift()?.(0);
fireEvent.blur(trigger);
fireEvent.focus(trigger);
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("false");
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
});
it("wraps long repository paths inside a viewport-safe tooltip", () => {
renderTooltipPill({
value: "repository",
tooltip: `Repository · C:\\${"unbroken-path-segment".repeat(20)}`,
});
const classes = screen.getByTestId("tooltip-content").classList;
expect(classes.contains("max-w-[calc(100vw-2rem)]")).toBe(true);
expect(classes.contains("break-all")).toBe(true);
});
it("releases picker tooltip suppression when the keyboard leaves the trigger", async () => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
fireEvent.click(trigger);
fireEvent.click(await screen.findByRole("option", { name: "repo-one" }));
fireEvent.blur(trigger);
fireEvent.focus(trigger);
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
});
});
describe("Pill popover", () => {
it("puts the current option first with a persistent selected surface", () => {
render(
<Pill
icon={<span aria-hidden="true" />}
value="Current label"
selectedValue="current"
placeholder="repository"
options={[
{ value: "first", label: "First" },
{ value: "current", label: "Current" },
{ value: "last", label: "Last" },
]}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
/>,
);
fireEvent.click(screen.getByText("Current label"));
const options = screen.getAllByRole("option");
expect(options.map((option) => option.textContent?.trim())).toEqual([
"Current",
"First",
"Last",
]);
expect(options[0].className).toContain("bg-card");
expect(options[0].className).toContain("border-primary/50");
expect(options[0].querySelector("svg")).not.toBeNull();
});
it("portals selector content outside the trigger container", () => {
render(
<div data-testid="clipping-host" className="overflow-hidden">
<Pill
icon={<span aria-hidden="true" />}
value="kandev"
placeholder="repository"
options={[{ value: "repo-1", label: "repo-one" }]}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
/>
</div>,
);
fireEvent.click(within(screen.getByTestId("clipping-host")).getByText("kandev"));
const content = document.body.querySelector('[data-slot="popover-content"]');
expect(content).not.toBeNull();
expect(screen.getByTestId("clipping-host").contains(content)).toBe(false);
});
it("activates an optional toolbar action with a pointer", () => {
const onAction = vi.fn();
render(
<Pill
icon={<span aria-hidden="true" />}
value=""
placeholder="repository"
options={[]}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
action={{ label: CREATE_REPOSITORY, onSelect: onAction }}
/>,
);
fireEvent.click(screen.getByText("repository"));
fireEvent.click(screen.getByRole("button", { name: CREATE_REPOSITORY }));
expect(onAction).toHaveBeenCalledOnce();
});
it("renders an optional toolbar action outside the option list", () => {
const onAction = vi.fn();
render(
<Pill
icon={<span aria-hidden="true" />}
value=""
placeholder="repository"
options={[]}
onSelect={vi.fn()}
searchPlaceholder="Search repositories..."
emptyMessage="No repositories"
action={{ label: CREATE_REPOSITORY, onSelect: onAction }}
/>,
);
fireEvent.click(screen.getByText("repository"));
expect(screen.getByRole("button", { name: CREATE_REPOSITORY })).toBeTruthy();
expect(screen.queryByRole("option", { name: CREATE_REPOSITORY })).toBeNull();
expect(onAction).not.toHaveBeenCalled();
});
});