import { describe, expect, test } from "bun:test"; 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"; /** 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"; // ─── Cycle 1: AI zone visual accent ───────────────────────────────────────── describe("AI zone – primary accent ring contract", () => { 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"); }); }); // ─── 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"); }); }); // ─── 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: hidden on mobile, small on desktop so it stays secondary */ const INFO_TRIGGER_CLASSES = "hidden h-6 w-6 md:inline-flex"; 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 hidden on mobile so keyboard-only guidance does not appear in touch layouts", () => { const resolved = cn(INFO_TRIGGER_CLASSES); expect(resolved).toContain("hidden"); expect(resolved).toContain("md:inline-flex"); }); test("info trigger is small (h-6 w-6) so it doesn't compete with Generate", () => { const resolved = cn(INFO_TRIGGER_CLASSES); expect(resolved).toContain("h-6"); expect(resolved).toContain("w-6"); }); }); // ─── 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 md:text-sm"; 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/); }); });