Files
local-cal/tests/ai-toolbar.test.ts
Dmytro Stanchiev 513aafcebc feat: support multiple image uploads for AI event generation 🖼️
- Updated OpenRouter integration to accept an array of image URLs
- Updated ImagePicker to use the `multiple` attribute natively
- Added `appendImagesDeduped` for handling client-side image deduplication
- Enhanced clipboard pasting to extract multiple images at once
- Rendered multiple images in a horizontal thumbnail strip in the AIToolbar
- Added tests to cover multi-image logic and AI request mapping
2026-04-08 20:46:43 -04:00

304 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: ghost icon button, sits left of Generate */
const INFO_TRIGGER_CLASSES = "h-6 w-6";
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 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/);
});
});