diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index 325d68f..1a26c52 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -177,10 +177,23 @@ const createTextareaKeydownEvent = ( ...overrides, }) as unknown as React.KeyboardEvent; +const createDocumentKeydownEvent = ( + overrides: Partial = {}, +) => ({ + 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 = {}, ) => { 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();