import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __resetLocalStorage } from "../__mocks__/@vicinae/api"; import { forgetVisionSupport, visionSupport } from "../lib/vision"; /** * The capability check decides whether a snip feature is offered at all, so the * cases that matter are the ones where the answer is not a clean yes or no: a * dead network must not be mistaken for "this model cannot see". */ const fetchMock = vi.fn(); function showResponse(capabilities: string[]): Response { return new Response(JSON.stringify({ capabilities }), { status: 200, headers: { "Content-Type": "application/json" }, }); } beforeEach(() => { __resetLocalStorage(); fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); const base = { host: "https://ollama.com", apiKey: "k", model: "gemma4" }; describe("visionSupport", () => { it("reports yes when Ollama lists the vision capability", async () => { fetchMock.mockResolvedValueOnce(showResponse(["completion", "vision"])); await expect(visionSupport(base)).resolves.toBe("yes"); }); it("reports no for a text-only model", async () => { fetchMock.mockResolvedValueOnce(showResponse(["completion", "tools"])); await expect(visionSupport(base)).resolves.toBe("no"); }); it("asks /api/show for the model", async () => { fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await visionSupport(base); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("https://ollama.com/api/show"); expect(JSON.parse(init.body)).toEqual({ model: "gemma4" }); }); it("serves a repeat question from the cache", async () => { fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await visionSupport(base); await expect(visionSupport(base)).resolves.toBe("yes"); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("caches a negative too, so a text model is not re-checked on every mount", async () => { fetchMock.mockResolvedValueOnce(showResponse(["tools"])); await visionSupport(base); await expect(visionSupport(base)).resolves.toBe("no"); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("keys the cache per model, so switching model re-checks", async () => { fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await visionSupport(base); fetchMock.mockResolvedValueOnce(showResponse(["tools"])); await expect(visionSupport({ ...base, model: "gpt-oss:120b" })).resolves.toBe("no"); expect(fetchMock).toHaveBeenCalledTimes(2); }); it("re-checks when explicitly asked to, even with a cache entry", async () => { fetchMock.mockResolvedValueOnce(showResponse(["tools"])); await visionSupport(base); fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await expect(visionSupport({ ...base, refresh: true })).resolves.toBe("yes"); }); it("reports unknown rather than no when the request fails", async () => { // A dead network is not evidence that a model cannot see, and treating it as // such would hide the feature for a reason the user cannot see. fetchMock.mockRejectedValueOnce(new Error("offline")); await expect(visionSupport(base)).resolves.toBe("unknown"); }); it("does not cache a failure", async () => { fetchMock.mockRejectedValueOnce(new Error("offline")); await visionSupport(base); fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await expect(visionSupport(base)).resolves.toBe("yes"); expect(fetchMock).toHaveBeenCalledTimes(2); }); it("reports a rejected key as unknown rather than crashing", async () => { fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 })); await expect(visionSupport(base)).resolves.toBe("unknown"); }); }); describe("forgetVisionSupport", () => { it("drops every cached answer, so a re-pulled model is not pinned to no", async () => { fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await visionSupport(base); await forgetVisionSupport(); fetchMock.mockResolvedValueOnce(showResponse(["vision"])); await visionSupport(base); expect(fetchMock).toHaveBeenCalledTimes(2); }); });