724 lines
26 KiB
TypeScript
724 lines
26 KiB
TypeScript
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");
|
||
|
||
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> = {},
|
||
): 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: <T,>(initialValue: T) => ({ current: initialValue }),
|
||
useState: <T,>(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<AIToolbarProps> = {}) => {
|
||
const { AIToolbar } = await import("@/components/ai-toolbar");
|
||
|
||
AIToolbar(createToolbarProps(overrides));
|
||
};
|
||
|
||
const renderToolbarBoundary = async (overrides: Partial<AIToolbarProps> = {}) => {
|
||
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
|
||
|
||
mock.module("@/components/ui/textarea", () => ({
|
||
Textarea: (props: React.ComponentProps<"textarea">) => {
|
||
capturedTextareaProps = props;
|
||
return actualReact.createElement("textarea", props);
|
||
},
|
||
}));
|
||
|
||
const { AIToolbar } = await import("@/components/ai-toolbar");
|
||
|
||
renderToStaticMarkup(
|
||
actualReact.createElement(AIToolbar, createToolbarProps(overrides)),
|
||
);
|
||
|
||
expect(capturedTextareaProps).toBeDefined();
|
||
|
||
return capturedTextareaProps!;
|
||
};
|
||
|
||
const getExamplePromptButtonClassName = () => {
|
||
const source = readToolbarSource();
|
||
const match = source.match(
|
||
/<Button\s+type="button"\s+variant="secondary"\s+size="sm"\s+className="([^"]*\bwhitespace-normal\b[^"]*)"/,
|
||
);
|
||
|
||
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/);
|
||
});
|
||
|
||
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("textarea paste path still forwards clipboard images to onImagesSelect through the component boundary", async () => {
|
||
const onImagesSelect = mock();
|
||
const image = new File(["image-bytes"], "clipboard.png", {
|
||
type: "image/png",
|
||
});
|
||
|
||
const textareaProps = await renderToolbarBoundary({ onImagesSelect });
|
||
const preventDefault = mock();
|
||
|
||
textareaProps.onPaste?.({
|
||
clipboardData: createClipboardData([image]),
|
||
preventDefault,
|
||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
|
||
|
||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||
expect(onImagesSelect).toHaveBeenCalledTimes(1);
|
||
expect(onImagesSelect).toHaveBeenCalledWith([image]);
|
||
});
|
||
|
||
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("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 () => {
|
||
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 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();
|
||
|
||
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/);
|
||
});
|
||
});
|