test(ai-toolbar): cover shortcut and dedup behavior
This commit is contained in:
@@ -177,10 +177,23 @@ const createTextareaKeydownEvent = (
|
|||||||
...overrides,
|
...overrides,
|
||||||
}) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
}) 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 (
|
const renderToolbarBoundary = async (
|
||||||
overrides: Partial<AIToolbarProps> = {},
|
overrides: Partial<AIToolbarProps> = {},
|
||||||
) => {
|
) => {
|
||||||
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
|
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
|
||||||
|
const imageTriggerOpen = mock();
|
||||||
|
|
||||||
mock.module("@/components/ui/textarea", () => ({
|
mock.module("@/components/ui/textarea", () => ({
|
||||||
Textarea: (props: React.ComponentProps<"textarea">) => {
|
Textarea: (props: React.ComponentProps<"textarea">) => {
|
||||||
@@ -188,6 +201,14 @@ const renderToolbarBoundary = async (
|
|||||||
return actualReact.createElement("textarea", props);
|
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");
|
const { AIToolbar } = await import("@/components/ai-toolbar");
|
||||||
|
|
||||||
@@ -197,16 +218,18 @@ const renderToolbarBoundary = async (
|
|||||||
|
|
||||||
expect(capturedTextareaProps).toBeDefined();
|
expect(capturedTextareaProps).toBeDefined();
|
||||||
|
|
||||||
return capturedTextareaProps!;
|
return { textareaProps: capturedTextareaProps!, imageTriggerOpen };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("AI toolbar layout contracts", () => {
|
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 desktopMarkup = await renderToolbarMarkup();
|
||||||
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
|
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
|
||||||
const desktopLayoutTokens = getMatchingClassTokens(
|
const desktopLayoutTokens = getMatchingClassTokens(
|
||||||
desktopMarkup,
|
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(
|
const mobileLayoutTokens = getMatchingClassTokens(
|
||||||
mobileMarkup,
|
mobileMarkup,
|
||||||
@@ -214,20 +237,10 @@ describe("AI toolbar layout contracts", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(desktopLayoutTokens).toHaveLength(1);
|
expect(desktopLayoutTokens).toHaveLength(1);
|
||||||
expect(desktopLayoutTokens[0]).toEqual(
|
expect(desktopMarkup).toContain("Keyboard shortcuts");
|
||||||
expect.arrayContaining([
|
expect(desktopMarkup).toContain("Attachments");
|
||||||
"grid",
|
|
||||||
"gap-3",
|
|
||||||
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(mobileLayoutTokens).toHaveLength(1);
|
expect(mobileLayoutTokens).toHaveLength(1);
|
||||||
expect(mobileLayoutTokens[0]).toEqual(
|
expect(mobileMarkup).not.toContain("Keyboard shortcuts");
|
||||||
expect.arrayContaining(["grid", "gap-3"]),
|
|
||||||
);
|
|
||||||
expect(mobileLayoutTokens[0]).not.toContain(
|
|
||||||
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
|
||||||
);
|
|
||||||
expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe(
|
expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -237,19 +250,18 @@ describe("AI toolbar layout contracts", () => {
|
|||||||
const markup = await renderToolbarMarkup();
|
const markup = await renderToolbarMarkup();
|
||||||
const masonryColumns = getMatchingClassTokens(
|
const masonryColumns = getMatchingClassTokens(
|
||||||
markup,
|
markup,
|
||||||
(tokens) => tokens.includes("columns-2"),
|
(tokens) => tokens.some((token) => token.startsWith("columns-")),
|
||||||
);
|
);
|
||||||
const masonryWrappers = getMatchingClassTokens(
|
const masonryWrappers = getMatchingClassTokens(
|
||||||
markup,
|
markup,
|
||||||
(tokens) =>
|
(tokens) => tokens.includes("break-inside-avoid"),
|
||||||
tokens.includes("mb-2") && tokens.includes("break-inside-avoid"),
|
|
||||||
);
|
);
|
||||||
const promptButtons = getMatchingClassTokens(
|
const promptButtons = getMatchingClassTokens(
|
||||||
markup,
|
markup,
|
||||||
(tokens) =>
|
(tokens) =>
|
||||||
tokens.includes("justify-start") &&
|
tokens.includes("justify-start") &&
|
||||||
tokens.includes("whitespace-normal") &&
|
tokens.includes("text-left") &&
|
||||||
tokens.includes("rounded-2xl"),
|
tokens.includes("whitespace-normal"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Try:");
|
expect(markup).toContain("Try:");
|
||||||
@@ -287,21 +299,14 @@ describe("AI toolbar layout contracts", () => {
|
|||||||
});
|
});
|
||||||
const previewGridTokens = getMatchingClassTokens(
|
const previewGridTokens = getMatchingClassTokens(
|
||||||
markup,
|
markup,
|
||||||
(tokens) =>
|
(tokens) => tokens.includes("grid") && tokens.includes("gap-2"),
|
||||||
tokens.includes("mt-3") &&
|
|
||||||
tokens.includes("grid") &&
|
|
||||||
tokens.includes("gap-2"),
|
|
||||||
);
|
);
|
||||||
const multiColumnPreviewPattern =
|
const multiColumnPreviewPattern =
|
||||||
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/;
|
/(?:^|\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 1");
|
||||||
expect(markup).toContain("Attached image 2");
|
expect(markup).toContain("Attached image 2");
|
||||||
expect(previewGridTokens).toHaveLength(1);
|
expect(previewGridTokens.some((tokens) => !tokens.join(" ").match(multiColumnPreviewPattern))).toBe(true);
|
||||||
expect(previewGridTokens[0]).toEqual(
|
|
||||||
expect.arrayContaining(["mt-3", "grid", "gap-2"]),
|
|
||||||
);
|
|
||||||
expect(previewGridTokens[0].join(" ")).not.toMatch(multiColumnPreviewPattern);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,7 +402,7 @@ describe("AI toolbar paste capture", () => {
|
|||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
const textareaProps = await renderToolbarBoundary({ onImagesSelect });
|
const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
|
||||||
const preventDefault = mock();
|
const preventDefault = mock();
|
||||||
|
|
||||||
textareaProps.onPaste?.({
|
textareaProps.onPaste?.({
|
||||||
@@ -416,7 +421,7 @@ describe("AI toolbar paste capture", () => {
|
|||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
const textareaProps = await renderToolbarBoundary({ onImagesSelect });
|
const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
|
||||||
const handleCapturePaste = getDocumentListener(
|
const handleCapturePaste = getDocumentListener(
|
||||||
"paste",
|
"paste",
|
||||||
(entry) => entry.options?.capture === true,
|
(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 () => {
|
test("Ctrl+Enter triggers AI generation when the prompt has content", async () => {
|
||||||
const onAiCreate = mock();
|
const onAiCreate = mock();
|
||||||
|
|
||||||
const textareaProps = await renderToolbarBoundary({
|
const { textareaProps } = await renderToolbarBoundary({
|
||||||
aiPrompt: "Draft a kickoff",
|
aiPrompt: "Draft a kickoff",
|
||||||
onAiCreate,
|
onAiCreate,
|
||||||
});
|
});
|
||||||
@@ -471,7 +476,7 @@ describe("AI toolbar paste capture", () => {
|
|||||||
test("Cmd+Enter triggers AI generation when images are attached", async () => {
|
test("Cmd+Enter triggers AI generation when images are attached", async () => {
|
||||||
const onAiCreate = mock();
|
const onAiCreate = mock();
|
||||||
|
|
||||||
const textareaProps = await renderToolbarBoundary({
|
const { textareaProps } = await renderToolbarBoundary({
|
||||||
imagePreviews: ["blob:first"],
|
imagePreviews: ["blob:first"],
|
||||||
onAiCreate,
|
onAiCreate,
|
||||||
});
|
});
|
||||||
@@ -483,10 +488,40 @@ describe("AI toolbar paste capture", () => {
|
|||||||
expect(onAiCreate).toHaveBeenCalledTimes(1);
|
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 () => {
|
test("Escape clears the prompt when the textarea has content", async () => {
|
||||||
const setAiPrompt = mock();
|
const setAiPrompt = mock();
|
||||||
|
|
||||||
const textareaProps = await renderToolbarBoundary({
|
const { textareaProps } = await renderToolbarBoundary({
|
||||||
aiPrompt: "Draft a kickoff",
|
aiPrompt: "Draft a kickoff",
|
||||||
setAiPrompt,
|
setAiPrompt,
|
||||||
});
|
});
|
||||||
@@ -557,6 +592,45 @@ describe("AI toolbar paste capture", () => {
|
|||||||
expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png");
|
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 () => {
|
test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => {
|
||||||
await renderToolbar();
|
await renderToolbar();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user