package service import ( "context" "errors" "testing" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/vision" ) func packet(species, variety, category string) vision.SeedPacket { return vision.SeedPacket{Species: species, Variety: variety, Category: category} } func plant(id int64, name string) domain.Plant { return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable} } // TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates, // best first, and never invents a match — the whole point, since a wrong auto- // match would fragment a variety's seed-lot history across duplicate rows. func TestMatchPlants(t *testing.T) { catalog := []domain.Plant{ plant(1, "Garlic"), plant(2, "Music Garlic"), plant(3, "Cherokee Purple"), plant(4, "Basil"), } t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) { got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog) if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" { t.Fatalf("want Cherokee Purple exact first, got %+v", got) } }) t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) { // "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic" // shares the species word (rank 2). got := matchPlants(packet("garlic", "Music", "vegetable"), catalog) if len(got) != 2 { t.Fatalf("got %d candidates, want 2: %+v", len(got), got) } if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" { t.Errorf("first = %+v, want Music Garlic / variety in name", got[0]) } if got[1].Plant.ID != 1 || got[1].Reason != "same species" { t.Errorf("second = %+v, want Garlic / same species", got[1]) } }) t.Run("case-insensitive", func(t *testing.T) { got := matchPlants(packet("", "cherokee purple", ""), catalog) if len(got) == 0 || got[0].Plant.ID != 3 { t.Errorf("case-insensitive exact match failed: %+v", got) } }) t.Run("no match → empty (a new variety)", func(t *testing.T) { if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 { t.Errorf("want no candidates, got %+v", got) } }) t.Run("species word boundary, not substring", func(t *testing.T) { // "garlic" should not match a hypothetical "garlicky" — wordIn is token-based. got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")}) if len(got) != 0 { t.Errorf("substring shouldn't match on species: %+v", got) } }) } // visionTestService builds a service with a configured vision model and a canned // extractor, so ExtractSeedPacket can run with no live model. func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) { t.Helper() cfg := openConfig() cfg.Agent.OllamaCloudAPIKey = "k" cfg.Agent.VisionModel = "ollama-cloud/vision:cloud" s := newTestService(t, cfg) s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) { return out, extractErr } owner := seedUser(t, s, "a@example.com") return s, owner } // TestExtractSeedPacket exercises the orchestration: canned packet → proposal // with catalog candidates + prefill suggestions. func TestExtractSeedPacket(t *testing.T) { ctx := context.Background() s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil) // Seed a matching plant. if _, err := s.CreatePlant(ctx, owner, PlantInput{ Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄", }); err != nil { t.Fatalf("seed plant: %v", err) } prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes")) if err != nil { t.Fatalf("extract: %v", err) } if prop.Packet.Variety != "Music" { t.Errorf("packet variety = %q", prop.Packet.Variety) } if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" { t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates) } if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable { t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory) } } // TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the // feature is unavailable (ErrInvalidInput), and the extractor is never called. func TestExtractSeedPacketNeedsVisionModel(t *testing.T) { s := newTestService(t, openConfig()) // no vision model, no key called := false s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) { called = true return vision.SeedPacket{}, nil } owner := seedUser(t, s, "a@example.com") if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("err = %v, want ErrInvalidInput", err) } if called { t.Error("extractor was called despite no configured vision model") } } // TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a // lot attributed to it, in one call. func TestCreateFromPacketNewPlant(t *testing.T) { ctx := context.Background() s, owner := visionTestService(t, vision.SeedPacket{}, nil) res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{ NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"}, Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs}, }) if err != nil { t.Fatalf("create: %v", err) } if !res.PlantIsNew || res.Plant.Name != "Music Garlic" { t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew) } if res.Lot == nil || res.Lot.PlantID != res.Plant.ID { t.Errorf("lot not attributed to the new plant: %+v", res.Lot) } } // TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to // the existing plant and creates nothing new. func TestCreateFromPacketExistingPlant(t *testing.T) { ctx := context.Background() s, owner := visionTestService(t, vision.SeedPacket{}, nil) existing, err := s.CreatePlant(ctx, owner, PlantInput{ Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄", }) if err != nil { t.Fatalf("seed plant: %v", err) } res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{ PlantID: &existing.ID, Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs}, }) if err != nil { t.Fatalf("create: %v", err) } if res.PlantIsNew || res.Plant.ID != existing.ID { t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew) } if res.Lot.PlantID != existing.ID { t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID) } } // TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused, // so an ambiguous confirm can't silently pick. func TestCreateFromPacketExactlyOne(t *testing.T) { ctx := context.Background() s, owner := visionTestService(t, vision.SeedPacket{}, nil) id := int64(1) for _, in := range []PacketConfirm{ {}, // neither {PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both } { if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err) } } }