diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 5f9da1d..3f0ce25 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -94,22 +94,6 @@ interface AIToolbarProps { events: CalendarEvent[]; } -interface GlobalPasteCaptureGate { - isAuthenticated: boolean; - isPending: boolean; - canUseAi: boolean; - aiLoading: boolean; -} - -export function shouldEnableGlobalPasteCapture({ - isAuthenticated, - isPending, - canUseAi, - aiLoading, -}: GlobalPasteCaptureGate): boolean { - return isAuthenticated && !isPending && canUseAi && !aiLoading; -} - // ─── Component ──────────────────────────────────────────────────────────────── export const AIToolbar = ({ @@ -170,14 +154,7 @@ export const AIToolbar = ({ // focused element or OS clipboard model (X11/Wayland). // This is the approach used by Excalidraw's actionPaste. useEffect(() => { - if ( - !shouldEnableGlobalPasteCapture({ - isAuthenticated, - isPending, - canUseAi, - aiLoading, - }) - ) + if (!(isAuthenticated && !isPending && canUseAi && !aiLoading)) return; // ── Handler 1: paste event (works when textarea is NOT focused) ─────── diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index dbc09a0..e763890 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { shouldEnableGlobalPasteCapture } from "@/components/ai-toolbar"; +import * as React from "react"; import { buttonVariants } from "@/components/ui/button"; import { getAiDisabledMessage } from "@/lib/ai-feature-flags"; import { cn } from "@/lib/utils"; @@ -55,6 +55,88 @@ const BADGE_POSITION_CLASS = "ml-auto"; 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; + +beforeEach(() => { + documentAddEventListener.mockClear(); + documentRemoveEventListener.mockClear(); + lastEffectCleanup = undefined; + globalThis.document = { + addEventListener: documentAddEventListener, + removeEventListener: documentRemoveEventListener, + } as Document; + 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; + lastEffectCleanup = undefined; + mock.restore(); +}); + +const renderToolbar = async (overrides: Partial = {}) => { + const { AIToolbar } = await import("@/components/ai-toolbar"); + + AIToolbar(createToolbarProps(overrides)); +}; + const getExamplePromptButtonClassName = () => { const source = readToolbarSource(); const match = source.match( @@ -172,6 +254,51 @@ describe("Event count badge – positioning contract", () => { }); describe("AI capture redesign", () => { + 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 is unavailable or loading", async () => { + await renderToolbar({ adminAiEnabled: false }); + await renderToolbar({ aiEnabled: false }); + await renderToolbar({ aiLoading: true }); + + expect(documentAddEventListener).not.toHaveBeenCalled(); + expect(documentRemoveEventListener).not.toHaveBeenCalled(); + }); + test("composer layout is driven by useIsMobile instead of Tailwind breakpoint classes", () => { const source = readToolbarSource(); @@ -324,55 +451,6 @@ describe("Keyboard shortcuts – toolbar integration contract", () => { }); }); -describe("Global paste capture – AI availability gate", () => { - test("document-level paste handlers only arm when AI is actually usable", () => { - expect( - shouldEnableGlobalPasteCapture({ - isAuthenticated: true, - isPending: false, - canUseAi: true, - aiLoading: false, - }), - ).toBe(true); - - expect( - shouldEnableGlobalPasteCapture({ - isAuthenticated: false, - isPending: false, - canUseAi: true, - aiLoading: false, - }), - ).toBe(false); - - expect( - shouldEnableGlobalPasteCapture({ - isAuthenticated: true, - isPending: true, - canUseAi: true, - aiLoading: false, - }), - ).toBe(false); - - expect( - shouldEnableGlobalPasteCapture({ - isAuthenticated: true, - isPending: false, - canUseAi: false, - aiLoading: false, - }), - ).toBe(false); - - expect( - shouldEnableGlobalPasteCapture({ - isAuthenticated: true, - isPending: false, - canUseAi: true, - aiLoading: true, - }), - ).toBe(false); - }); -}); - // ─── Cycle 8: Multi-image thumbnail strip ──────────────────────────────────── // // When multiple images are attached, they render as a horizontal scrollable