Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones: - scanSeedPacket extends only the READ deadline; a slow upload + a live vision call runs past the server's absolute 30s WriteTimeout and the successful response is silently dropped (the #78 failure mode). Extend the write deadline too (scanWriteTimeout). - An oversized upload tripping MaxBytesReader was mapped to 400; it's 413. Detect *http.MaxBytesError and report IMAGE_TOO_LARGE. - Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400, genuine read/encode faults (and a failed file.Open)->500, not 400. - CreateFromPacket discarded the plant it created when the lot then failed, contradicting its own doc. Roll the new plant back instead so the confirm is all-or-nothing (a fresh plant has no lots/plantings, so the delete is safe; log-and-continue on cleanup failure). - Dedup: packetLotRequest and seedLotCreateRequest shared every lot field. Extract a seedLotFields base both use. validCategory now reuses plantCategories. EffectiveConfig resolves agent+vision from one settings-row read instead of two. - capabilities swallowed an EffectiveVision error silently; log it. - vision test hand-copied Extract's body (drift risk). Split generate() out of Extract so the hermetic test drives the real request builder. Tests: rollback-on-lot-failure (service), oversized->413 (api). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
108 lines
3.3 KiB
Go
108 lines
3.3 KiB
Go
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()
|
|
}
|
|
|
|
// 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 := generate(context.Background(), 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 := generate(context.Background(), 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")
|
|
}
|
|
}
|