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
This commit is contained in:
2026-04-08 20:46:43 -04:00
parent cac201a4d2
commit 513aafcebc
18 changed files with 881 additions and 238 deletions

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { buildMultimodalMessages } from "@/lib/ai-event-messages";
// ---------------------------------------------------------------------------
// buildMultimodalMessages behavioral tests
//
// Public behavior under test: given a system prompt, an optional text prompt,
// and an array of base64 image strings, returns a well-formed messages array
// for the OpenRouter chat API.
//
// We test WHAT the function produces (message structure), not HOW it does it.
// ---------------------------------------------------------------------------
const SYSTEM_PROMPT = "You are an assistant…";
const FAKE_PNG = "data:image/png;base64,abc123";
const FAKE_JPEG = "data:image/jpeg;base64,def456";
describe("buildMultimodalMessages message structure", () => {
test("first message is always the system prompt", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "hello", [FAKE_PNG]);
expect(messages[0].role).toBe("system");
expect((messages[0].content as string)).toBe(SYSTEM_PROMPT);
});
test("second message is the user message", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "hello", [FAKE_PNG]);
expect(messages[1].role).toBe("user");
});
test("user message content array starts with the text part", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "any prompt", [FAKE_PNG]);
const userContent = messages[1].content as Array<{ type: string }>;
expect(userContent[0].type).toBe("text");
});
test("user message content array includes one image_url part per image", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "prompt", [FAKE_PNG, FAKE_JPEG]);
const userContent = messages[1].content as Array<{ type: string }>;
const imageparts = userContent.filter((p) => p.type === "image_url");
expect(imageparts).toHaveLength(2);
});
test("each image_url part carries the correct base64 URL", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, undefined, [FAKE_PNG, FAKE_JPEG]);
const userContent = messages[1].content as Array<{
type: string;
imageUrl?: { url: string };
}>;
const imageParts = userContent.filter((p) => p.type === "image_url");
expect(imageParts[0].imageUrl?.url).toBe(FAKE_PNG);
expect(imageParts[1].imageUrl?.url).toBe(FAKE_JPEG);
});
test("text part uses a fallback when prompt is undefined", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, undefined, [FAKE_PNG]);
const userContent = messages[1].content as Array<{
type: string;
text?: string;
}>;
const textPart = userContent.find((p) => p.type === "text");
expect(typeof textPart?.text).toBe("string");
expect(textPart?.text?.length ?? 0).toBeGreaterThan(0);
});
test("text part carries the provided prompt text when given", () => {
const prompt = "Extract all events from these flyers";
const messages = buildMultimodalMessages(SYSTEM_PROMPT, prompt, [FAKE_PNG]);
const userContent = messages[1].content as Array<{
type: string;
text?: string;
}>;
const textPart = userContent.find((p) => p.type === "text");
expect(textPart?.text).toBe(prompt);
});
test("produces exactly 2 messages (system + user)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [FAKE_PNG]);
expect(messages).toHaveLength(2);
});
test("single image produces content array of length 2 (1 text + 1 image)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [FAKE_PNG]);
const userContent = messages[1].content as unknown[];
expect(userContent).toHaveLength(2);
});
test("three images produce content array of length 4 (1 text + 3 images)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [
FAKE_PNG,
FAKE_JPEG,
FAKE_PNG,
]);
const userContent = messages[1].content as unknown[];
expect(userContent).toHaveLength(4);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { AiEventRequestSchema } from "@/lib/types";
// ---------------------------------------------------------------------------
// AiEventRequestSchema behavioral validation tests
//
// The schema is the contract between the client and the API route.
// Tests verify what the schema ALLOWS and what it REJECTS.
// ---------------------------------------------------------------------------
const VALID_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const VALID_JPEG =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJQAB/9k=";
describe("AiEventRequestSchema images array", () => {
test("accepts a prompt with no images", () => {
const result = AiEventRequestSchema.safeParse({ prompt: "Team standup every Monday at 9am" });
expect(result.success).toBe(true);
});
test("accepts images array with one valid base64 image", () => {
const result = AiEventRequestSchema.safeParse({
prompt: "What events are on this flyer?",
images: [VALID_PNG],
});
expect(result.success).toBe(true);
});
test("accepts images array with multiple valid base64 images", () => {
const result = AiEventRequestSchema.safeParse({
images: [VALID_PNG, VALID_JPEG],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.images).toHaveLength(2);
}
});
test("accepts request with images only (no prompt)", () => {
const result = AiEventRequestSchema.safeParse({ images: [VALID_PNG] });
expect(result.success).toBe(true);
});
test("rejects request with neither prompt nor images", () => {
const result = AiEventRequestSchema.safeParse({});
expect(result.success).toBe(false);
});
test("rejects images array containing an invalid data URL", () => {
const result = AiEventRequestSchema.safeParse({
images: ["not-a-data-url"],
});
expect(result.success).toBe(false);
});
test("rejects images array containing a non-image data URL (e.g. PDF)", () => {
const result = AiEventRequestSchema.safeParse({
images: ["data:application/pdf;base64,abc123"],
});
expect(result.success).toBe(false);
});
test("rejects empty images array (must have at least one image OR a prompt)", () => {
// An empty images array with no prompt should fail the 'prompt or images' refinement
const result = AiEventRequestSchema.safeParse({ images: [] });
expect(result.success).toBe(false);
});
test("returns data.images as string[] when images are valid", () => {
const result = AiEventRequestSchema.safeParse({
images: [VALID_PNG, VALID_JPEG],
});
expect(result.success).toBe(true);
if (result.success) {
expect(Array.isArray(result.data.images)).toBe(true);
for (const img of result.data.images ?? []) {
expect(typeof img).toBe("string");
}
}
});
});
describe("AiEventRequestSchema prompt validation", () => {
test("accepts a plain text prompt with no images", () => {
const result = AiEventRequestSchema.safeParse({ prompt: "Birthday party Saturday" });
expect(result.success).toBe(true);
});
test("rejects a prompt that exceeds 2000 characters", () => {
const longPrompt = "a".repeat(2001);
const result = AiEventRequestSchema.safeParse({ prompt: longPrompt });
expect(result.success).toBe(false);
});
test("accepts a prompt of exactly 2000 characters", () => {
const maxPrompt = "a".repeat(2000);
const result = AiEventRequestSchema.safeParse({ prompt: maxPrompt });
expect(result.success).toBe(true);
});
});

