From 18e4a299c2c21a6373e73e7ee923f25d1c46cb21 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 05:16:40 +0000 Subject: [PATCH] Agent seam: bulk ops (FillRegion/ClearObject/DescribeGarden) + DefineTool wrappers (#19) Co-authored-by: Steve Dudenhoeffer --- internal/agent/doc.go | 18 ++ internal/agent/tools.go | 119 ++++++++++++ internal/agent/tools_test.go | 152 +++++++++++++++ internal/service/ops.go | 348 +++++++++++++++++++++++++++++++++++ internal/service/ops_test.go | 276 +++++++++++++++++++++++++++ internal/store/plantings.go | 77 ++++++++ 6 files changed, 990 insertions(+) create mode 100644 internal/agent/doc.go create mode 100644 internal/agent/tools.go create mode 100644 internal/agent/tools_test.go create mode 100644 internal/service/ops.go create mode 100644 internal/service/ops_test.go diff --git a/internal/agent/doc.go b/internal/agent/doc.go new file mode 100644 index 0000000..339e45e --- /dev/null +++ b/internal/agent/doc.go @@ -0,0 +1,18 @@ +// Package agent adapts pansy's service layer to majordomo tools so an agent can +// drive a garden in natural language ("fill the NE corner with garlic, the NW +// with basil, the south half with beans"). Each tool runs as a fixed actor and +// therefore inherits pansy's ACL checks for free — a viewer's fill_region returns +// ErrForbidden, exactly as the REST API would. +// +// Two deliberate separations keep the core server lean: +// +// - cmd/pansy does NOT import this package, so the server binary carries no +// agent/LLM dependencies. +// - the tool wiring (tools.go) sits behind the `majordomo` build tag, so the +// default `go build ./...` / `go test ./...` compiles without the majordomo +// module. Build the agent tools with `-tags majordomo` once majordomo is a +// dependency (`go get gitea.stevedudenhoeffer.com/steve/majordomo`). +// +// The agent harness itself (model loop, chat surface) lives in the +// majordomo/executus stack, outside this repo; this package is only the toolbox. +package agent diff --git a/internal/agent/tools.go b/internal/agent/tools.go new file mode 100644 index 0000000..e3ad479 --- /dev/null +++ b/internal/agent/tools.go @@ -0,0 +1,119 @@ +//go:build majordomo + +package agent + +import ( + "context" + + "gitea.stevedudenhoeffer.com/steve/majordomo/llm" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" +) + +// NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a +// single acting user. Every tool call runs as actorID, so pansy's permission +// checks (requireGardenRole / objectForRole) apply unchanged. Construct one per +// authenticated agent session: +// +// box := agent.NewToolbox(svc, session.UserID) +// agent.Run(ctx, model, box, "fill the NE corner with garlic") +func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { + a := &adapter{svc: svc, actor: actorID} + return llm.NewToolbox("pansy", + llm.DefineTool("list_gardens", + "List the gardens the user can see (owned and shared), with the user's role on each.", + a.listGardens), + llm.DefineTool("describe_garden", + "Summarize a garden: its dimensions, objects (with sizes/positions/version), and each object's active plantings with a rough compass location.", + a.describeGarden), + llm.DefineTool("create_object", + "Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.", + a.createObject), + llm.DefineTool("move_object", + "Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.", + a.moveObject), + llm.DefineTool("place_planting", + "Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).", + a.placePlanting), + llm.DefineTool("fill_region", + "Fill a named region of a plantable object with a plant, hex-packed. Region is one of nw/ne/sw/se (corners), north/south/east/west or top/bottom/left/right (halves), or all.", + a.fillRegion), + llm.DefineTool("clear_object", + "Remove all plants from an object (soft-remove; history is kept).", + a.clearObject), + ) +} + +// adapter carries the service and the acting user for the tool handlers. +type adapter struct { + svc *service.Service + actor int64 +} + +func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) { + return a.svc.ListGardens(ctx, a.actor) +} + +func (a *adapter) describeGarden(ctx context.Context, args struct { + GardenID int64 `json:"gardenId" description:"id of the garden to describe"` +}) (any, error) { + return a.svc.DescribeGarden(ctx, a.actor, args.GardenID) +} + +func (a *adapter) createObject(ctx context.Context, args struct { + GardenID int64 `json:"gardenId" description:"garden to add the object to"` + Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"` + Name string `json:"name" description:"optional label for the object"` + Shape string `json:"shape" description:"rect or circle (default rect)"` + XCM float64 `json:"xCm" description:"center x in garden cm"` + YCM float64 `json:"yCm" description:"center y in garden cm"` + WidthCM float64 `json:"widthCm" description:"width in cm (a circle's diameter)"` + HeightCM float64 `json:"heightCm" description:"height in cm"` +}) (any, error) { + return a.svc.CreateObject(ctx, a.actor, args.GardenID, service.ObjectInput{ + Kind: args.Kind, Name: args.Name, Shape: args.Shape, + XCM: args.XCM, YCM: args.YCM, WidthCM: args.WidthCM, HeightCM: args.HeightCM, + }) +} + +func (a *adapter) moveObject(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"object to move"` + XCM float64 `json:"xCm" description:"new center x in garden cm"` + YCM float64 `json:"yCm" description:"new center y in garden cm"` + Version int64 `json:"version" description:"the object's current version (from describe_garden)"` +}) (any, error) { + return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, + service.ObjectPatch{XCM: &args.XCM, YCM: &args.YCM}, args.Version) +} + +func (a *adapter) placePlanting(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"plantable object to plant in"` + PlantID int64 `json:"plantId" description:"plant to place"` + XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"` + YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"` + RadiusCM float64 `json:"radiusCm" description:"plop radius in cm"` + Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"` +}) (any, error) { + return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{ + PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count, + }) +} + +func (a *adapter) fillRegion(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"plantable object to fill"` + Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"` + PlantID int64 `json:"plantId" description:"plant to fill with"` + SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"` +}) (any, error) { + return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride) +} + +func (a *adapter) clearObject(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"object to remove all plants from"` +}) (any, error) { + n, err := a.svc.ClearObject(ctx, a.actor, args.ObjectID) + if err != nil { + return nil, err + } + return map[string]int{"cleared": n}, nil +} diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go new file mode 100644 index 0000000..a1d33c8 --- /dev/null +++ b/internal/agent/tools_test.go @@ -0,0 +1,152 @@ +//go:build majordomo + +package agent + +import ( + "context" + "encoding/json" + "testing" + + "gitea.stevedudenhoeffer.com/steve/majordomo/llm" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/config" + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" + "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" + "gitea.stevedudenhoeffer.com/steve/pansy/internal/store" +) + +// TestToolboxScenario is the #19 demo harness: it drives the DESIGN scenario +// through the tool layer (JSON args → DefineTool → service) rather than a live +// model — fill the NE corner with garlic, the NW with basil, the south with +// beans — and checks describe_garden reports three groups in the right places. +// It also confirms the toolbox inherits pansy's ACL (a viewer is refused). +// +// Build/run with: go test -tags majordomo ./internal/agent/ +func TestToolboxScenario(t *testing.T) { + ctx := context.Background() + db, err := store.Open(":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := db.Migrate(ctx); err != nil { + t.Fatalf("migrate: %v", err) + } + svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true}) + + owner, err := svc.Register(ctx, service.RegisterInput{Email: "a@example.com", DisplayName: "A", Password: "password123"}) + if err != nil { + t.Fatalf("register: %v", err) + } + box := NewToolbox(svc, owner.ID) + + call := func(name string, args any) llm.ToolResult { + t.Helper() + raw, _ := json.Marshal(args) + return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: raw}) + } + + // Garden + plants are set up directly (there are no create_garden/plant tools); + // the agent-facing bits — object + fills + describe — go through the toolbox. + g, err := svc.CreateGarden(ctx, owner.ID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄") + basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿") + beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘") + + // create_object → a 400×400 bed. + res := call("create_object", map[string]any{ + "gardenId": g.ID, "kind": "bed", "name": "Bed 1", "xCm": 1000, "yCm": 1000, "widthCm": 400, "heightCm": 400, + }) + if res.IsError { + t.Fatalf("create_object: %s", res.Content) + } + var bed struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal([]byte(res.Content), &bed); err != nil { + t.Fatalf("decode created object: %v", err) + } + + // fill_region for each corner/half. + for _, f := range []struct { + region string + plantID int64 + }{{"ne", garlic.ID}, {"nw", basil.ID}, {"south", beans.ID}} { + if r := call("fill_region", map[string]any{"objectId": bed.ID, "region": f.region, "plantId": f.plantID}); r.IsError { + t.Fatalf("fill_region %s: %s", f.region, r.Content) + } + } + + // describe_garden → parse and check locations. + res = call("describe_garden", map[string]any{"gardenId": g.ID}) + if res.IsError { + t.Fatalf("describe_garden: %s", res.Content) + } + var desc service.DescribeResult + if err := json.Unmarshal([]byte(res.Content), &desc); err != nil { + t.Fatalf("decode describe: %v", err) + } + if len(desc.Objects) != 1 { + t.Fatalf("objects = %d, want 1", len(desc.Objects)) + } + seen := map[string]map[string]bool{} + for _, p := range desc.Objects[0].Plantings { + if seen[p.Plant] == nil { + seen[p.Plant] = map[string]bool{} + } + seen[p.Plant][p.Location] = true + } + if !seen["Garlic"]["NE corner"] { + t.Errorf("garlic at %v, want NE corner", seen["Garlic"]) + } + if !seen["Basil"]["NW corner"] { + t.Errorf("basil at %v, want NW corner", seen["Basil"]) + } + if len(seen["Beans"]) == 0 { + t.Error("beans produced no plops") + } + for loc := range seen["Beans"] { + if loc == "north" || loc == "NE corner" || loc == "NW corner" || loc == "center" { + t.Errorf("beans at %q, want only southern locations", loc) + } + } + + // ACL: a viewer's fill_region is refused (the toolbox runs as that actor). + viewerUser, err := svc.Register(ctx, service.RegisterInput{Email: "v@example.com", DisplayName: "V", Password: "password123"}) + if err != nil { + t.Fatalf("register viewer: %v", err) + } + if _, err := svc.AddShare(ctx, owner.ID, g.ID, "v@example.com", domain.RoleViewer); err != nil { + t.Fatalf("share: %v", err) + } + viewerBox := NewToolbox(svc, viewerUser.ID) + vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{ + "objectId": bed.ID, "region": "all", "plantId": garlic.ID, + })}) + if !vr.IsError { + t.Errorf("viewer fill_region succeeded, want a permission error") + } +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spacing float64, icon string) *domain.Plant { + t.Helper() + p, err := svc.CreatePlant(context.Background(), owner, service.PlantInput{ + Name: name, Category: domain.CategoryVegetable, SpacingCM: spacing, Color: "#4a7c3f", Icon: icon, + }) + if err != nil { + t.Fatalf("create plant %s: %v", name, err) + } + return p +} diff --git a/internal/service/ops.go b/internal/service/ops.go new file mode 100644 index 0000000..70e3f42 --- /dev/null +++ b/internal/service/ops.go @@ -0,0 +1,348 @@ +package service + +import ( + "context" + "math" + "strings" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" +) + +// This file holds pansy's bulk, "natural-language-shaped" operations — the ones +// an agent drives ("fill the NE corner with garlic", "clear the bed"). They live +// on *Service like every other operation, so agent tools (internal/agent) and any +// future REST surface inherit the same ACL enforcement via objectForRole / +// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the +// object's center, +x east, +y south, so -y is NORTH). + +// maxFillPlops bounds a single FillRegion so a huge bed with tiny spacing can't +// generate a runaway number of inserts. A bed with thousands of plops is already +// far past any real garden; over the cap we refuse rather than silently truncate. +const maxFillPlops = 5000 + +// Region is an axis-aligned rectangle in an object's local frame. (Circle/polygon +// regions are post-v1, like polygon objects; NamedRegion produces only rects.) +type Region struct { + MinX, MinY, MaxX, MaxY float64 +} + +// contains reports whether a local point lies in the region. +func (r Region) contains(x, y float64) bool { + return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY +} + +// clampTo intersects the region with an object's local bounds (±halfW, ±halfH), +// so an oversized caller-supplied region can't make hexCenters loop forever. +func (r Region) clampTo(halfW, halfH float64) Region { + return Region{ + MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH), + MaxX: math.Min(r.MaxX, halfW), MaxY: math.Min(r.MaxY, halfH), + } +} + +// rect builds a rectangular region. +func rect(minX, minY, maxX, maxY float64) Region { + return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY} +} + +// NamedRegion resolves a compass name to a Region in the object's local frame. +// Recognizes the quarter corners "nw|ne|sw|se", the halves +// "north|south|east|west" and their "top|bottom|left|right" synonyms, and "all". +// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North +// is -y (see the file header). Unknown names return ErrInvalidInput. +func NamedRegion(o *domain.GardenObject, name string) (Region, error) { + if o == nil { + return Region{}, domain.ErrInvalidInput + } + hw, hh := o.WidthCM/2, o.HeightCM/2 + key := strings.ToLower(strings.TrimSpace(name)) + key = strings.TrimSpace(strings.TrimSuffix(key, "corner")) + key = strings.TrimSpace(strings.TrimSuffix(key, "half")) + + switch key { + case "all": + return rect(-hw, -hh, hw, hh), nil + case "north", "top": + return rect(-hw, -hh, hw, 0), nil + case "south", "bottom": + return rect(-hw, 0, hw, hh), nil + case "east", "right": + return rect(0, -hh, hw, hh), nil + case "west", "left": + return rect(-hw, -hh, 0, hh), nil + case "nw", "northwest": + return rect(-hw, -hh, 0, 0), nil + case "ne", "northeast": + return rect(0, -hh, hw, 0), nil + case "sw", "southwest": + return rect(-hw, 0, 0, hh), nil + case "se", "southeast": + return rect(0, 0, hw, hh), nil + default: + return Region{}, domain.ErrInvalidInput + } +} + +// defaultPlopRadius is the radius a freshly-placed plop gets from its plant's +// spacing: max(1.5×spacing, 15cm) — matching the editor's placement default (#15). +func defaultPlopRadius(spacingCM float64) float64 { + return math.Max(1.5*spacingCM, 15) +} + +// FillRegion lays a hex-packed field of plops of one plant across a region of a +// plantable object the actor can edit. Plop radius comes from the plant's spacing +// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2× +// radius pitch, kept where the center is inside the region. A candidate is +// skipped when its plop would sit entirely inside an existing active plop (so +// re-filling doesn't stack duplicates). Returns the plops it created. +func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { + o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) + if err != nil { + return nil, err + } + return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride) +} + +// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object +// already loaded and authorized (roleEditor). It clamps the region to the +// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch +// in one transaction rather than one round-trip per plop. +func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { + if !o.Plantable { + return nil, domain.ErrInvalidInput + } + plant, err := s.visiblePlant(ctx, actorID, plantID) + if err != nil { + return nil, err + } + spacing := plant.SpacingCM + if spacingOverride != nil { + if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM { + return nil, domain.ErrInvalidInput + } + spacing = *spacingOverride + } + radius := defaultPlopRadius(spacing) + if !isFinite(radius) || radius <= 0 { + return nil, domain.ErrInvalidInput + } + + region = region.clampTo(o.WidthCM/2, o.HeightCM/2) + centers := hexCenters(region, radius) + if len(centers) > maxFillPlops { + return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less + } + + existing, err := s.store.ListActivePlantingsForObject(ctx, o.ID) + if err != nil { + return nil, err + } + today := s.now().UTC().Format(dateLayout) + batch := make([]*domain.Planting, 0, len(centers)) + for _, c := range centers { + if coveredByExisting(c.x, c.y, radius, existing) { + continue + } + p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today} + batch = append(batch, p) + existing = append(existing, *p) // so later candidates in THIS fill don't stack on it + } + created, err := s.store.CreatePlantings(ctx, batch) + if err != nil { + return nil, err + } + for i := range created { + created[i].DerivedCount = derivedCount(created[i].RadiusCM, spacing) + } + return created, nil +} + +type localPoint struct{ x, y float64 } + +// hexCenters returns hex-packed lattice centers whose center lies in the region. +// Rows are spaced radius·√3 apart and every other row is offset by radius, the +// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one +// radius inside the region's min corner so the first plop sits inside it. +func hexCenters(r Region, radius float64) []localPoint { + if radius <= 0 { + return nil + } + pitch := 2 * radius + rowH := pitch * math.Sqrt(3) / 2 + const eps = 1e-6 + var pts []localPoint + row := 0 + for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH { + xStart := r.MinX + radius + if row%2 == 1 { + xStart += radius + } + for x := xStart; x <= r.MaxX+eps; x += pitch { + if r.contains(x, y) { + pts = append(pts, localPoint{x, y}) + } + } + row++ + } + return pts +} + +// coveredByExisting reports whether a new plop (center, radius) would sit +// entirely inside some existing active plop. +func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool { + for _, e := range existing { + if math.Hypot(x-e.XCM, y-e.YCM)+radius <= e.RadiusCM { + return true + } + } + return false +} + +// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half") +// instead of a resolved Region — the ergonomic form for agent tools, which don't +// hold the object's geometry. It resolves the name against the object, then fills. +func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { + o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) + if err != nil { + return nil, err + } + region, err := NamedRegion(o, regionName) + if err != nil { + return nil, err + } + return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride) +} + +// ClearObject soft-removes every active plop in an object the actor can edit (one +// UPDATE), returning how many were cleared. Distinct from deleting the object. +// Unlike FillRegion it does NOT require the object be plantable: an object toggled +// non-plantable after it was planted must still be clearable (you can always +// remove existing plops, only not add new ones). +func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) { + if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil { + return 0, err + } + today := s.now().UTC().Format(dateLayout) + return s.store.ClearObjectPlantings(ctx, objectID, today) +} + +// DescribeResult is a structured summary of a garden for prompting an agent. +type DescribeResult struct { + GardenID int64 `json:"gardenId"` + Name string `json:"name"` + WidthCM float64 `json:"widthCm"` + HeightCM float64 `json:"heightCm"` + UnitPref string `json:"unitPref"` + Objects []DescribeObject `json:"objects"` +} + +// DescribeObject is one object plus its active plantings, for DescribeResult. +// Version is included so an agent can move/edit the object (the mutation guard). +type DescribeObject struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Shape string `json:"shape"` + WidthCM float64 `json:"widthCm"` + HeightCM float64 `json:"heightCm"` + XCM float64 `json:"xCm"` + YCM float64 `json:"yCm"` + RotationDeg float64 `json:"rotationDeg"` + Plantable bool `json:"plantable"` + Version int64 `json:"version"` + Plantings []DescribePlanting `json:"plantings"` +} + +// DescribePlanting is one plop with a rough compass location, for DescribeResult. +type DescribePlanting struct { + PlantID int64 `json:"plantId"` + Plant string `json:"plant"` + Count int `json:"count"` + Location string `json:"location"` + RadiusCM float64 `json:"radiusCm"` +} + +// DescribeGarden returns a structured summary — dimensions, objects, and each +// object's active plantings (plant, effective count, rough location) — for a +// garden the actor can view. Built on GardenFull so it inherits the ACL check. +func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) { + full, err := s.GardenFull(ctx, actorID, gardenID) + if err != nil { + return nil, err + } + + plantByID := make(map[int64]domain.Plant, len(full.Plants)) + for _, p := range full.Plants { + plantByID[p.ID] = p + } + plopsByObject := make(map[int64][]domain.Planting) + for _, pl := range full.Plantings { + plopsByObject[pl.ObjectID] = append(plopsByObject[pl.ObjectID], pl) + } + + res := &DescribeResult{ + GardenID: full.Garden.ID, + Name: full.Garden.Name, + WidthCM: full.Garden.WidthCM, + HeightCM: full.Garden.HeightCM, + UnitPref: full.Garden.UnitPref, + Objects: make([]DescribeObject, 0, len(full.Objects)), + } + for _, o := range full.Objects { + do := DescribeObject{ + ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape, + WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM, + RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version, + Plantings: []DescribePlanting{}, + } + for _, pl := range plopsByObject[o.ID] { + count := pl.DerivedCount + if pl.Count != nil { + count = *pl.Count + } + do.Plantings = append(do.Plantings, DescribePlanting{ + PlantID: pl.PlantID, + Plant: plantByID[pl.PlantID].Name, + Count: count, + Location: describeLocation(pl.XCM, pl.YCM), + RadiusCM: pl.RadiusCM, + }) + } + res.Objects = append(res.Objects, do) + } + return res, nil +} + +// describeLocation reverse-maps a local point to a rough compass location — the +// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center"). +func describeLocation(x, y float64) string { + const eps = 1e-6 + ns := "" + switch { + case y < -eps: + ns = "N" + case y > eps: + ns = "S" + } + ew := "" + switch { + case x < -eps: + ew = "W" + case x > eps: + ew = "E" + } + switch { + case ns == "" && ew == "": + return "center" + case ns != "" && ew != "": + return ns + ew + " corner" + case ns == "N": + return "north" + case ns == "S": + return "south" + case ew == "E": + return "east" + default: + return "west" + } +} diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go new file mode 100644 index 0000000..733ac95 --- /dev/null +++ b/internal/service/ops_test.go @@ -0,0 +1,276 @@ +package service + +import ( + "context" + "errors" + "testing" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" +) + +func TestNamedRegion(t *testing.T) { + o := &domain.GardenObject{WidthCM: 200, HeightCM: 100} // hw=100, hh=50 + cases := []struct { + name string + minX, minY, maxX, maxY float64 + }{ + {"all", -100, -50, 100, 50}, + {"north", -100, -50, 100, 0}, + {"top half", -100, -50, 100, 0}, + {"south", -100, 0, 100, 50}, + {"bottom", -100, 0, 100, 50}, + {"east", 0, -50, 100, 50}, + {"right half", 0, -50, 100, 50}, + {"west", -100, -50, 0, 50}, + {"nw", -100, -50, 0, 0}, + {"NE corner", 0, -50, 100, 0}, + {"northeast", 0, -50, 100, 0}, + {"sw", -100, 0, 0, 50}, + {"se corner", 0, 0, 100, 50}, + } + for _, c := range cases { + r, err := NamedRegion(o, c.name) + if err != nil { + t.Errorf("%q: unexpected error %v", c.name, err) + continue + } + if r.MinX != c.minX || r.MinY != c.minY || r.MaxX != c.maxX || r.MaxY != c.maxY { + t.Errorf("%q = [%v,%v,%v,%v], want [%v,%v,%v,%v]", c.name, r.MinX, r.MinY, r.MaxX, r.MaxY, c.minX, c.minY, c.maxX, c.maxY) + } + } + if _, err := NamedRegion(o, "middle-ish"); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("unknown region err = %v, want ErrInvalidInput", err) + } + if _, err := NamedRegion(o, ""); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("empty region err = %v, want ErrInvalidInput", err) + } + if _, err := NamedRegion(nil, "all"); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("nil object err = %v, want ErrInvalidInput", err) + } +} + +func TestFillRegionCappedForHugeArea(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Huge", WidthCM: 8000, HeightCM: 8000}) + bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap + plant := seedOwnPlant(t, s, owner, 10) + region, _ := NamedRegion(bed, "all") + if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err) + } +} + +func TestDefaultPlopRadius(t *testing.T) { + if got := defaultPlopRadius(4); got != 15 { // 1.5*4=6 → floored to 15 + t.Errorf("radius(4) = %v, want 15", got) + } + if got := defaultPlopRadius(20); got != 30 { // 1.5*20 + t.Errorf("radius(20) = %v, want 30", got) + } +} + +// seedFillBed makes a plantable bed of the given size centered in a big garden. +func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject { + t.Helper() + o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{ + Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: w, HeightCM: h, + }) + if err != nil { + t.Fatalf("seed bed %vx%v: %v", w, h, err) + } + return o +} + +func TestFillRegionDeterministicPacking(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + bed := seedFillBed(t, s, owner, g.ID, 60, 60) // hw=hh=30 + plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15 + + region, _ := NamedRegion(bed, "all") + created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil) + if err != nil { + t.Fatalf("FillRegion: %v", err) + } + // Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2). + if len(created) != 4 { + t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created)) + } + for _, p := range created { + if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 { + t.Errorf("unexpected created plop: %+v", p) + } + if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 { + t.Errorf("plop center out of bed bounds: %+v", p) + } + } + + // Re-filling the same region skips everything (each candidate sits exactly on + // an existing plop → entirely inside it). + again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil) + if err != nil { + t.Fatalf("second FillRegion: %v", err) + } + if len(again) != 0 { + t.Errorf("re-fill created %d plops, want 0 (all covered)", len(again)) + } +} + +func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000}) + bed := seedFillBed(t, s, owner, g.ID, 400, 400) + // Rotate the bed 45°; the fill must still target the LOCAL NE corner. + rot := 45.0 + bed, _ = s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{RotationDeg: &rot}, bed.Version) + plant := seedOwnPlant(t, s, owner, 20) + + region, _ := NamedRegion(bed, "ne") + created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil) + if err != nil { + t.Fatalf("FillRegion: %v", err) + } + if len(created) == 0 { + t.Fatal("expected some plops in the NE corner") + } + for _, p := range created { + // NE corner in local coords: x ≥ 0 (east), y ≤ 0 (north). + if p.XCM < 0 || p.YCM > 0 { + t.Errorf("plop not in local NE corner: %+v", p) + } + } +} + +func TestClearObject(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + region, _ := NamedRegion(bed, "all") + if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); err != nil { + t.Fatalf("fill: %v", err) + } + + n, err := s.ClearObject(ctx, owner, bed.ID) + if err != nil { + t.Fatalf("ClearObject: %v", err) + } + if n < 1 { + t.Fatalf("cleared %d, want ≥ 1", n) + } + full, _ := s.GardenFull(ctx, owner, g.ID) + if len(full.Plantings) != 0 { + t.Errorf("plantings after clear = %d, want 0", len(full.Plantings)) + } + // Clearing an already-empty object clears 0. + if again, _ := s.ClearObject(ctx, owner, bed.ID); again != 0 { + t.Errorf("second clear = %d, want 0", again) + } +} + +func TestOpsForbiddenForViewer(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "owner@example.com") + viewer := seedUser(t, s, "viewer@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + if _, err := s.AddShare(ctx, owner, g.ID, "viewer@example.com", domain.RoleViewer); err != nil { + t.Fatalf("share: %v", err) + } + region, _ := NamedRegion(bed, "all") + + if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrForbidden) { + t.Errorf("viewer fill = %v, want ErrForbidden", err) + } + if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) { + t.Errorf("viewer clear = %v, want ErrForbidden", err) + } + // But a viewer can DescribeGarden (read). + if _, err := s.DescribeGarden(ctx, viewer, g.ID); err != nil { + t.Errorf("viewer describe = %v, want ok", err) + } +} + +// TestFillScenario is the DESIGN scenario: garlic in the NE corner, basil in the +// NW, beans across the south — three distinct groups in the right places. +func TestFillScenario(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) + bed := seedFillBed(t, s, owner, g.ID, 400, 400) + garlic := seedNamedPlant(t, s, owner, "Garlic", 15) + basil := seedNamedPlant(t, s, owner, "Basil", 25) + beans := seedNamedPlant(t, s, owner, "Beans", 10) + + fill := func(name string, plantID int64) { + t.Helper() + region, err := NamedRegion(bed, name) + if err != nil { + t.Fatalf("region %q: %v", name, err) + } + if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil); err != nil { + t.Fatalf("fill %q: %v", name, err) + } + } + fill("ne", garlic.ID) + fill("nw", basil.ID) + fill("south", beans.ID) + + desc, err := s.DescribeGarden(ctx, owner, g.ID) + if err != nil { + t.Fatalf("DescribeGarden: %v", err) + } + if len(desc.Objects) != 1 { + t.Fatalf("objects = %d, want 1", len(desc.Objects)) + } + // Tally plant → the set of rough locations it appears in. + locs := map[string]map[string]bool{} + for _, p := range desc.Objects[0].Plantings { + if locs[p.Plant] == nil { + locs[p.Plant] = map[string]bool{} + } + locs[p.Plant][p.Location] = true + } + if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] { + t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"]) + } + if !locs["Basil"]["NW corner"] { + t.Errorf("basil locations = %v, want NW corner", locs["Basil"]) + } + // Beans fill the south half → their plops read as "south" (and possibly the + // SE/SW corners at the edges), never north. + for loc := range locs["Beans"] { + if loc == "north" || loc == "NE corner" || loc == "NW corner" || loc == "center" { + t.Errorf("beans appeared in %q, want only southern locations", loc) + } + } + if len(locs["Beans"]) == 0 { + t.Error("beans produced no plops") + } +} + +// seedNamedPlant creates a custom plant with a specific name + spacing. +func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingCM float64) *domain.Plant { + t.Helper() + p, err := s.CreatePlant(context.Background(), owner, PlantInput{ + Name: name, Category: domain.CategoryVegetable, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "🌱", + }) + if err != nil { + t.Fatalf("seed plant %s: %v", name, err) + } + return p +} diff --git a/internal/store/plantings.go b/internal/store/plantings.go index a60f430..d47b15e 100644 --- a/internal/store/plantings.go +++ b/internal/store/plantings.go @@ -56,6 +56,53 @@ func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ( return plantings, nil } +// ListActivePlantingsForObject returns an object's currently-planted plops +// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid +// stacking new plops inside existing ones. +func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) { + rows, err := d.sql.QueryContext(ctx, + `SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? AND removed_at IS NULL ORDER BY id`, + objectID, + ) + if err != nil { + return nil, fmt.Errorf("store: list object plantings: %w", err) + } + defer rows.Close() + + plantings := []domain.Planting{} + for rows.Next() { + p, err := scanPlanting(rows) + if err != nil { + return nil, fmt.Errorf("store: scan planting: %w", err) + } + plantings = append(plantings, *p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate plantings: %w", err) + } + return plantings, nil +} + +// ClearObjectPlantings soft-removes every active plop in an object in one UPDATE +// (sets removed_at=date, bumps version) and returns how many rows it affected. +func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string) (int, error) { + res, err := d.sql.ExecContext(ctx, + `UPDATE plantings + SET removed_at = ?, version = version + 1, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE object_id = ? AND removed_at IS NULL`, + date, objectID, + ) + if err != nil { + return 0, fmt.Errorf("store: clear object plantings: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("store: clear plantings rows: %w", err) + } + return int(n), nil +} + // GetPlanting returns the planting with the given id, or domain.ErrNotFound. func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error) { p, err := scanPlanting(d.sql.QueryRowContext(ctx, @@ -85,6 +132,36 @@ func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Pl return created, nil } +// CreatePlantings inserts many plops in a single transaction (one commit), for +// bulk fills. Returns the stored rows in order. An empty input is a no-op. +func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting) ([]domain.Planting, error) { + if len(plantings) == 0 { + return []domain.Planting{}, nil + } + tx, err := d.sql.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("store: begin plantings tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op after a successful commit + + const stmt = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING ` + plantingColumns + out := make([]domain.Planting, 0, len(plantings)) + for _, p := range plantings { + created, err := scanPlanting(tx.QueryRowContext(ctx, stmt, + p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt)) + if err != nil { + return nil, fmt.Errorf("store: insert planting (batch): %w", err) + } + out = append(out, *created) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("store: commit plantings: %w", err) + } + return out, nil +} + // UpdatePlanting applies a version-guarded update of all mutable columns (the // service merges partial patches first). Returns the updated row, or // (current row, ErrVersionConflict) / ErrNotFound — the same contract as the