package vision import ( "bytes" "context" "encoding/json" "errors" "image" "image/png" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/kami/maven/internal/media" ) func testImage(t *testing.T) media.Image { t.Helper() var buf bytes.Buffer if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 32, 32))); err != nil { t.Fatal(err) } im, err := media.PrepareImage(buf.Bytes(), "test", 32) if err != nil { t.Fatal(err) } return im } func TestDisabledAlwaysRefuses(t *testing.T) { _, err := Disabled{}.Describe(context.Background(), testImage(t), "что тут?") if !errors.Is(err, ErrDisabled) { t.Fatalf("got %v, want ErrDisabled", err) } } // The whole reason this package has its own HTTP client instead of reusing // internal/llm.Client: a vision endpoint that is not on this box is refused. func TestNewLocalRefusesNonPrivateEndpoints(t *testing.T) { bad := []string{ "https://api.openai.com", "http://8.8.8.8:8080", "https://vision.example.com", // a name could resolve anywhere "ftp://127.0.0.1:8080", // wrong scheme "", // nothing to talk to } for _, ep := range bad { if _, err := NewLocal(Config{Endpoint: ep}); err == nil { t.Errorf("NewLocal(%q) was accepted", ep) } } } func TestNewLocalAcceptsLocalEndpoints(t *testing.T) { for _, ep := range []string{"http://127.0.0.1:8081", "http://localhost:8081/", "http://192.168.1.104:8081", "http://[::1]:8081"} { p, err := NewLocal(Config{Endpoint: ep}) if err != nil { t.Errorf("NewLocal(%q): %v", ep, err) continue } if strings.HasSuffix(p.Endpoint(), "/") { t.Errorf("trailing slash kept: %q", p.Endpoint()) } } } func TestDescribeSendsImageAsDataURIAndReturnsText(t *testing.T) { var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/chat/completions" { t.Errorf("path = %s", r.URL.Path) } raw, _ := io.ReadAll(r.Body) if err := json.Unmarshal(raw, &gotBody); err != nil { t.Errorf("unmarshal request: %v", err) } _, _ = w.Write([]byte(`{"choices":[{"message":{"content":" На картинке кот "}}]}`)) })) defer srv.Close() p, err := NewLocal(Config{Endpoint: srv.URL, Model: "qwen-vl"}) if err != nil { t.Fatal(err) } text, err := p.Describe(context.Background(), testImage(t), "кто на фото?") if err != nil { t.Fatalf("describe: %v", err) } if text != "На картинке кот" { t.Errorf("text = %q (should be trimmed)", text) } msgs, ok := gotBody["messages"].([]any) if !ok || len(msgs) != 1 { t.Fatalf("messages = %#v", gotBody["messages"]) } parts, ok := msgs[0].(map[string]any)["content"].([]any) if !ok || len(parts) != 2 { t.Fatalf("content parts = %#v", msgs[0]) } if got := parts[0].(map[string]any)["text"]; got != "кто на фото?" { t.Errorf("prompt = %v", got) } url := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string) if !strings.HasPrefix(url, "data:image/jpeg;base64,") { t.Errorf("image not sent as a jpeg data uri: %.40s", url) } } func TestDescribeUsesDefaultPromptWhenNoQuestion(t *testing.T) { var sentPrompt string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Messages []struct { Content []struct { Text string `json:"text"` } `json:"content"` } `json:"messages"` } _ = json.NewDecoder(r.Body).Decode(&body) sentPrompt = body.Messages[0].Content[0].Text _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ок"}}]}`)) })) defer srv.Close() p, err := NewLocal(Config{Endpoint: srv.URL, Prompt: "Опиши по-русски."}) if err != nil { t.Fatal(err) } if _, err := p.Describe(context.Background(), testImage(t), " "); err != nil { t.Fatal(err) } if sentPrompt != "Опиши по-русски." { t.Errorf("prompt = %q", sentPrompt) } } // A Thinking model sometimes leaves content empty and puts the answer in // reasoning_content; internal/llm has the same fallback and vision needs it too. func TestDescribeFallsBackToReasoningContent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","reasoning_content":"схема платы"}}]}`)) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) text, err := p.Describe(context.Background(), testImage(t), "") if err != nil { t.Fatal(err) } if text != "схема платы" { t.Errorf("text = %q", text) } } func TestDescribeErrors(t *testing.T) { t.Run("no choices", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"choices":[]}`)) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) if _, err := p.Describe(context.Background(), testImage(t), ""); !errors.Is(err, ErrEmptyReply) { t.Errorf("got %v, want ErrEmptyReply", err) } }) t.Run("server error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) if _, err := p.Describe(context.Background(), testImage(t), ""); err == nil { t.Error("500 was not an error") } }) t.Run("empty image", func(t *testing.T) { p, _ := NewLocal(Config{Endpoint: "http://127.0.0.1:1"}) if _, err := p.Describe(context.Background(), media.Image{}, ""); !errors.Is(err, media.ErrEmpty) { t.Errorf("got %v, want media.ErrEmpty", err) } }) t.Run("context cancelled", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(200 * time.Millisecond) _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"поздно"}}]}`)) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() if _, err := p.Describe(ctx, testImage(t), ""); err == nil { t.Error("cancelled context returned no error") } }) }