import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { readFileSync } from "node:fs"; import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; const readToolbarSource = () => readFileSync("src/components/ai-toolbar.tsx", "utf8"); 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 = {}, ) => { const { AIToolbar } = await import("@/components/ai-toolbar"); return renderToStaticMarkup( actualReact.createElement(AIToolbar, createToolbarProps(overrides)), ); }; const renderToolbarBoundary = async ( overrides: Partial = {}, ) => { let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined; mock.module("@/components/ui/textarea", () => ({ Textarea: (props: React.ComponentProps<"textarea">) => { capturedTextareaProps = props; return actualReact.createElement("textarea", props); }, })); const { AIToolbar } = await import("@/components/ai-toolbar"); renderToStaticMarkup( actualReact.createElement(AIToolbar, createToolbarProps(overrides)), ); expect(capturedTextareaProps).toBeDefined(); return capturedTextareaProps!; }; describe("AI toolbar layout contracts", () => { test("desktop composer uses a 70/30 split driven by the isMobile branch", () => { const source = readToolbarSource(); expect(source).toContain("isMobile"); expect(source).toContain('? "grid gap-3"'); expect(source).toContain( ': "grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]"', ); expect(source).not.toContain( ': "grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"', ); }); test("example prompts render as a two-column masonry-style cluster below the textarea", () => { const source = readToolbarSource(); expect(source).toContain("Try:"); expect(source).toContain('className="columns-2 gap-2"'); expect(source).toContain('className="mb-2 break-inside-avoid"'); expect(source).toContain( 'className="h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed"', ); }); 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 stack one per row instead of rendering a horizontal strip or multi-column gallery", () => { const source = readToolbarSource(); const previewGridClassNames = [...source.matchAll( /className="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g, )].map(([, className]) => className); const multiColumnPreviewPattern = /(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/; expect(source).toContain('className="mt-3 grid gap-2"'); expect(previewGridClassNames).toHaveLength(1); expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern); }); }); 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"); }); }); 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("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("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(); }); });