diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index b0f1769..aa2e4f6 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -2,57 +2,6 @@ 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"; -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"); @@ -166,6 +115,7 @@ beforeEach(() => { afterEach(() => { delete (globalThis as { document?: Document }).document; + delete (globalThis as { navigator?: Navigator }).navigator; lastEffectCleanup = undefined; mock.restore(); }); @@ -176,7 +126,19 @@ const renderToolbar = async (overrides: Partial = {}) => { AIToolbar(createToolbarProps(overrides)); }; -const renderToolbarBoundary = async (overrides: Partial = {}) => { +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", () => ({ @@ -197,124 +159,106 @@ const renderToolbarBoundary = async (overrides: Partial = {}) => return capturedTextareaProps!; }; -const getExamplePromptButtonClassName = () => { - const source = readToolbarSource(); - const match = source.match( - / { + test("desktop composer uses a 70/30 split driven by the isMobile branch", () => { + const source = readToolbarSource(); - expect(match).not.toBeNull(); - - return match![1]; -}; - -// ─── 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/); + 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("AI zone wrapper has a subtle primary background tint", () => { - const resolved = cn(AI_ZONE_CLASSES); - expect(resolved).toMatch(/bg-primary/); + 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("AI zone wrapper has rounded corners consistent with card radius", () => { - const resolved = cn(AI_ZONE_CLASSES); - expect(resolved).toMatch(/rounded/); + 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); }); }); -// ─── Cycle 1: Locked CTA (unauthenticated) ────────────────────────────────── +describe("AI toolbar state rendering", () => { + test("unauthenticated users see the sign-in-required callout", async () => { + const markup = await renderToolbarMarkup({ isAuthenticated: false }); -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"); + 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("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("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("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("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("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"); + 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 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("textarea paste path still forwards clipboard images to onImagesSelect through the component boundary", async () => { +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", @@ -333,7 +277,7 @@ describe("AI capture redesign", () => { expect(onImagesSelect).toHaveBeenCalledWith([image]); }); - test("textarea-targeted paste bypasses the document listener and is handled only by the textarea path", async () => { + 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", @@ -376,25 +320,7 @@ describe("AI capture redesign", () => { expect(onImagesSelect).toHaveBeenCalledTimes(1); }); - test("capture-phase document paste ignores textarea targets before reading clipboard data", async () => { - await renderToolbar(); - - const handleCapturePaste = getDocumentListener( - "paste", - (entry) => entry.options?.capture === true, - ); - - expect(() => - handleCapturePaste({ - target: { tagName: "TEXTAREA", isContentEditable: false }, - get clipboardData() { - throw new Error("editable paste should stay on the component path"); - }, - } as unknown as Event), - ).not.toThrow(); - }); - - test("document paste forwards clipboard images to onImagesSelect only for non-editable targets", async () => { + 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", @@ -419,31 +345,7 @@ describe("AI capture redesign", () => { expect(onImagesSelect).toHaveBeenCalledWith([image]); }); - test("document paste ignores contenteditable targets so the focused editor owns the paste", 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: true }, - clipboardData: createClipboardData([image]), - preventDefault, - } as unknown as Event); - - expect(preventDefault).not.toHaveBeenCalled(); - expect(onImagesSelect).not.toHaveBeenCalled(); - }); - - test("Ctrl/Cmd+V fallback forwards clipboard images to onImagesSelect for non-editable targets", async () => { + test("Ctrl/Cmd+V fallback forwards clipboard images for non-editable targets", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ { @@ -476,31 +378,6 @@ describe("AI capture redesign", () => { expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); }); - test("Ctrl/Cmd+V fallback ignores contenteditable 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: "DIV", isContentEditable: true }, - } 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(); @@ -537,7 +414,9 @@ describe("AI capture redesign", () => { ); }); - test("global paste listeners stay disarmed when AI is unavailable or loading", async () => { + 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 }); @@ -545,240 +424,4 @@ describe("AI capture redesign", () => { 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/); - }); });