package vision import ( "bytes" "context" "image" "image/jpeg" "testing" "gitea.stevedudenhoeffer.com/steve/majordomo" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake" ) // tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before // the provider, which checks the image's magic bytes, so a string literal won't // do — the bytes must actually be a JPEG. func tinyJPEG(t *testing.T) []byte { t.Helper() var b bytes.Buffer if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil { t.Fatalf("encode jpeg: %v", err) } return b.Bytes() } // extractVia is Extract with a caller-supplied registry, so the test can point it // at a fake provider instead of resolving a live model. It mirrors Extract's body // exactly apart from where the model comes from. func extractVia(t *testing.T, m llm.Model, jpeg []byte) (SeedPacket, error) { t.Helper() return majordomo.Generate[SeedPacket](context.Background(), m, majordomo.Request{ Messages: []majordomo.Message{ majordomo.UserParts(majordomo.Text(extractPrompt), majordomo.Image("image/jpeg", jpeg)), }, }) } // TestExtractParsesModelJSON is the hermetic proof that the extraction path works // end to end without a live model: a fake vision model returns canned packet JSON // and Generate[SeedPacket] unmarshals it into the struct, image and schema // included. func TestExtractParsesModelJSON(t *testing.T) { reg := majordomo.New(majordomo.WithoutEnvProviders()) fp := fake.New("fp") // default caps advertise structured output + images reg.RegisterProvider(fp) fp.Enqueue("vision", fake.Reply(`{ "species": "garlic", "variety": "Music", "category": "vegetable", "vendor": "Johnny's", "sku": "2761", "lotCode": "L-42", "packedForYear": 2026, "daysToMaturity": 240, "spacingCm": 15, "seedCount": 8 }`)) m, err := reg.Parse("fp/vision") if err != nil { t.Fatalf("parse: %v", err) } got, err := extractVia(t, m, tinyJPEG(t)) if err != nil { t.Fatalf("extract: %v", err) } if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" { t.Errorf("unexpected packet: %+v", got) } if got.SpacingCM == nil || *got.SpacingCM != 15 { t.Errorf("spacingCm = %v, want 15", got.SpacingCM) } if got.PackedForYear == nil || *got.PackedForYear != 2026 { t.Errorf("packedForYear = %v, want 2026", got.PackedForYear) } // The image and the derived schema really reached the model. call := fp.Calls()[0] if call.Request.SchemaName != "seedpacket" { t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName) } var sawImage bool for _, p := range call.Request.Messages[0].Parts { if _, ok := p.(llm.ImagePart); ok { sawImage = true } } if !sawImage { t.Error("the image part didn't reach the model") } } // TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes // back with nil pointers for the numbers, not invented zeros — the whole reason // the numeric fields are pointers. func TestExtractLeavesMissingFieldsNil(t *testing.T) { reg := majordomo.New(majordomo.WithoutEnvProviders()) fp := fake.New("fp") reg.RegisterProvider(fp) fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`)) m, _ := reg.Parse("fp/vision") got, err := extractVia(t, m, tinyJPEG(t)) if err != nil { t.Fatalf("extract: %v", err) } if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil { t.Errorf("missing numeric fields should be nil, got %+v", got) } } // TestExtractRejectsEmptyImage: no bytes, no call. func TestExtractRejectsEmptyImage(t *testing.T) { if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil { t.Error("Extract accepted an empty image") } }