src/vision/imageCaptionCache.test.ts

import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { ImageCaptionCache } from "./imageCaptionCache.ts";

// --- Basic cache behavior ---

describe("ImageCaptionCache", () => { test("get returns null for uncached url", () => { const cache = new ImageCaptionCache(); assert.equal(cache.get("https://example.com/img.jpg"), null); });

test("has returns false for uncached url", () => {
    const cache = new ImageCaptionCache();
    assert.equal(cache.has("https://example.com/img.jpg"), false);
});

test("set and get round-trip", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://example.com/cat.jpg", "An orange cat", "anthropic", "claude-haiku-4-5");

    const result = cache.get("https://example.com/cat.jpg");
    assert.ok(result);
    assert.equal(result.caption, "An orange cat");
    assert.equal(result.provider, "anthropic");
    assert.equal(result.model, "claude-haiku-4-5");
    assert.ok(result.captionedAt > 0);
});

test("has returns true after set", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://example.com/cat.jpg", "A cat");
    assert.equal(cache.has("https://example.com/cat.jpg"), true);
});

test("normalizes URLs by stripping query params", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://cdn.discord.com/attachments/123/img.jpg?token=abc123", "A dog");

    // Same path, different token
    const result = cache.get("https://cdn.discord.com/attachments/123/img.jpg?token=xyz789");
    assert.ok(result);
    assert.equal(result.caption, "A dog");
});

test("normalizes URLs case-insensitively", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://Example.COM/Image.JPG", "An image");

    const result = cache.get("https://example.com/image.jpg");
    assert.ok(result);
    assert.equal(result.caption, "An image");
});

test("returns null for empty/null urls", () => {
    const cache = new ImageCaptionCache();
    assert.equal(cache.get(""), null);
    assert.equal(cache.get(null), null);
    assert.equal(cache.has(""), false);
});

test("set ignores empty caption", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://example.com/img.jpg", "");
    assert.equal(cache.has("https://example.com/img.jpg"), false);
});

test("size tracks number of entries", () => {
    const cache = new ImageCaptionCache();
    assert.equal(cache.size, 0);
    cache.set("https://example.com/a.jpg", "Image A");
    assert.equal(cache.size, 1);
    cache.set("https://example.com/b.jpg", "Image B");
    assert.equal(cache.size, 2);
});

test("clear removes all entries", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://example.com/a.jpg", "Image A");
    cache.set("https://example.com/b.jpg", "Image B");
    cache.clear();
    assert.equal(cache.size, 0);
    assert.equal(cache.get("https://example.com/a.jpg"), null);
});

});

// --- Eviction ---

describe("ImageCaptionCache eviction", () => { test("evict removes entries older than TTL", () => { const cache = new ImageCaptionCache({ defaultTtlMs: 100 });

    // Insert an entry and force its timestamp to be very old
    cache.set("https://example.com/old.jpg", "Old image");
    const entry = cache.get("https://example.com/old.jpg");
    assert.ok(entry, "entry should exist after set");
    entry.captionedAt = Date.now() - 10_000; // 10s ago — well past 100ms TTL

    // Verify the mutation is visible through get()
    const verify = cache.get("https://example.com/old.jpg");
    assert.ok(verify.captionedAt < Date.now() - 5_000, "timestamp mutation should be visible");

    cache.set("https://example.com/new.jpg", "New image");

    const evicted = cache.evict();
    assert.equal(evicted, 1);
    assert.equal(cache.get("https://example.com/old.jpg"), null);
    assert.ok(cache.get("https://example.com/new.jpg"));
});

test("enforces max entries with LRU eviction", () => {
    const cache = new ImageCaptionCache({ maxEntries: 12 });

    for (let i = 0; i < 15; i++) {
        cache.set(`https://example.com/${i}.jpg`, `Image ${i}`);
    }

    // Should have evicted some entries
    assert.ok(cache.size <= 12);
    // Most recent entries should still be present
    assert.ok(cache.has("https://example.com/14.jpg"));
});

});

// --- getOrCaption ---