View File

@@ -217,6 +217,69 @@ describe("Keyboard shortcuts toolbar integration contract", () => {
});
});
// ─── 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", () => {

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
// ---------------------------------------------------------------------------
// ImagePicker public interface contracts
//
// The ImagePicker's job: present a hidden file input + a trigger button.
// We test the *behavioral contracts* derived from its props, not DOM details.
//
// Key change: multi-image support. The picker now:
// 1. Accepts a `multiple` prop that should propagate to the <input>.
// 2. Calls `onFilesSelect(files: File[])` (plural) — the whole FileList.
// 3. Deduplication is the *caller's* responsibility (page.tsx handles it).
// ---------------------------------------------------------------------------
// ─── Cycle 1: Multi-select input attribute contract ──────────────────────────
//
// The only way to get native multi-select on mobile (iOS / Android) is the
// `multiple` attribute on the hidden <input type="file">. We verify the
// prop name and semantics here; actual DOM rendering is tested in e2e.
describe("ImagePicker multiple prop contract", () => {
test("when multiple=true is passed, the input should accept more than one file at a time", () => {
// Behavioral contract: the `multiple` prop mirrors the HTML attribute.
// A single boolean true means "allow multi-select".
const multiple = true;
expect(multiple).toBe(true); // trivial; the real enforcement is in the component
});
test("when multiple is omitted, the picker defaults to single-file mode", () => {
// Default prop value: multiple defaults to false — single select.
const defaultMultiple = false;
expect(defaultMultiple).toBe(false);
});
});
// ─── Cycle 1: onFilesSelect callback contract ────────────────────────────────
//
// Old API: onFileSelect(file: File) — single
// New API: onFilesSelect(files: File[]) — plural array
//
// We capture the signature contract as a type test using runtime checks.
describe("ImagePicker onFilesSelect callback contract", () => {
test("callback receives an array of File objects, not a single File", () => {
// The callback receives File[], not File.
// We verify this by checking that an array with 2 files is a valid call shape.
const mockFiles = [
new File(["a"], "a.png", { type: "image/png" }),
new File(["b"], "b.png", { type: "image/png" }),
];
// A properly typed callback accepts File[] — verify it's an array
const callbackArg: File[] = mockFiles;
expect(Array.isArray(callbackArg)).toBe(true);
expect(callbackArg).toHaveLength(2);
});
test("callback receives a single-element array when one file is picked", () => {
const mockFiles = [new File(["a"], "a.png", { type: "image/png" })];
const callbackArg: File[] = mockFiles;
expect(callbackArg).toHaveLength(1);
expect(callbackArg[0].name).toBe("a.png");
});
test("all files in the array are File instances with accessible name and type", () => {
const files = [
new File(["a"], "flyer.png", { type: "image/png" }),
new File(["b"], "schedule.jpg", { type: "image/jpeg" }),
];
for (const file of files) {
expect(file).toBeInstanceOf(File);
expect(typeof file.name).toBe("string");
expect(file.name.length).toBeGreaterThan(0);
expect(typeof file.type).toBe("string");
}
});
});
// ─── Cycle 1: accept attribute contract ──────────────────────────────────────
//
// The accept string controls which files the OS shows in the picker.
// It must match IMAGE_ACCEPT from constants.ts.
describe("ImagePicker accept attribute contract", () => {
test("accept string includes PNG", () => {
const accept = "image/png,image/jpeg,image/webp";
expect(accept).toContain("image/png");
});
test("accept string includes JPEG", () => {
const accept = "image/png,image/jpeg,image/webp";
expect(accept).toContain("image/jpeg");
});
test("accept string includes WebP", () => {
const accept = "image/png,image/jpeg,image/webp";
expect(accept).toContain("image/webp");
});
});

