Files
pansy/internal/service/plants_test.go
T
steveandClaude Opus 4.8 7c042ebfc9
Build image / build-and-push (push) Successful in 4s
Gadfly review (reusable) / review (pull_request) Canceled after 5m3s
Adversarial Review (Gadfly) / review (pull_request) Canceled after 5m3s
Agent tools: plant lookup, plant creation, journal entries (#55)
"Change the garlic garden bed to instead be cucumbers this year" was nearly
executable already — describe_garden resolves "the garlic bed" to an object id
because it reports the NAMES of what's planted, clear_object empties it, and
fill_region replants it. Step three had no way to get its plantId. The toolbox
had no plant tool at all, so the agent could see the string "Garlic" come out of
describe_garden and had no route from "cucumbers" to an id. One missing tool
blocked the whole flagship interaction.

find_plant matches the actor's visible catalog and deliberately returns SEVERAL
candidates rather than one guess. "garlic" against a catalog holding both
"Garlic" and "German Red Garlic" is genuinely ambiguous, and a caller holding
the surrounding conversation is far better placed to disambiguate than a
fuzzy-match heuristic here. Results are ranked exact, then prefix, then
substring, then category, and ordered stably — a tool whose results reshuffle
between calls is one a model can't reason about across turns.

Each result also reports how much seed is left of that plant across the actor's
lots, so the agent can say "you only have enough for half that bed" instead of
confidently planting seed that doesn't exist. Omitted rather than zeroed when
there are no lots, and dropped when lots disagree about the unit, since summing
seeds and grams would be a lie.

create_plant lets a variety be named in conversation without leaving the chat.
It's user-scoped, not garden-scoped — someone with no editable garden can still
name a plant — which the tests assert deliberately rather than assume.

add_journal_entry arrived with #52: "note that the west bed has mildew" is
squarely the kind of thing you say out loud while walking around.

Also reviewed the descriptions on the existing seven tools, since they are the
model's only documentation. fill_region's region vocabulary now says north is
the top of the garden, gives a worked example of the replant sequence, and notes
that filling twice is safe.

The demo sequence is now a test: describe_garden → find_plant("cucumber") →
clear_object → fill_region, ending with a bed of cucumbers and no active garlic.

go.mod is deliberately untouched — majordomo stays out of the module until #56
drops the build tag, so the default build is unaffected.

Closes #55

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:59:35 -04:00

341 lines
12 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
func ptrInt(n int) *int { return &n }
// validPlant is a well-formed PlantInput for tests to tweak.
func validPlant(name string) PlantInput {
return PlantInput{Name: name, Category: domain.CategoryHerb, SpacingCM: 25, Color: "#4a7c3f", Icon: "🌿"}
}
func TestListPlantsIncludesSeededBuiltins(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plants, err := s.ListPlants(context.Background(), owner)
if err != nil {
t.Fatalf("ListPlants: %v", err)
}
// The 0003 seed ships a catalog; assert it's there and all built-ins.
if len(plants) < 30 {
t.Fatalf("seeded catalog = %d plants, want >= 30", len(plants))
}
for _, p := range plants {
if p.OwnerID != nil {
t.Fatalf("unexpected non-builtin in a fresh catalog: %+v", p)
}
}
// Find a known one.
var found bool
for _, p := range plants {
if p.Name == "Garlic" {
found = true
if p.SpacingCM != 15 || p.Icon != "🧄" {
t.Errorf("Garlic seeded wrong: %+v", p)
}
}
}
if !found {
t.Error("expected seeded Garlic in the catalog")
}
}
func TestCreatePlantIsOwnedAndTrimmed(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
in := validPlant(" Purple basil ")
in.DaysToMaturity = ptrInt(60)
p, err := s.CreatePlant(context.Background(), owner, in)
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
if p.OwnerID == nil || *p.OwnerID != owner {
t.Errorf("owner = %v, want %d", p.OwnerID, owner)
}
if p.Name != "Purple basil" {
t.Errorf("name = %q, want trimmed", p.Name)
}
if p.Version != 1 || p.DaysToMaturity == nil || *p.DaysToMaturity != 60 {
t.Errorf("unexpected plant: %+v", p)
}
}
func TestPlantVisibilityAcrossUsers(t *testing.T) {
s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
p, err := s.CreatePlant(context.Background(), alice, validPlant("Alice's mint"))
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
// Bob's catalog: built-ins only, none of Alice's.
bobList, err := s.ListPlants(context.Background(), bob)
if err != nil {
t.Fatalf("ListPlants(bob): %v", err)
}
for _, bp := range bobList {
if bp.ID == p.ID {
t.Fatal("bob can see alice's custom plant")
}
}
// Bob can neither edit nor delete it — invisibility masks it as ErrNotFound.
nm := "hijack"
if _, err := s.UpdatePlant(context.Background(), bob, p.ID, PlantPatch{Name: &nm}, p.Version); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob update err = %v, want ErrNotFound", err)
}
if err := s.DeletePlant(context.Background(), bob, p.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob delete err = %v, want ErrNotFound", err)
}
}
func TestBuiltinPlantsAreReadOnly(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plants, _ := s.ListPlants(context.Background(), owner)
builtin := plants[0] // seeded, owner_id nil
if builtin.OwnerID != nil {
t.Fatalf("expected a built-in, got %+v", builtin)
}
nm := "tweaked"
if _, err := s.UpdatePlant(context.Background(), owner, builtin.ID, PlantPatch{Name: &nm}, builtin.Version); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("edit built-in err = %v, want ErrForbidden", err)
}
if err := s.DeletePlant(context.Background(), owner, builtin.ID); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("delete built-in err = %v, want ErrForbidden", err)
}
}
func TestCloneBuiltinThenEdit(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plants, _ := s.ListPlants(context.Background(), owner)
src := plants[0]
// "Clone" = POST a copy; it's now owned and editable.
clone, err := s.CreatePlant(context.Background(), owner, PlantInput{
Name: src.Name + " (mine)", Category: src.Category, SpacingCM: src.SpacingCM, Color: src.Color, Icon: src.Icon,
})
if err != nil {
t.Fatalf("clone: %v", err)
}
sp := 12.0
updated, err := s.UpdatePlant(context.Background(), owner, clone.ID, PlantPatch{SpacingCM: &sp}, clone.Version)
if err != nil {
t.Fatalf("edit clone: %v", err)
}
if updated.SpacingCM != 12 || updated.Version != clone.Version+1 {
t.Errorf("clone edit didn't apply: %+v", updated)
}
}
func TestUpdatePlantClearsDays(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
in := validPlant("Dill")
in.DaysToMaturity = ptrInt(50)
p, _ := s.CreatePlant(context.Background(), owner, in)
// SetDays with nil clears; absent (SetDays=false) would leave it.
cleared, err := s.UpdatePlant(context.Background(), owner, p.ID, PlantPatch{SetDays: true, DaysToMaturity: nil}, p.Version)
if err != nil {
t.Fatalf("clear days: %v", err)
}
if cleared.DaysToMaturity != nil {
t.Errorf("daysToMaturity = %v, want nil", *cleared.DaysToMaturity)
}
}
func TestPlantVersionConflict(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
p, _ := s.CreatePlant(context.Background(), owner, validPlant("Sage"))
sp := 30.0
if _, err := s.UpdatePlant(context.Background(), owner, p.ID, PlantPatch{SpacingCM: &sp}, p.Version); err != nil {
t.Fatalf("first update: %v", err)
}
// Stale version → conflict + current row.
current, err := s.UpdatePlant(context.Background(), owner, p.ID, PlantPatch{SpacingCM: &sp}, p.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Version != 2 {
t.Errorf("conflict didn't return current row: %+v", current)
}
}
func TestCreatePlantValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
bad := []PlantInput{
{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name
{Name: "x", Category: "spaceship", SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // bad category
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 0, Color: "#4a7c3f", Icon: "🌿"}, // zero spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon
{Name: strings.Repeat("x", maxPlantNameLen+1), Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // name too long
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿", DaysToMaturity: ptrInt(0)}, // days must be >= 1
}
for i, in := range bad {
if _, err := s.CreatePlant(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("case %d: err = %v, want ErrInvalidInput", i, err)
}
}
// A shorthand hex (#rgb) and a set days value pass.
ok := validPlant("Thyme")
ok.Color = "#0a0"
ok.DaysToMaturity = ptrInt(90)
if _, err := s.CreatePlant(context.Background(), owner, ok); err != nil {
t.Errorf("valid plant rejected: %v", err)
}
}
func TestDeletePlantInUseIsBlocked(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
})
plant, _ := s.CreatePlant(context.Background(), owner, validPlant("Basil"))
// Plantings CRUD is #14; insert a plop directly so the plant is referenced.
if _, err := s.store.SQL().ExecContext(context.Background(),
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (?, ?, 0, 0, 10)`,
bed.ID, plant.ID,
); err != nil {
t.Fatalf("seed planting: %v", err)
}
if err := s.DeletePlant(context.Background(), owner, plant.ID); !errors.Is(err, domain.ErrPlantInUse) {
t.Fatalf("delete in-use plant err = %v, want ErrPlantInUse", err)
}
// Clearing the reference frees the delete.
if _, err := s.store.SQL().ExecContext(context.Background(), `DELETE FROM plantings WHERE plant_id = ?`, plant.ID); err != nil {
t.Fatalf("clear planting: %v", err)
}
if err := s.DeletePlant(context.Background(), owner, plant.ID); err != nil {
t.Errorf("delete freed plant: %v", err)
}
}
// TestFindPlantsRanksAndDisambiguates — FindPlants deliberately returns several
// candidates rather than one guess, because "garlic" against a catalog holding
// both "Garlic" and "German Red Garlic" is genuinely ambiguous and a caller with
// the surrounding context is better placed to choose than a heuristic here.
func TestFindPlantsRanksAndDisambiguates(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
ctx := context.Background()
custom, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15,
Color: "#8a5a8a", Icon: "🧄",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
got, err := s.FindPlants(ctx, owner, "garlic")
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
names := map[string]bool{}
for _, m := range got {
names[m.Name] = true
}
if !names["Garlic"] || !names[custom.Name] {
t.Errorf("got %v, want both the built-in and the custom variety", names)
}
// Exact match first, so a caller taking [0] gets the least surprising one.
if got[0].Name != "Garlic" {
t.Errorf("first match = %q, want the exact name", got[0].Name)
}
// Prefix beats substring: "german" should surface the custom one first.
pre, err := s.FindPlants(ctx, owner, "german")
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(pre) == 0 || pre[0].Name != custom.Name {
t.Errorf("FindPlants(german) = %+v, want the custom variety first", pre)
}
// Category search works, and a query matching nothing says so plainly.
if herbs, _ := s.FindPlants(ctx, owner, "herb"); len(herbs) == 0 {
t.Error("category search found nothing")
}
if none, _ := s.FindPlants(ctx, owner, "zzzzz"); len(none) != 0 {
t.Errorf("FindPlants(zzzzz) = %+v, want nothing", none)
}
}
// TestFindPlantsReportsSeedRemaining — so a caller can say "you only have enough
// for half that bed" instead of confidently planting seed that doesn't exist.
func TestFindPlantsReportsSeedRemaining(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
ctx := context.Background()
plant := seedOwnPlant(t, s, owner, 15)
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Quantity: 100, Unit: domain.UnitSeeds,
}); err != nil {
t.Fatalf("CreateSeedLot: %v", err)
}
got, err := s.FindPlants(ctx, owner, plant.Name)
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(got) == 0 || got[0].SeedRemaining == nil || *got[0].SeedRemaining != 100 {
t.Fatalf("seedRemaining = %+v, want 100", got[0].SeedRemaining)
}
if got[0].SeedUnit != domain.UnitSeeds {
t.Errorf("seedUnit = %q", got[0].SeedUnit)
}
// A plant with no lots reports nothing rather than a misleading zero.
builtin, err := s.FindPlants(ctx, owner, "Garlic")
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(builtin) > 0 && builtin[0].SeedRemaining != nil {
t.Errorf("a plant with no lots reported %v remaining", *builtin[0].SeedRemaining)
}
// And lots are private: another user searching the same plant sees no seed.
// (They can't see the custom plant at all, so search a built-in instead.)
if _, err := s.CreateSeedLot(ctx, other, SeedLotInput{
PlantID: builtinPlantID(t, s, other), Quantity: 50, Unit: domain.UnitSeeds,
}); err != nil {
t.Fatalf("CreateSeedLot(other): %v", err)
}
mine, _ := s.FindPlants(ctx, owner, "Garlic")
for _, m := range mine {
if m.SeedRemaining != nil {
t.Errorf("saw another user's seed count on %q", m.Name)
}
}
}