import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; type AIToolbarProps = { adminAiEnabled: boolean; aiEnabled: boolean; isAuthenticated: boolean; isPending: boolean; aiPrompt: string; setAiPrompt: (prompt: string) => void; aiLoading: boolean; imagePreviews: string[]; onImagesSelect: (files: File[]) => void; onImageRemove: (index: number) => void; onAiCreate: () => void; onAiTemplateSelect: (prompt: string) => void; onAiSummarize: () => void; onSummaryDismiss: () => void; summary: string | null; summaryUpdated: string | null; events: Array<{ id?: string }>; }; const createToolbarProps = ( overrides: Partial = {}, ): AIToolbarProps => ({ adminAiEnabled: true, aiEnabled: true, isAuthenticated: true, isPending: false, aiPrompt: "", setAiPrompt: () => {}, aiLoading: false, imagePreviews: [], onImagesSelect: () => {}, onImageRemove: () => {}, onAiCreate: () => {}, onAiTemplateSelect: () => {}, onAiSummarize: () => {}, onSummaryDismiss: () => {}, summary: null, summaryUpdated: null, events: [], ...overrides, }); const actualReact = React; const documentAddEventListener = mock(); const documentRemoveEventListener = mock(); let lastEffectCleanup: (() => void) | undefined; const registeredDocumentListeners = new Map< string, Array<{ listener: EventListener; options?: AddEventListenerOptions }> >(); const getDocumentListener = ( type: string, predicate: (entry: { listener: EventListener; options?: AddEventListenerOptions; }) => boolean = () => true, ) => { const entry = registeredDocumentListeners.get(type)?.find(predicate); expect(entry).toBeDefined(); return entry!.listener; }; const createClipboardData = (files: File[]): DataTransfer => ({ files, items: files.map((file) => ({ kind: "file", type: file.type, getAsFile: () => file, })), } as unknown as DataTransfer); beforeEach(() => { documentAddEventListener.mockClear(); documentRemoveEventListener.mockClear(); lastEffectCleanup = undefined; registeredDocumentListeners.clear(); globalThis.document = { addEventListener: documentAddEventListener, removeEventListener: documentRemoveEventListener, } as Document; documentAddEventListener.mockImplementation( ( type: string, listener: EventListener, options?: AddEventListenerOptions, ) => { const listeners = registeredDocumentListeners.get(type) ?? []; listeners.push({ listener, options }); registeredDocumentListeners.set(type, listeners); }, ); mock.module("react", () => ({ ...actualReact, useEffect: (effect: () => void | (() => void)) => { const cleanup = effect(); lastEffectCleanup = typeof cleanup === "function" ? cleanup : undefined; }, useRef: (initialValue: T) => ({ current: initialValue }), useState: (initialValue: T) => [initialValue, mock()], })); mock.module("@/hooks/use-mobile", () => ({ useIsMobile: () => false, })); }); afterEach(() => { delete (globalThis as { document?: Document }).document; delete (globalThis as { navigator?: Navigator }).navigator; lastEffectCleanup = undefined; mock.restore(); }); const renderToolbar = async (overrides: Partial = {}) => { const { AIToolbar } = await import("@/components/ai-toolbar"); AIToolbar(createToolbarProps(overrides)); }; const renderToolbarMarkup = async ( overrides: Partial = {}, { isMobile = false }: { isMobile?: boolean } = {}, ) => { mock.module("@/hooks/use-mobile", () => ({ useIsMobile: () => isMobile, })); const { AIToolbar } = await import("@/components/ai-toolbar"); return renderToStaticMarkup( actualReact.createElement(AIToolbar, createToolbarProps(overrides)), ); }; const escapeForRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const getButtonOpeningTag = (markup: string, label: string) => { const buttonMatch = markup.match( new RegExp( `]*>(?:(?!<\\/button>)[\\s\\S])*?${escapeForRegex(label)}(?:(?!<\\/button>)[\\s\\S])*?<\\/button>`, ), ); const openingTagMatch = buttonMatch?.[0].match(/^]*>/); expect(openingTagMatch).toBeDefined(); 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 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">) => { capturedTextareaProps = 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"); renderToStaticMarkup( actualReact.createElement(AIToolbar, createToolbarProps(overrides)), ); expect(capturedTextareaProps).toBeDefined(); return { textareaProps: capturedTextareaProps!, imageTriggerOpen }; }; describe("AI toolbar layout contracts", () => { 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.some((token) => token.startsWith("grid-cols-[minmax(0,0.7fr)")), ); const mobileLayoutTokens = getMatchingClassTokens( mobileMarkup, (tokens) => tokens.includes("grid") && tokens.includes("gap-3"), ); expect(desktopLayoutTokens).toHaveLength(1); expect(desktopMarkup).toContain("Keyboard shortcuts"); expect(desktopMarkup).toContain("Attachments"); expect(mobileLayoutTokens).toHaveLength(1); expect(mobileMarkup).not.toContain("Keyboard shortcuts"); 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.some((token) => token.startsWith("columns-")), ); const masonryWrappers = getMatchingClassTokens( markup, (tokens) => tokens.includes("break-inside-avoid"), ); const promptButtons = getMatchingClassTokens( markup, (tokens) => tokens.includes("justify-start") && tokens.includes("text-left") && tokens.includes("whitespace-normal"), ); expect(markup).toContain("Try:"); expect(markup).toContain( "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 () => { const markup = await renderToolbarMarkup(); expect(markup).toContain("Attachments"); expect(markup).toContain("0 files"); expect(markup).toContain("Attach images"); expect(markup).toContain( "Drop or paste images here to pair them with the prompt.", ); }); test("attachment previews render in a stacked grid instead of a multi-column strip", async () => { const markup = await renderToolbarMarkup({ imagePreviews: ["blob:first", "blob:second"], }); const previewGridTokens = getMatchingClassTokens( markup, (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.some((tokens) => !tokens.join(" ").match(multiColumnPreviewPattern))).toBe(true); }); }); describe("AI toolbar state rendering", () => { test("unauthenticated users see the sign-in-required callout", async () => { const markup = await renderToolbarMarkup({ isAuthenticated: false }); expect(markup).toContain("Sign in required to generate event drafts with AI"); expect(markup).toContain( "Sign in to turn natural language or flyers into event drafts", ); }); test("admin-disabled state explains that AI is disabled by the administrator", async () => { const markup = await renderToolbarMarkup({ adminAiEnabled: false, isAuthenticated: true, }); expect(markup).toContain("AI integrations are unavailable"); expect(markup).toContain( "AI integrations are currently disabled by the administrator.", ); }); test("browser-disabled state explains that AI was turned off from settings", async () => { const markup = await renderToolbarMarkup({ adminAiEnabled: true, aiEnabled: false, isAuthenticated: true, }); expect(markup).toContain("AI integrations are unavailable"); expect(markup).toContain( "AI has been turned off in this browser from Settings.", ); }); test("summary panel renders the summary body, updated timestamp, and dismiss affordance", async () => { const markup = await renderToolbarMarkup({ summary: "Three events need attention.", summaryUpdated: "Updated just now", }); expect(markup).toContain("AI Summary"); expect(markup).toContain("Three events need attention."); expect(markup).toContain("Updated just now"); expect(markup).toContain("Dismiss AI summary"); }); test("Summarize only appears when there are events to summarize", async () => { const emptyMarkup = await renderToolbarMarkup({ events: [] }); const populatedMarkup = await renderToolbarMarkup({ events: [{ id: "event-1" }], }); expect(emptyMarkup).not.toContain("Summarize"); expect(populatedMarkup).toContain("Summarize"); }); test("Generate event stays disabled while loading or when both prompt and images are absent", async () => { const emptyMarkup = await renderToolbarMarkup(); const loadingMarkup = await renderToolbarMarkup({ aiLoading: true, aiPrompt: "Draft a kickoff", }); const promptMarkup = await renderToolbarMarkup({ aiPrompt: "Draft a kickoff", }); const imageMarkup = await renderToolbarMarkup({ imagePreviews: ["blob:first"], }); expect(getButtonOpeningTag(emptyMarkup, "Generate event")).toContain( " disabled=\"\"", ); expect(getButtonOpeningTag(loadingMarkup, "Generating...")).toContain( " disabled=\"\"", ); expect(getButtonOpeningTag(promptMarkup, "Generate event")).not.toContain( " disabled=", ); expect(getButtonOpeningTag(imageMarkup, "Generate event")).not.toContain( " disabled=", ); }); }); describe("AI toolbar paste capture", () => { test("textarea paste path forwards clipboard images to onImagesSelect through the component boundary", async () => { const onImagesSelect = mock(); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect }); const preventDefault = mock(); textareaProps.onPaste?.({ clipboardData: createClipboardData([image]), preventDefault, } as unknown as React.ClipboardEvent); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledWith([image]); }); test("textarea-targeted paste bypasses the document listeners so the focused textarea owns the paste", async () => { const onImagesSelect = mock(); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect }); const handleCapturePaste = getDocumentListener( "paste", (entry) => entry.options?.capture === true, ); const handleDocumentPaste = getDocumentListener( "paste", (entry) => !entry.options?.capture, ); const preventDefault = mock(); const textareaTarget = { tagName: "TEXTAREA", isContentEditable: false, } as HTMLTextAreaElement; const pasteEvent = { target: textareaTarget, currentTarget: textareaTarget, clipboardData: createClipboardData([image]), preventDefault, }; handleCapturePaste(pasteEvent as unknown as Event); expect(preventDefault).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); textareaProps.onPaste?.( pasteEvent as unknown as React.ClipboardEvent, ); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledWith([image]); handleDocumentPaste(pasteEvent as unknown as Event); expect(preventDefault).toHaveBeenCalledTimes(1); 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("Mod+Enter does not trigger AI generation while loading", async () => { const onAiCreate = mock(); const { textareaProps } = await renderToolbarBoundary({ aiPrompt: "Draft a kickoff", aiLoading: true, onAiCreate, }); const event = createTextareaKeydownEvent({ ctrlKey: true }); textareaProps.onKeyDown?.(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(onAiCreate).not.toHaveBeenCalled(); }); test("AI unavailable state removes the composer so generate shortcuts are not exposed", async () => { const markup = await renderToolbarMarkup({ aiEnabled: false }); expect(markup).toContain("AI integrations are unavailable"); expect(markup).not.toContain("Type or paste event details..."); expect(markup).not.toContain("Generate event"); }); 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+Cmd+A also opens the image picker when the composer is idle", async () => { const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); const metaEvent = createTextareaKeydownEvent({ key: "A", metaKey: true, shiftKey: true, }); textareaProps.onKeyDown?.(metaEvent); expect(metaEvent.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({ 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", { type: "image/png", }); await renderToolbar({ onImagesSelect }); const handleDocumentPaste = getDocumentListener( "paste", (entry) => !entry.options?.capture, ); const preventDefault = mock(); handleDocumentPaste({ target: { tagName: "DIV", isContentEditable: false }, clipboardData: createClipboardData([image]), preventDefault, } as unknown as Event); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledWith([image]); }); test("Ctrl/Cmd+V fallback forwards clipboard images for non-editable targets", 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 handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown({ key: "v", ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, target: { tagName: "DIV", isContentEditable: false }, } as unknown as Event); expect(clipboardRead).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0]).toHaveLength(1); expect(onImagesSelect.mock.calls[0]?.[0][0]).toBeInstanceOf(File); expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); }); test("async clipboard fallback does not double-handle a paste already handled by the synchronous document paste flow", 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 handleDocumentPaste = getDocumentListener( "paste", (entry) => !entry.options?.capture, ); const handleDocumentKeydown = getDocumentListener("keydown"); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); const preventDefault = mock(); const keydownPromise = handleDocumentKeydown( createDocumentKeydownEvent({ ctrlKey: true }), ); const pasteEvent = { target: { tagName: "DIV", isContentEditable: false }, clipboardData: createClipboardData([image]), preventDefault, } as unknown as Event; handleCapturePaste(pasteEvent); handleDocumentPaste(pasteEvent); await keydownPromise; expect(preventDefault).toHaveBeenCalledTimes(1); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledWith([image]); }); test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => { await renderToolbar(); expect(documentAddEventListener).toHaveBeenCalledTimes(3); expect(documentAddEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), ); expect(documentAddEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), { capture: true }, ); expect(documentAddEventListener).toHaveBeenCalledWith( "keydown", expect.any(Function), ); lastEffectCleanup?.(); expect(documentRemoveEventListener).toHaveBeenCalledTimes(3); expect(documentRemoveEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), ); expect(documentRemoveEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), { capture: true }, ); expect(documentRemoveEventListener).toHaveBeenCalledWith( "keydown", expect.any(Function), ); }); test("global paste listeners stay disarmed when AI cannot be used", async () => { await renderToolbar({ isAuthenticated: false }); await renderToolbar({ isPending: true }); await renderToolbar({ adminAiEnabled: false }); await renderToolbar({ aiEnabled: false }); await renderToolbar({ aiLoading: true }); expect(documentAddEventListener).not.toHaveBeenCalled(); expect(documentRemoveEventListener).not.toHaveBeenCalled(); }); });