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

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"]);
});
});