Files
lukaszraczylo edb655f590 feat(captcha): self-consistency voting, optimized prompts, no-targets signal
- Add Samples to TextOptions/GridOptions; N>1 runs N solves at temp 0.5
  and majority-votes (text: most frequent; grid: tiles in strict majority).
  N<=1 keeps the single deterministic call path unchanged.
- Add NoTargets to GridResult so callers know to click Verify instead of
  looping; prompt instructs empty tiles + no_targets:true when nothing matches.
- Grid prompt: numbered cells 0..R*C-1, select any partial overlap.
- Text prompt: disambiguate similar glyphs (0/O 1/l/I 5/S 2/Z 8/B).
- Rotation prompt: reason step-by-step then emit final JSON.
2026-06-07 17:37:18 +01:00

98 lines
2.7 KiB
Go

package captcha
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"testing"
"github.com/lukaszraczylo/captcha-solver-mcp/internal/llm"
)
// smallPNG returns a tiny valid PNG for exercising preprocessing paths.
func smallPNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 6, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 6; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 80, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestSolveGrid(t *testing.T) {
f := &llm.Fake{VisionResp: `{"tiles":[0,4,8],"confidence":0.8,"recheck_recommended":true}`}
res, err := SolveGrid(context.Background(), f, []byte{1}, "image/png", GridOptions{
Instruction: "select all buses", Rows: 3, Cols: 3, ImageWidth: 300, ImageHeight: 300,
})
if err != nil {
t.Fatal(err)
}
if len(res.Tiles) != 3 || res.Tiles[0] != 0 {
t.Fatalf("tiles %+v", res.Tiles)
}
if len(res.Centroids) != 3 || res.Centroids[1].X != 150 { // tile 4 centre
t.Fatalf("centroids %+v", res.Centroids)
}
if !res.RecheckRecommended {
t.Fatal("recheck flag lost")
}
}
func TestSolveGridVoting(t *testing.T) {
f := &llm.Fake{VisionResponses: []string{
`{"tiles":[0,1],"confidence":0.9}`,
`{"tiles":[0,2],"confidence":0.9}`,
`{"tiles":[0,1],"confidence":0.9}`,
}}
res, err := SolveGrid(context.Background(), f, []byte{1}, "image/png", GridOptions{
Instruction: "select all buses", Rows: 3, Cols: 3, Samples: 3,
})
if err != nil {
t.Fatal(err)
}
// 0 in 3/3, 1 in 2/3 (>half), 2 in 1/3 (dropped).
if len(res.Tiles) != 2 || res.Tiles[0] != 0 || res.Tiles[1] != 1 {
t.Fatalf("voted tiles %+v, want [0 1]", res.Tiles)
}
if len(f.VisionCalls) != 3 {
t.Fatalf("expected 3 vision calls, got %d", len(f.VisionCalls))
}
}
func TestSolveGridNoTargets(t *testing.T) {
f := &llm.Fake{VisionResp: `{"tiles":[],"confidence":0.9,"no_targets":true}`}
res, err := SolveGrid(context.Background(), f, []byte{1}, "image/png", GridOptions{
Instruction: "select all buses", Rows: 3, Cols: 3,
})
if err != nil {
t.Fatal(err)
}
if !res.NoTargets {
t.Fatal("no_targets flag lost")
}
if len(res.Tiles) != 0 {
t.Fatalf("expected empty tiles, got %+v", res.Tiles)
}
}
func TestSolveGridPreprocessing(t *testing.T) {
f := &llm.Fake{VisionResp: `{"tiles":[0,4],"confidence":0.8}`}
res, err := SolveGrid(context.Background(), f, smallPNG(t), "image/png", GridOptions{
Instruction: "select all buses", Rows: 3, Cols: 3, Annotate: true, Upscale: 2,
})
if err != nil {
t.Fatal(err)
}
if len(res.Tiles) != 2 {
t.Fatalf("expected 2 tiles, got %+v", res.Tiles)
}
}