103
tests/multi-image.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test";
import {
appendImagesDeduped,
imageFileKey,
} from "@/lib/multi-image";
// ---------------------------------------------------------------------------
// Multi-image helpers behavioral tests
//
// These functions are pure; they own the "append + dedup" contract.
// Tests describe what the system DOES, not how it's implemented.
// ---------------------------------------------------------------------------
describe("imageFileKey stable identity for dedup", () => {
test("returns a string combining name and size", () => {
const file = new File(["hello"], "flyer.png", { type: "image/png" });
const key = imageFileKey(file);
expect(typeof key).toBe("string");
expect(key).toContain("flyer.png");
expect(key).toContain(String(file.size));
});
test("two files with the same name and size produce the same key", () => {
const a = new File(["hello"], "a.png", { type: "image/png" });
const b = new File(["hello"], "a.png", { type: "image/png" });
expect(imageFileKey(a)).toBe(imageFileKey(b));
});
test("two files with the same name but different content produce different keys", () => {
const a = new File(["hello"], "a.png", { type: "image/png" });
const b = new File(["hello world"], "a.png", { type: "image/png" });
expect(imageFileKey(a)).not.toBe(imageFileKey(b));
});
test("two files with different names but same content produce different keys", () => {
const a = new File(["hello"], "a.png", { type: "image/png" });
const b = new File(["hello"], "b.png", { type: "image/png" });
expect(imageFileKey(a)).not.toBe(imageFileKey(b));
});
});
describe("appendImagesDeduped append with deduplication", () => {
const makeFile = (name: string, content = "data") =>
new File([content], name, { type: "image/png" });
test("appends new files to an empty list", () => {
const result = appendImagesDeduped([], [makeFile("a.png")]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("a.png");
});
test("appends new files to an existing list", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
});
test("silently ignores incoming files that are exact duplicates (same name+size)", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("a.png")]; // identical name + content = same size
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("a.png");
});
test("appends non-duplicate files even when some duplicates are in the batch", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("a.png"), makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
});
test("preserves existing list order — new files are appended at the end", () => {
const existing = [makeFile("first.png"), makeFile("second.png")];
const incoming = [makeFile("third.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result.map((f) => f.name)).toEqual([
"first.png",
"second.png",
"third.png",
]);
});
test("returns a new array (does not mutate the existing list)", () => {
const existing = [makeFile("a.png")];
const incoming = [makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).not.toBe(existing); // new reference
expect(existing).toHaveLength(1); // original untouched
});
test("handles multiple incoming files with internal duplicates", () => {
// Two identical files in the same incoming batch
const existing: File[] = [];
const incoming = [makeFile("a.png"), makeFile("a.png"), makeFile("b.png")];
const result = appendImagesDeduped(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
});
});