describe("ImageCaptionCache getOrCaption", () => { test("returns cached entry on hit", async () => { const cache = new ImageCaptionCache(); cache.set("https://example.com/cat.jpg", "A cat sitting");

    const llm = {
        isProviderConfigured: () => true,
        generate: async () => {
            throw new Error("Should not be called");
        }
    };

    const result = await cache.getOrCaption({
        url: "https://example.com/cat.jpg",
        llm
    });

    assert.ok(result);
    assert.equal(result.caption, "A cat sitting");
});

test("generates and caches on miss", async () => {
    const cache = new ImageCaptionCache();
    let generateCalls = 0;

    const llm = {
        isProviderConfigured: () => true,
        generate: async () => {
            generateCalls++;
            return {
                text: "A fiery sunset over mountains",
                provider: "anthropic",
                model: "claude-haiku-4-5"
            };
        }
    };

    const result = await cache.getOrCaption({
        url: "https://example.com/sunset.jpg",
        llm
    });

    assert.ok(result);
    assert.equal(result.caption, "A fiery sunset over mountains");
    assert.equal(generateCalls, 1);

    // Second call should use cached value
    const result2 = await cache.getOrCaption({
        url: "https://example.com/sunset.jpg",
        llm
    });
    assert.equal(result2.caption, "A fiery sunset over mountains");
    assert.equal(generateCalls, 1); // Still 1 — no new call
});

test("coalesces concurrent requests for the same URL", async () => {
    const cache = new ImageCaptionCache();
    let generateCalls = 0;

    const llm = {
        isProviderConfigured: () => true,
        generate: async () => {
            generateCalls++;
            // Small delay to simulate API call
            await new Promise((resolve) => setTimeout(resolve, 50));
            return {
                text: "A red sports car",
                provider: "anthropic",
                model: "claude-haiku-4-5"
            };
        }
    };

    // Fire two requests concurrently
    const [r1, r2] = await Promise.all([
        cache.getOrCaption({ url: "https://example.com/car.jpg", llm }),
        cache.getOrCaption({ url: "https://example.com/car.jpg", llm })
    ]);

    assert.equal(generateCalls, 1); // Only one API call fired
    assert.equal(r1?.caption, "A red sports car");
    assert.equal(r2?.caption, "A red sports car");
});

test("returns null when captioning fails", async () => {
    const cache = new ImageCaptionCache();

    const llm = {
        isProviderConfigured: () => true,
        generate: async () => ({ text: "" })
    };

    const result = await cache.getOrCaption({
        url: "https://example.com/broken.jpg",
        llm
    });

    assert.equal(result, null);
    // Should not cache a failed caption
    assert.equal(cache.has("https://example.com/broken.jpg"), false);
});

test("returns null for empty url", async () => {
    const cache = new ImageCaptionCache();
    const llm = {
        isProviderConfigured: () => true,
        generate: async () => ({ text: "nope" })
    };

    assert.equal(await cache.getOrCaption({ url: "", llm }), null);
});

});

// --- hasOrInflight ---

describe("ImageCaptionCache hasOrInflight", () => { test("returns false for unknown URL", () => { const cache = new ImageCaptionCache(); assert.equal(cache.hasOrInflight("https://example.com/unknown.jpg"), false); });

test("returns true for cached URL", () => {
    const cache = new ImageCaptionCache();
    cache.set("https://example.com/cached.jpg", "A cached image");
    assert.equal(cache.hasOrInflight("https://example.com/cached.jpg"), true);
});

test("returns true while caption is in-flight", async () => {
    const cache = new ImageCaptionCache();
    let resolveGenerate;
    const llm = {
        isProviderConfigured: () => true,
        generate: () => new Promise((resolve) => {
            resolveGenerate = resolve;
        })
    };

    // Start captioning but don't await — it's now in-flight
    const captionPromise = cache.getOrCaption({
        url: "https://example.com/inflight.jpg",
        llm
    });

    // While in-flight, hasOrInflight should return true
    assert.equal(cache.hasOrInflight("https://example.com/inflight.jpg"), true);
    // But has() should still return false (not yet cached)
    assert.equal(cache.has("https://example.com/inflight.jpg"), false);

    // Resolve and clean up
    resolveGenerate({ text: "Done", provider: "anthropic", model: "claude-haiku-4-5" });
    await captionPromise;

    // Now both should be true (cached)
    assert.equal(cache.hasOrInflight("https://example.com/inflight.jpg"), true);
    assert.equal(cache.has("https://example.com/inflight.jpg"), true);
});

});