src/bot/imageAnalysis.test.ts

import { test } from "bun:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { appConfig } from "../config.ts"; import { LLMService } from "../llm.ts"; import { MemoryManager } from "../memory/memoryManager.ts"; import { Store } from "../store/store.ts"; import { rmTempDir } from "../testHelpers.ts"; import type { BotContext } from "./botContext.ts"; import { captionRecentHistoryImages, extractHistoryImageCandidates, mergeImageInputs, rankImageLookupCandidates, runModelRequestedImageLookup, type ImageCaptionCacheLike } from "./imageAnalysis.ts";

async function withTempImageContext(run: (ctx: BotContext) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-bot-image-analysis-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();

const llm = new LLMService({ appConfig, store }); const memory = new MemoryManager({ store, llm, memoryFilePath: path.join(dir, "memory.md") }); const ctx: BotContext = { appConfig, store, llm, memory, client: { user: { id: "bot-1" }, guilds: { cache: new Map() } }, botUserId: "bot-1" };

try { await run(ctx); } finally { store.close(); await rmTempDir(dir); } }

test("extractHistoryImageCandidates keeps image URLs, skips excluded URLs, and enriches cached captions", () => { const imageCaptionCache: ImageCaptionCacheLike = { get(url) { if (url === "https://cdn.example.com/cat.png") { return { caption: "a sleeping cat" }; } return null; } };

const candidates = extractHistoryImageCandidates({ recentMessages: [ { message_id: "m1", author_name: "alice", created_at: "2026-03-06T12:00:00.000Z", content: "check this out https://cdn.example.com/cat.png https://cdn.example.com/skip.png https://example.com/readme.pdf" } ], excluded: new Set(["https://cdn.example.com/skip.png"]), imageCaptionCache });

assert.equal(candidates.length, 1); assert.equal(candidates[0]?.url, "https://cdn.example.com/cat.png"); assert.equal(candidates[0]?.imageRef, "IMG 1"); assert.equal(candidates[0]?.filename, "cat.png"); assert.equal(candidates[0]?.contentType, "image/png"); assert.equal(candidates[0]?.hasCachedCaption, true); assert.equal(candidates[0]?.context, "check this out https://cdn.example.com/skip.png https://example.com/readme.pdf [caption: a sleeping cat]"); });

test("rankImageLookupCandidates prefers phrase and token matches over recency fallback", () => { const ranked = rankImageLookupCandidates({ candidates: [ { url: "https://cdn.example.com/cat.png", context: "orange cat on the sofa", filename: "cat.png", authorName: "alice", recencyRank: 1 }, { url: "https://cdn.example.com/receipt.png", context: "grocery receipt on the table", filename: "receipt.png", authorName: "bob", recencyRank: 0 } ], query: "orange cat" });

assert.equal(ranked[0]?.url, "https://cdn.example.com/cat.png"); assert.equal(String(ranked[0]?.matchReason || "").includes("phrase match"), true); });

test("mergeImageInputs clamps and dedupes image inputs", () => { const merged = mergeImageInputs({ baseInputs: [ { url: "https://cdn.example.com/one.png", contentType: "image/png" }, { dataBase64: "aaa", mediaType: "image/png" } ], extraInputs: [ { url: "https://cdn.example.com/one.png", contentType: "image/png" }, { dataBase64: "aaa", mediaType: "image/png" }, { url: "https://cdn.example.com/two.png", contentType: "image/png" } ], maxInputs: 3 });

assert.deepEqual(merged, [ { url: "https://cdn.example.com/one.png", contentType: "image/png" }, { dataBase64: "aaa", mediaType: "image/png" }, { url: "https://cdn.example.com/two.png", contentType: "image/png" } ]); });

test("runModelRequestedImageLookup normalizes the query and selects matching history images", async () => { const lookup = await runModelRequestedImageLookup({ imageLookup: { enabled: true, candidates: [ { url: "https://cdn.example.com/cat.png", filename: "cat.png", contentType: "image/png", context: "orange cat on the sofa", authorName: "alice", recencyRank: 1 }, { url: "https://cdn.example.com/receipt.png", filename: "receipt.png", contentType: "image/png", context: "grocery receipt on the table", authorName: "bob", recencyRank: 0 } ] }, query: " orange cat " });

assert.equal(lookup.query, "orange cat"); assert.equal(lookup.used, true); assert.equal(lookup.results[0]?.url, "https://cdn.example.com/cat.png"); assert.deepEqual(lookup.selectedImageInputs[0], { url: "https://cdn.example.com/cat.png", filename: "cat.png", contentType: "image/png" }); });

test("runModelRequestedImageLookup resolves direct IMG refs", async () => { const lookup = await runModelRequestedImageLookup({ imageLookup: { enabled: true, candidates: [ { imageRef: "IMG 1", url: "https://cdn.example.com/cat.png", filename: "cat.png", contentType: "image/png", context: "orange cat on the sofa", authorName: "alice", recencyRank: 1 } ] }, query: "img 1" });

assert.equal(lookup.used, true); assert.equal(lookup.results[0]?.matchReason, "direct image ref"); assert.equal(lookup.selectedImageInputs[0]?.filename, "cat.png"); });

test("captionRecentHistoryImages respects hourly budget and skips inflight URLs", async () => { await withTempImageContext(async (ctx) => { const captionTimestamps = [Date.now() - 2 * 60 * 60 * 1000, Date.now() - 1000]; const scheduledUrls: string[] = []; const imageCaptionCache: ImageCaptionCacheLike = { hasOrInflight(url) { return url === "https://cdn.example.com/busy.png"; }, async getOrCaption(payload) { scheduledUrls.push(payload.url); return null; } };

captionRecentHistoryImages(ctx, {
  imageCaptionCache,
  captionTimestamps,
  settings: {
    vision: {
      maxCaptionsPerHour: 2
    }
  },
  candidates: [
    {
      url: "https://cdn.example.com/busy.png",
      contentType: "image/png"
    },
    {
      url: "https://cdn.example.com/free.png",
      contentType: "image/png"
    },
    {
      url: "https://cdn.example.com/free-2.png",
      contentType: "image/png"
    }
  ]
});

await Promise.resolve();

assert.deepEqual(scheduledUrls, ["https://cdn.example.com/free.png"]);
assert.equal(captionTimestamps.length, 2);

}); });