From f2816247c13ce9e3096193554c6e7b90ea89dc8b Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Wed, 22 Apr 2026 23:49:29 -0400 Subject: [PATCH] test(ai-toolbar): reduce layout brittleness and cover shortcuts --- tests/ai-toolbar.test.ts | 146 +++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 14 deletions(-) diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index ba8fe03..325d68f 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -154,6 +154,29 @@ const getButtonOpeningTag = (markup: string, label: string) => { return openingTagMatch![0]; }; +const getClassAttributes = (markup: string) => + [...markup.matchAll(/class="([^"]+)"/g)].map(([, className]) => className); + +const getMatchingClassTokens = ( + markup: string, + predicate: (tokens: string[]) => boolean, +) => + getClassAttributes(markup) + .map((className) => className.split(/\s+/).filter(Boolean)) + .filter(predicate); + +const createTextareaKeydownEvent = ( + overrides: Partial> = {}, +) => ({ + key: "Enter", + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: mock(), + ...overrides, +}) as unknown as React.KeyboardEvent; + const renderToolbarBoundary = async ( overrides: Partial = {}, ) => { @@ -181,27 +204,70 @@ describe("AI toolbar layout contracts", () => { test("desktop composer renders a 70/30 split 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"), + ); + const mobileLayoutTokens = getMatchingClassTokens( + mobileMarkup, + (tokens) => tokens.includes("grid") && tokens.includes("gap-3"), + ); - expect(desktopMarkup).toContain( - "grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]", + 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).not.toContain( - "grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]", + expect(mobileLayoutTokens).toHaveLength(1); + expect(mobileLayoutTokens[0]).toEqual( + expect.arrayContaining(["grid", "gap-3"]), ); - expect(mobileMarkup).not.toContain( + 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( + false, + ); }); test("example prompts render as a masonry-style cluster below the textarea", async () => { const markup = await renderToolbarMarkup(); + const masonryColumns = getMatchingClassTokens( + markup, + (tokens) => tokens.includes("columns-2"), + ); + const masonryWrappers = getMatchingClassTokens( + markup, + (tokens) => + tokens.includes("mb-2") && tokens.includes("break-inside-avoid"), + ); + const promptButtons = getMatchingClassTokens( + markup, + (tokens) => + tokens.includes("justify-start") && + tokens.includes("whitespace-normal") && + tokens.includes("rounded-2xl"), + ); expect(markup).toContain("Try:"); - expect(markup).toContain('class="columns-2 gap-2"'); - expect(markup).toContain('class="mb-2 break-inside-avoid"'); expect(markup).toContain( - "h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed", + "Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.", ); + expect(markup).toContain( + "Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.", + ); + expect(markup).toContain( + "Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.", + ); + expect(masonryColumns).toHaveLength(1); + expect(masonryColumns[0]).toEqual( + expect.arrayContaining(["columns-2", "gap-2"]), + ); + expect(masonryWrappers).toHaveLength(3); + expect(promptButtons).toHaveLength(3); }); test("attachments render as a separate surfaced panel with count badge, picker, and empty state", async () => { @@ -219,17 +285,23 @@ describe("AI toolbar layout contracts", () => { const markup = await renderToolbarMarkup({ imagePreviews: ["blob:first", "blob:second"], }); - const previewGridClassNames = [...markup.matchAll( - /class="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g, - )].map(([, className]) => className); + const previewGridTokens = getMatchingClassTokens( + markup, + (tokens) => + tokens.includes("mt-3") && + 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('class="mt-3 grid gap-2"'); expect(markup).toContain("Attached image 1"); expect(markup).toContain("Attached image 2"); - expect(previewGridClassNames).toHaveLength(1); - expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern); + expect(previewGridTokens).toHaveLength(1); + expect(previewGridTokens[0]).toEqual( + expect.arrayContaining(["mt-3", "grid", "gap-2"]), + ); + expect(previewGridTokens[0].join(" ")).not.toMatch(multiColumnPreviewPattern); }); }); @@ -381,6 +453,52 @@ describe("AI toolbar paste capture", () => { expect(onImagesSelect).toHaveBeenCalledTimes(1); }); + test("Ctrl+Enter triggers AI generation when the prompt has content", async () => { + const onAiCreate = mock(); + + const textareaProps = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ ctrlKey: true }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(onAiCreate).toHaveBeenCalledTimes(1); + }); + + test("Cmd+Enter triggers AI generation when images are attached", async () => { + const onAiCreate = mock(); + + const textareaProps = await renderToolbarBoundary({ + imagePreviews: ["blob:first"], + onAiCreate, + }); + const event = createTextareaKeydownEvent({ metaKey: true }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(onAiCreate).toHaveBeenCalledTimes(1); + }); + + test("Escape clears the prompt when the textarea has content", async () => { + const setAiPrompt = mock(); + + const textareaProps = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + setAiPrompt, + }); + const event = createTextareaKeydownEvent({ key: "Escape" }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(setAiPrompt).toHaveBeenCalledTimes(1); + expect(setAiPrompt).toHaveBeenCalledWith(""); + }); + test("document paste forwards clipboard images only for non-editable targets", async () => { const onImagesSelect = mock(); const image = new File(["image-bytes"], "clipboard.png", {