import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { readFileSync } from "node:fs"; import * as React from "react"; import { buttonVariants } from "@/components/ui/button"; import { getAiDisabledMessage } from "@/lib/ai-feature-flags"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // AI Toolbar – Layout & Behavioral Contracts // // Public interface under test: the CSS class contracts that govern the // toolbar's visual zones, state-driven visibility, and interaction affordances. // // Philosophy: tests describe WHAT the toolbar does (two-zone layout, // auth-gated AI section, destructive action distinction) — not HOW the // internal JSX is structured. These tests survive refactors because they // lock down the *behavior* (what classes produce what visual outcome) // rather than the implementation (which element wraps which). // --------------------------------------------------------------------------- // ─── Zone class contracts ─────────────────────────────────────────────────── // // The toolbar is divided into two visually distinct zones: // 1. AI zone – identified by a primary-color accent (ring/border on primary) // 2. Data zone – neutral utility surface, no accent color // // We capture the intended class sets here as source-of-truth strings so // that both the tests and the implementation reference the same contract. /** AI zone wrapper: primary accent ring to signal "intelligent / premium" */ const AI_ZONE_CLASSES = "rounded-lg border border-primary/20 bg-primary/5 p-3"; /** Locked AI CTA (unauthenticated): visually prominent enough to be a real CTA */ const LOCKED_AI_CTA_CLASSES = "flex items-center gap-3 py-2"; /** Locked AI CTA sign-in text: must be readable, not ghost-muted */ const LOCKED_AI_TEXT_CLASSES = "text-sm font-medium text-foreground"; const DISABLED_AI_TEXT_CLASSES = "text-sm leading-relaxed text-muted-foreground"; /** Data zone: neutral surface, clearly secondary to AI zone */ const DATA_ZONE_CLASSES = "flex items-center gap-2 flex-wrap"; /** Destructive action (Clear): must be visually distinct from neutral actions */ const DESTRUCTIVE_ACTION_CLASSES = "text-muted-foreground hover:text-destructive"; /** Event count badge: auto-positioned to far right via ml-auto */ 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; 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; 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( / { test("AI zone wrapper carries a primary-color border so it reads as the premium/intelligent section", () => { const resolved = cn(AI_ZONE_CLASSES); // Must have a border that references the primary color token expect(resolved).toMatch(/border-primary/); }); test("AI zone wrapper has a subtle primary background tint", () => { const resolved = cn(AI_ZONE_CLASSES); expect(resolved).toMatch(/bg-primary/); }); test("AI zone wrapper has rounded corners consistent with card radius", () => { const resolved = cn(AI_ZONE_CLASSES); expect(resolved).toMatch(/rounded/); }); }); // ─── Cycle 1: Locked CTA (unauthenticated) ────────────────────────────────── describe("AI zone – locked state CTA (unauthenticated)", () => { test("locked CTA row has flex layout so icon and text align horizontally", () => { const resolved = cn(LOCKED_AI_CTA_CLASSES); expect(resolved).toContain("flex"); expect(resolved).toContain("items-center"); }); test("locked CTA text class uses foreground (not muted-foreground) so it reads as a real CTA, not hint text", () => { const resolved = cn(LOCKED_AI_TEXT_CLASSES); // Must NOT contain 'muted' — the current bug is the text is too invisible expect(resolved).not.toMatch(/muted/); expect(resolved).toContain("text-foreground"); }); test("locked CTA text has font-medium weight, giving it CTA visual weight", () => { const resolved = cn(LOCKED_AI_TEXT_CLASSES); expect(resolved).toContain("font-medium"); }); test("locked CTA copy clearly requires signing in", () => { const copy = "Sign in required to generate event drafts with AI"; expect(copy.toLowerCase()).toContain("sign in"); expect(copy.toLowerCase()).toContain("required"); }); }); describe("AI zone – disabled state", () => { test("disabled AI body text stays muted because it is informative, not a CTA", () => { const resolved = cn(DISABLED_AI_TEXT_CLASSES); expect(resolved).toContain("text-muted-foreground"); }); test("admin-disabled copy explains the unavailable state", () => { expect(getAiDisabledMessage().toLowerCase()).toContain("disabled"); }); }); // ─── Cycle 2: Data zone action buttons ────────────────────────────────────── describe("Data zone – action row layout contract", () => { test("data zone uses flex with wrap so buttons reflow on mobile", () => { const resolved = cn(DATA_ZONE_CLASSES); expect(resolved).toContain("flex"); expect(resolved).toContain("flex-wrap"); }); test("data zone has consistent gap between action buttons", () => { const resolved = cn(DATA_ZONE_CLASSES); expect(resolved).toMatch(/\bgap-[1-9]\d*\b/); }); }); // ─── Cycle 3: Destructive action visual distinction ────────────────────────── describe("Data zone – destructive action (Clear) visual contract", () => { test("Clear button starts at muted color so it reads as low-priority", () => { const resolved = cn(DESTRUCTIVE_ACTION_CLASSES); expect(resolved).toContain("text-muted-foreground"); }); test("Clear button transitions to destructive on hover, warning the user", () => { const resolved = cn(DESTRUCTIVE_ACTION_CLASSES); expect(resolved).toContain("hover:text-destructive"); }); test("Clear button does NOT share the same base class as neutral outline actions", () => { // Neutral actions (Export, Import) use 'outline' variant. // The destructive action uses 'ghost' variant so it doesn't look like an equal peer. // We verify the destructive class set does NOT include 'border' (outline's signature). const resolved = cn(DESTRUCTIVE_ACTION_CLASSES); expect(resolved).not.toContain("border-input"); }); }); // ─── Cycle 4: Event count badge positioning ────────────────────────────────── describe("Event count badge – positioning contract", () => { test("event count badge has ml-auto so it aligns to the far right of its flex row", () => { const resolved = cn(BADGE_POSITION_CLASS); expect(resolved).toContain("ml-auto"); }); }); describe("AI capture redesign", () => { test("document paste forwards clipboard images to onImagesSelect 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 to onImagesSelect 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("Ctrl/Cmd+V fallback ignores editable targets", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => []); (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: "INPUT", isContentEditable: false }, } as unknown as Event); 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(); 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(); expect(source).toContain("useIsMobile"); expect(source).not.toContain("lg:grid-cols-"); }); test("example prompts use a masonry-style cluster inside the composer footer", () => { const source = readToolbarSource(); const promptButtonClassName = getExamplePromptButtonClassName(); const mergedPromptButtonClassName = cn( buttonVariants({ variant: "secondary", size: "sm", className: promptButtonClassName, }), ); expect(source).toContain("Try:"); expect(source).toContain("columns-2"); expect(source).toContain("break-inside-avoid"); expect(promptButtonClassName).toContain("whitespace-normal"); expect(promptButtonClassName).toContain("w-full"); expect(promptButtonClassName).toContain("text-left"); expect(mergedPromptButtonClassName).toContain("whitespace-normal"); expect(mergedPromptButtonClassName).not.toContain("whitespace-nowrap"); }); test("desktop composer uses a two-column row when the page gives it full-width space", () => { 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("attachments panel is a first-class surfaced region, not an inline footer affordance", () => { const source = readToolbarSource(); expect(source).toContain("rounded-[10px] bg-card p-3 shadow-sm"); }); test("attachment previews stack one per row instead of using a two-column desktop grid", () => { 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+))/; expect(source).toContain('"mt-3 grid gap-2"'); expect(previewGridClassNames).toHaveLength(1); expect(previewGridClassNames[0]).toMatch(/\bmt-3\b/); expect(previewGridClassNames[0]).toMatch(/\bgrid\b/); expect(previewGridClassNames[0]).toMatch(/\bgap-2\b/); expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern); }); }); // ─── Cycle 6: Composer footer bar ──────────────────────────────────────────── // // Below the textarea sits a single horizontal footer row: // left → [📎 Attach image] (ghost, labeled) // right → [ℹ️ info] [✦ Generate] (ghost info, primary generate) // // "Below" means the textarea and its footer share a wrapping column (space-y-*), // not a side column. The footer is a flex row with justify-between so the two // sides never compete for vertical space with the textarea. /** Footer bar: horizontal row, left/right ends flush via justify-between */ const COMPOSER_FOOTER_CLASSES = "flex items-center justify-between gap-2"; /** Attach-image button: left side, labeled (has text, not icon-only) */ const ATTACH_BTN_CLASSES = "gap-1.5 text-xs"; /** Generate button: right side, primary variant, labeled */ const GENERATE_BTN_CLASSES = "gap-1.5 text-xs"; /** Info popover trigger: compact affordance that only renders in desktop branches */ const INFO_TRIGGER_CLASSES = "h-8 w-8 text-muted-foreground/70 hover:text-foreground"; describe("Composer footer bar – layout contract", () => { test("footer row uses justify-between so Attach sits left and Generate sits right", () => { const resolved = cn(COMPOSER_FOOTER_CLASSES); expect(resolved).toContain("justify-between"); }); test("footer row is flex so children sit on one horizontal line", () => { const resolved = cn(COMPOSER_FOOTER_CLASSES); expect(resolved).toContain("flex"); expect(resolved).toContain("items-center"); }); test("Attach button carries gap class so icon and label have breathing room", () => { const resolved = cn(ATTACH_BTN_CLASSES); expect(resolved).toMatch(/\bgap-[0-9.]+\b/); }); test("Generate button carries gap class so icon and label have breathing room", () => { const resolved = cn(GENERATE_BTN_CLASSES); expect(resolved).toMatch(/\bgap-[0-9.]+\b/); }); test("Attach and Generate both use text-xs so labels are visually subordinate to the textarea", () => { expect(cn(ATTACH_BTN_CLASSES)).toContain("text-xs"); expect(cn(GENERATE_BTN_CLASSES)).toContain("text-xs"); }); }); describe("Info popover trigger – size contract", () => { test("info trigger is guarded by useIsMobile so keyboard-only guidance stays out of touch layouts", () => { const source = readToolbarSource(); expect(source).toContain("!isMobile ? ("); }); test("info trigger stays visually secondary when rendered on desktop", () => { const resolved = cn(INFO_TRIGGER_CLASSES); expect(resolved).toContain("h-8"); expect(resolved).toContain("w-8"); }); }); // ─── Cycle 7: Keyboard shortcuts – delegated to keyboard-shortcuts.test.ts ─── // // Resolution logic (resolveKeys, SHORTCUT_DEFINITIONS, OS detection) is // tested exhaustively in tests/keyboard-shortcuts.test.ts. // These tests just verify the toolbar-level integration contract: // SHORTCUT_DEFINITIONS is imported and all entries are wired in. import { SHORTCUT_DEFINITIONS } from "@/lib/keyboard-shortcuts"; describe("Keyboard shortcuts – toolbar integration contract", () => { test("SHORTCUT_DEFINITIONS has at least one entry per required action", () => { const labels = SHORTCUT_DEFINITIONS.map((d) => d.label.toLowerCase()); expect(labels.some((l) => l.includes("generate"))).toBe(true); expect(labels.some((l) => l.includes("attach"))).toBe(true); expect(labels.some((l) => l.includes("clear"))).toBe(true); }); test("every definition has a non-empty modifiers array and label", () => { for (const def of SHORTCUT_DEFINITIONS) { expect(def.modifiers.length).toBeGreaterThan(0); expect(def.label.length).toBeGreaterThan(0); } }); }); // ─── Cycle 8: Multi-image thumbnail strip ──────────────────────────────────── // // When multiple images are attached, they render as a horizontal scrollable // strip of 64×64 thumbnails below the textarea. // // Contract: // - Strip wrapper: `flex` + `overflow-x-auto` so it scrolls horizontally // - Each thumbnail wrapper: `relative inline-block` so the X button can be // positioned absolutely on top // - Image itself: fixed 64×64, `object-cover` // - Remove button: `absolute`, positioned at top-right corner const IMAGE_STRIP_CLASSES = "flex gap-2 overflow-x-auto py-1"; const THUMBNAIL_WRAPPER_CLASSES = "relative inline-block shrink-0"; const THUMBNAIL_IMAGE_CLASSES = "h-16 w-16 rounded-md object-cover"; const THUMBNAIL_REMOVE_BTN_CLASSES = "absolute -top-1.5 -right-1.5"; describe("Multi-image strip – layout contract", () => { test("image strip wrapper uses flex layout for horizontal row", () => { const resolved = cn(IMAGE_STRIP_CLASSES); expect(resolved).toContain("flex"); }); test("image strip wrapper has overflow-x-auto for horizontal scroll when many images", () => { const resolved = cn(IMAGE_STRIP_CLASSES); expect(resolved).toContain("overflow-x-auto"); }); test("image strip wrapper has gap between thumbnails", () => { const resolved = cn(IMAGE_STRIP_CLASSES); expect(resolved).toMatch(/\bgap-[1-9]\d*\b/); }); test("thumbnail wrapper is relative+inline-block so the remove button can be positioned absolutely", () => { const resolved = cn(THUMBNAIL_WRAPPER_CLASSES); expect(resolved).toContain("relative"); expect(resolved).toContain("inline-block"); }); test("thumbnail wrapper does not shrink (shrink-0) so images keep their size in flex row", () => { const resolved = cn(THUMBNAIL_WRAPPER_CLASSES); expect(resolved).toContain("shrink-0"); }); test("thumbnail image has fixed 64×64 size (h-16 w-16)", () => { const resolved = cn(THUMBNAIL_IMAGE_CLASSES); expect(resolved).toContain("h-16"); expect(resolved).toContain("w-16"); }); test("thumbnail image uses object-cover so it crops without distortion", () => { const resolved = cn(THUMBNAIL_IMAGE_CLASSES); expect(resolved).toContain("object-cover"); }); test("remove button is positioned absolutely at top-right corner of the thumbnail", () => { const resolved = cn(THUMBNAIL_REMOVE_BTN_CLASSES); expect(resolved).toContain("absolute"); expect(resolved).toMatch(/-top-/); expect(resolved).toMatch(/-right-/); }); }); // ─── Cycle 5: Textarea AI prompt – spacing contract (existing behavior) ────── describe("AI textarea – prompt input spacing contract", () => { const TEXTAREA_BASE = "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"; const AI_TEXTAREA_OVERRIDE = "wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic"; test("AI prompt textarea retains horizontal padding after override merge", () => { const resolved = cn(TEXTAREA_BASE, AI_TEXTAREA_OVERRIDE); expect(resolved).not.toMatch(/\bpx-0\b/); expect(resolved).toMatch(/\bpx-[1-9]\d*\b/); }); test("AI prompt textarea retains vertical padding after override merge", () => { const resolved = cn(TEXTAREA_BASE, AI_TEXTAREA_OVERRIDE); expect(resolved).not.toMatch(/\bpy-0\b/); expect(resolved).toMatch(/\bpy-[1-9]\d*\b/); }); });