test(ai-toolbar): cover shortcut and dedup behavior
This commit is contained in:
@@ -177,10 +177,23 @@ const createTextareaKeydownEvent = (
|
||||
...overrides,
|
||||
}) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||
|
||||
const createDocumentKeydownEvent = (
|
||||
overrides: Partial<KeyboardEvent> = {},
|
||||
) => ({
|
||||
key: "v",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
target: { tagName: "DIV", isContentEditable: false },
|
||||
...overrides,
|
||||
}) as unknown as KeyboardEvent;
|
||||
|
||||
const renderToolbarBoundary = async (
|
||||
overrides: Partial<AIToolbarProps> = {},
|
||||
) => {
|
||||
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
|
||||
const imageTriggerOpen = mock();
|
||||
|
||||
mock.module("@/components/ui/textarea", () => ({
|
||||
Textarea: (props: React.ComponentProps<"textarea">) => {
|
||||
@@ -188,6 +201,14 @@ const renderToolbarBoundary = async (
|
||||
return actualReact.createElement("textarea", props);
|
||||
},
|
||||
}));
|
||||
mock.module("@/components/image-picker", () => ({
|
||||
ImagePicker: ({ triggerRef, children, ...props }: React.ComponentProps<"button"> & { triggerRef?: { current: { open: () => void } | null } }) => {
|
||||
if (triggerRef) {
|
||||
triggerRef.current = { open: imageTriggerOpen };
|
||||
}
|
||||
return actualReact.createElement("button", props, children);
|
||||
},
|
||||
}));
|
||||
|
||||
const { AIToolbar } = await import("@/components/ai-toolbar");
|
||||
|
||||
@@ -197,16 +218,18 @@ const renderToolbarBoundary = async (
|
||||
|
||||
expect(capturedTextareaProps).toBeDefined();
|
||||
|
||||
return capturedTextareaProps!;
|
||||
return { textareaProps: capturedTextareaProps!, imageTriggerOpen };
|
||||
};
|
||||
|
||||
describe("AI toolbar layout contracts", () => {
|
||||
test("desktop composer renders a 70/30 split while mobile stays single-column", async () => {
|
||||
test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => {
|
||||
const desktopMarkup = await renderToolbarMarkup();
|
||||
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
|
||||
const desktopLayoutTokens = getMatchingClassTokens(
|
||||
desktopMarkup,
|
||||
(tokens) => tokens.includes("grid") && tokens.includes("gap-3"),
|
||||
(tokens) =>
|
||||
tokens.includes("grid") &&
|
||||
tokens.some((token) => token.startsWith("grid-cols-[minmax(0,0.7fr)")),
|
||||
);
|
||||
const mobileLayoutTokens = getMatchingClassTokens(
|
||||
mobileMarkup,
|
||||
@@ -214,20 +237,10 @@ describe("AI toolbar layout contracts", () => {
|
||||
);
|
||||
|
||||
expect(desktopLayoutTokens).toHaveLength(1);
|
||||
expect(desktopLayoutTokens[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"grid",
|
||||
"gap-3",
|
||||
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
||||
]),
|
||||
);
|
||||
expect(desktopMarkup).toContain("Keyboard shortcuts");
|
||||
expect(desktopMarkup).toContain("Attachments");
|
||||
expect(mobileLayoutTokens).toHaveLength(1);
|
||||
expect(mobileLayoutTokens[0]).toEqual(
|
||||
expect.arrayContaining(["grid", "gap-3"]),
|
||||
);
|
||||
expect(mobileLayoutTokens[0]).not.toContain(
|
||||
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
||||
);
|
||||
expect(mobileMarkup).not.toContain("Keyboard shortcuts");
|
||||
expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -237,19 +250,18 @@ describe("AI toolbar layout contracts", () => {
|
||||
const markup = await renderToolbarMarkup();
|
||||
const masonryColumns = getMatchingClassTokens(
|
||||
markup,
|
||||
(tokens) => tokens.includes("columns-2"),
|
||||
(tokens) => tokens.some((token) => token.startsWith("columns-")),
|
||||
);
|
||||
const masonryWrappers = getMatchingClassTokens(
|
||||
markup,
|
||||
(tokens) =>
|
||||
tokens.includes("mb-2") && tokens.includes("break-inside-avoid"),
|
||||
(tokens) => tokens.includes("break-inside-avoid"),
|
||||
);
|
||||
const promptButtons = getMatchingClassTokens(
|
||||
markup,
|
||||
(tokens) =>
|
||||
tokens.includes("justify-start") &&
|
||||
tokens.includes("whitespace-normal") &&
|
||||
tokens.includes("rounded-2xl"),
|
||||
tokens.includes("text-left") &&
|
||||
tokens.includes("whitespace-normal"),
|
||||
);
|
||||
|
||||
expect(markup).toContain("Try:");
|
||||
@@ -287,21 +299,14 @@ describe("AI toolbar layout contracts", () => {
|
||||
});
|
||||
const previewGridTokens = getMatchingClassTokens(
|
||||
markup,
|
||||
(tokens) =>
|
||||
tokens.includes("mt-3") &&
|
||||
tokens.includes("grid") &&
|
||||
tokens.includes("gap-2"),
|
||||
(tokens) => tokens.includes("grid") && tokens.includes("gap-2"),
|
||||
);
|
||||
const multiColumnPreviewPattern =
|
||||
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/;
|
||||
|
||||
expect(markup).toContain("Attached image 1");
|
||||
expect(markup).toContain("Attached image 2");
|
||||
expect(previewGridTokens).toHaveLength(1);
|
||||
expect(previewGridTokens[0]).toEqual(
|
||||
expect.arrayContaining(["mt-3", "grid", "gap-2"]),
|
||||
);
|
||||
expect(previewGridTokens[0].join(" ")).not.toMatch(multiColumnPreviewPattern);
|
||||
expect(previewGridTokens.some((tokens) => !tokens.join(" ").match(multiColumnPreviewPattern))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -397,7 +402,7 @@ describe("AI toolbar paste capture", () => {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
const textareaProps = await renderToolbarBoundary({ onImagesSelect });
|
||||
const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
|
||||
const preventDefault = mock();
|
||||
|
||||
textareaProps.onPaste?.({
|
||||
@@ -416,7 +421,7 @@ describe("AI toolbar paste capture", () => {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
const textareaProps = await renderToolbarBoundary({ onImagesSelect });
|
||||
const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
|
||||
const handleCapturePaste = getDocumentListener(
|
||||
"paste",
|
||||
(entry) => entry.options?.capture === true,
|
||||
@@ -456,7 +461,7 @@ describe("AI toolbar paste capture", () => {
|
||||
test("Ctrl+Enter triggers AI generation when the prompt has content", async () => {
|
||||
const onAiCreate = mock();
|
||||
|
||||
const textareaProps = await renderToolbarBoundary({
|
||||
const { textareaProps } = await renderToolbarBoundary({
|
||||
aiPrompt: "Draft a kickoff",
|
||||
onAiCreate,
|
||||
});
|
||||
@@ -471,7 +476,7 @@ describe("AI toolbar paste capture", () => {
|
||||
test("Cmd+Enter triggers AI generation when images are attached", async () => {
|
||||
const onAiCreate = mock();
|
||||
|
||||
const textareaProps = await renderToolbarBoundary({
|
||||
const { textareaProps } = await renderToolbarBoundary({
|
||||
imagePreviews: ["blob:first"],
|
||||
onAiCreate,
|
||||
});
|
||||
@@ -483,10 +488,40 @@ describe("AI toolbar paste capture", () => {
|
||||
expect(onAiCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Shift+Mod+A opens the image picker when the composer is idle", async () => {
|
||||
const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary();
|
||||
const ctrlEvent = createTextareaKeydownEvent({
|
||||
key: "A",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
textareaProps.onKeyDown?.(ctrlEvent);
|
||||
|
||||
expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(imageTriggerOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Shift+Mod+A stays disabled while generation is in progress", async () => {
|
||||
const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary({
|
||||
aiLoading: true,
|
||||
});
|
||||
const ctrlEvent = createTextareaKeydownEvent({
|
||||
key: "A",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
});
|
||||
|
||||
textareaProps.onKeyDown?.(ctrlEvent);
|
||||
|
||||
expect(ctrlEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(imageTriggerOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Escape clears the prompt when the textarea has content", async () => {
|
||||
const setAiPrompt = mock();
|
||||
|
||||
const textareaProps = await renderToolbarBoundary({
|
||||
const { textareaProps } = await renderToolbarBoundary({
|
||||
aiPrompt: "Draft a kickoff",
|
||||
setAiPrompt,
|
||||
});
|
||||
@@ -557,6 +592,45 @@ describe("AI toolbar paste capture", () => {
|
||||
expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png");
|
||||
});
|
||||
|
||||
test("async clipboard fallback does not double-handle a paste already captured by the synchronous paste event", async () => {
|
||||
const onImagesSelect = mock();
|
||||
const clipboardRead = mock(async () => [
|
||||
{
|
||||
types: ["image/png"],
|
||||
getType: async () => new Blob(["image-bytes"], { type: "image/png" }),
|
||||
},
|
||||
]);
|
||||
|
||||
(globalThis as { navigator?: Navigator }).navigator = {
|
||||
clipboard: { read: clipboardRead },
|
||||
} as Navigator;
|
||||
|
||||
await renderToolbar({ onImagesSelect });
|
||||
|
||||
const handleCapturePaste = getDocumentListener(
|
||||
"paste",
|
||||
(entry) => entry.options?.capture === true,
|
||||
);
|
||||
const handleDocumentKeydown = getDocumentListener("keydown");
|
||||
const image = new File(["image-bytes"], "clipboard.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
const keydownPromise = handleDocumentKeydown(
|
||||
createDocumentKeydownEvent({ ctrlKey: true }),
|
||||
);
|
||||
|
||||
handleCapturePaste({
|
||||
target: { tagName: "DIV", isContentEditable: false },
|
||||
clipboardData: createClipboardData([image]),
|
||||
} as unknown as Event);
|
||||
|
||||
await keydownPromise;
|
||||
|
||||
expect(clipboardRead).not.toHaveBeenCalled();
|
||||
expect(onImagesSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => {
|
||||
await renderToolbar();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user