Agent seam: bulk ops (FillRegion/ClearObject/DescribeGarden) + DefineTool wrappers (#19)
Build image / build-and-push (push) Successful in 5s
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #38.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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, "[email protected]")
|
||||
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, "[email protected]")
|
||||
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, "[email protected]")
|
||||
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, "[email protected]")
|
||||
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, "[email protected]")
|
||||
viewer := seedUser(t, s, "[email protected]")
|
||||
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, "[email protected]", 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, "[email protected]")
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user