Files
pansy/internal/service/ops_test.go
T
steveandClaude Fable 5 deec7bb917
Build image / build-and-push (push) Successful in 11s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
Agent: undo for real, past seasons, and tools that correct the record
Six tools the live assistant kept needing and a prompt that knows about them:

- undo_change wraps RevertChangeSet(source=agent). A revert is its own change
  set, so Run reports the last one as the turn's handle when the turn changed
  nothing else — an undo-only reply keeps its "Undo this", which is now a redo.
- describe_garden takes a year: the season view (GardenFull(year)), pulled
  plops included, with removed/removedAt per group and per plop; list_years
  says which years have records. Rotation questions finally have data.
- update_planting corrects a plop's date, count, label, radius or seed lot in
  place; remove_planting, remove_plantings and clear_object take a removedAt so
  a harvest can be backdated.
- update_journal_entry / delete_journal_entry correct a note instead of
  stacking a contradicting one.
- update_garden renames/resizes/re-units a garden and rewrites its notes — and
  the notes now go into the system prompt as the gardener's standing facts, so
  "remember we're in zone 6a" persists across conversations.

describe_garden also reports the garden's notes, version and grid, which the
new tools need. Prompt, CLAUDE.md and DESIGN.md updated to match; UI step
labels for the new tools.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 01:50:10 -04:00

923 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"fmt"
"math"
"reflect"
"sort"
"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, FillClump, 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)
}
}
// TestHexCentersEdgeInset pins the spacing rule the packing exists to honour:
// spacing is a constraint between neighbouring plants, so a bed edge — which is
// nobody's neighbour — is owed half a pitch, not a whole one.
//
// The bug this guards against was visible to anyone who filled a bed: staggered
// rows began a full pitch in, leaving a bare strip a whole plop wide down one
// side of every other row, while the far edge had plops hanging off it.
func TestHexCentersEdgeInset(t *testing.T) {
for _, tc := range []struct {
name string
w, h, radius, spacing float64
wantRowStarts []float64 // x of the first plop in rows 0 and 1
}{
// 4ft × 8ft bed, garlic at 15cm spacing → radius 22.5, pitch 45. Three
// columns, the outer ones overhanging by 6.5cm — under the 7.5cm the rule
// allows. Anchored at the corner this row started at -38.5 and its
// staggered neighbour a full 45 further in still.
{"4ft bed of garlic", 122, 244, 22.5, 15, []float64{-45, -22.5}},
// An exact fit: 90 wide at pitch 30 → 3 columns, no overhang needed.
{"exact fit", 90, 90, 15, 10, []float64{-30, -15}},
} {
t.Run(tc.name, func(t *testing.T) {
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
pts, total := hexCenters(r, tc.radius, edgeInset(tc.radius, tc.spacing, FillClump), maxFillPlops)
if len(pts) == 0 {
t.Fatal("no centers")
}
// The count is derived up front so an oversized fill is refused without
// building its lattice — which only works if it matches what gets built.
if total != len(pts) {
t.Errorf("reported total %d, built %d", total, len(pts))
}
// A clump may cross the edge, but only by the half-spacing the rule
// allows — never enough to be mostly out in the path.
budget := tc.spacing / 2
for _, p := range pts {
over := math.Max(
math.Max(r.MinX-(p.x-tc.radius), (p.x+tc.radius)-r.MaxX),
math.Max(r.MinY-(p.y-tc.radius), (p.y+tc.radius)-r.MaxY),
)
if over > budget+1e-6 {
t.Errorf("plop at (%.1f,%.1f) overhangs by %.2f, budget %.2f", p.x, p.y, over, budget)
}
}
// The margins match on opposite edges: the leftover is shared, not piled
// against the far side.
minX, maxX, minY, maxY := pts[0].x, pts[0].x, pts[0].y, pts[0].y
for _, p := range pts {
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
}
if w, e := minX-r.MinX, r.MaxX-maxX; math.Abs(w-e) > 1e-6 {
t.Errorf("lopsided horizontally: west margin %.2f, east %.2f", w, e)
}
if n, s := minY-r.MinY, r.MaxY-maxY; math.Abs(n-s) > 1e-6 {
t.Errorf("lopsided vertically: north margin %.2f, south %.2f", n, s)
}
// The staggered row is offset by HALF a pitch, not a whole one.
starts := map[float64]float64{}
for _, p := range pts {
if x, ok := starts[p.y]; !ok || p.x < x {
starts[p.y] = p.x
}
}
ys := make([]float64, 0, len(starts))
for y := range starts {
ys = append(ys, y)
}
sort.Float64s(ys)
for i, want := range tc.wantRowStarts {
if i >= len(ys) {
t.Fatalf("only %d rows, want at least %d", len(ys), len(tc.wantRowStarts))
}
if got := starts[ys[i]]; math.Abs(got-want) > 1e-6 {
t.Errorf("row %d starts at x=%.2f, want %.2f", i, got, want)
}
}
})
}
}
// TestHexCentersTinyRegion covers a region too small to hold a plop at the
// half-pitch inset: planting one in the middle beats refusing to plant at all.
//
// The off-centre case earns its place — a region symmetric about the origin
// can't tell "the middle of the region" from "the origin", so on its own it
// would pass for an implementation that just returned (0,0).
func TestHexCentersTinyRegion(t *testing.T) {
for _, tc := range []struct {
name string
r Region
wantX, wantY float64
}{
{"centred on the origin", rect(-5, -5, 5, 5), 0, 0},
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
} {
t.Run(tc.name, func(t *testing.T) {
pts, _ := hexCenters(tc.r, 15, edgeInset(15, 10, FillClump), maxFillPlops)
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
}
})
}
}
// TestFillRegionRejectsNonFiniteRegion: non-finite bounds survive clamping and
// the inverted-region guard (NaN compares false both ways). Without the explicit
// check, NaN surfaced as a raw store error ("NOT NULL constraint failed") and
// +Inf as a silent success that planted nothing — neither of which tells the
// caller what it actually did wrong.
func TestFillRegionRejectsNonFiniteRegion(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, 100, 100)
plant := seedOwnPlant(t, s, owner, 10)
nan := math.NaN()
for _, r := range []Region{
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump, nil)
if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
}
for _, p := range created {
if !isFinite(p.XCM) || !isFinite(p.YCM) {
t.Errorf("persisted a plop with non-finite coordinates: %+v", p)
}
}
}
}
// TestFillRegionOutsideObjectIsRefused covers a region that misses the object
// entirely. clampTo inverts such a region rather than emptying it; it used to
// plant nothing and report success, which read as "done" to a caller that had
// aimed at the wrong coordinates — the agent, mixing up the garden frame and
// the bed's local one. Now it is an error, and still never one plop at some
// point off the bed.
func TestFillRegionOutsideObjectIsRefused(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, 100, 100) // local bounds ±50
plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump, nil)
if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion outside the bed: err = %v, want ErrInvalidInput", err)
}
if len(created) != 0 {
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
}
if full, _ := s.GardenFull(ctx, owner, g.ID, nil); len(full.Plantings) != 0 {
t.Errorf("the bed holds %d plops after a refused fill", len(full.Plantings))
}
}
// 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, FillClump, nil)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
//
// This was 4 while the lattice was anchored at the min corner, and the fourth
// sat at x=30 — centred ON the east edge, so half of it lay outside the bed,
// well past the half-spacing (5cm here) the rule allows. Packing one fewer
// plop is the point of the fix, not a regression in it.
if len(created) != 3 {
t.Fatalf("filled %d plops, want 3 (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)
}
// This bed fits its lattice exactly, so nothing should need to overhang.
if p.XCM-p.RadiusCM < -30 || p.XCM+p.RadiusCM > 30 ||
p.YCM-p.RadiusCM < -30 || p.YCM+p.RadiusCM > 30 {
t.Errorf("plop overhangs a bed it fits inside: %+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, FillClump, 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))
}
}
// TestFillGridLaysOutIndividualPlants is the #77 grid mode: a grid fill packs one
// plant per plop at true spacing, so a bed becomes rows of plants rather than a
// few fat clumps. On the same bed it produces many more, smaller plops, each a
// single plant.
func TestFillGridLaysOutIndividualPlants(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, 60, 60)
plant := seedOwnPlant(t, s, owner, 10) // spacing 10
region, _ := NamedRegion(bed, "all")
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil {
t.Fatalf("clump: %v", err)
}
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("clear: %v", err)
}
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid, nil)
if err != nil {
t.Fatalf("grid: %v", err)
}
// Grid packs at spacing 10 (radius 5, pitch 10); clump at radius 15 (pitch 30).
// Grid must produce many more plops.
if len(grid) <= len(clump) {
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
}
// Each grid plop is one plant at radius spacing/2 = 5.
maxAbs := 0.0
for _, p := range grid {
if p.RadiusCM != 5 {
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
}
if p.DerivedCount != 1 {
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
}
maxAbs = math.Max(maxAbs, math.Max(math.Abs(p.XCM), math.Abs(p.YCM)))
}
// The half-spacing edge rule: a grid plant sits AT the plop centre, so the
// outer row is inset a half-spacing (spacing/2 = 5) — at ±25 on this 60cm bed,
// not flush on ±30. Regression guard: the clump inset formula (radius half)
// collapses to 0 for grid and would plant one on the very edge.
if edge := 30.0; maxAbs > edge-5+1e-6 {
t.Errorf("outermost grid plop at |coord|=%.2f — only %.2fcm from the edge; want a half-spacing (5cm) in", maxAbs, edge-maxAbs)
}
}
// TestFillRejectsUnknownLayout: a layout that isn't clump/grid is ErrInvalidInput.
func TestFillRejectsUnknownLayout(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, 60, 60)
plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral"), nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
}
}
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, FillClump, 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, FillClump, 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, nil)
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, FillClump, 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, nil); 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, FillClump, 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, nil)
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.
// Plantings come grouped by plant: a group's Where names the region when the
// whole group sits in one, and a small group also lists its plops.
locs := map[string]map[string]bool{}
for _, g := range desc.Objects[0].Plantings {
if locs[g.Plant] == nil {
locs[g.Plant] = map[string]bool{}
}
locs[g.Plant][g.Where] = true
for _, p := range g.Each {
locs[g.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
}
// TestFillRegionPlantedAt: a fill dates its plops as told and refuses a date
// that isn't one. The UI sends its local day, so an evening fill isn't stamped
// with UTC's tomorrow; API and agent callers that omit it still get UTC today.
func TestFillRegionPlantedAt(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Dated", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 200, 100)
plant := seedOwnPlant(t, s, owner, 30)
day := "2026-04-01"
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &day)
if err != nil {
t.Fatalf("fill: %v", err)
}
if len(created) == 0 {
t.Fatal("fill created nothing")
}
for _, p := range created {
if p.PlantedAt == nil || *p.PlantedAt != day {
t.Errorf("planting %d plantedAt = %v, want %s", p.ID, p.PlantedAt, day)
}
}
bad := "April 1st"
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &bad); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
}
}
// TestDescribeGardenGroupsByPlant — describe_garden is what the assistant reads
// at the start of every turn, and the live one's first describe of a grid-filled
// garden was ~450 plop entries. A group per plant says what a person would say
// ("beans across the north half, sown in May"), spells out its plops only when
// there are few, and carries the dates the model had no way to know before.
func TestDescribeGardenGroupsByPlant(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Grouped", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
beans := seedNamedPlant(t, s, owner, "Beans", 10)
basil := seedNamedPlant(t, s, owner, "Basil", 25)
may, june := "2026-05-01", "2026-06-01"
// A grid fill of the north half: far more plops than get listed, all May.
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "north", PlantID: beans.ID, Layout: FillGrid, PlantedAt: &may}); err != nil {
t.Fatalf("fill beans: %v", err)
}
// Three basil plops in the south half, on two dates, one with an explicit count.
three := 3
for _, in := range []PlantingInput{
{PlantID: basil.ID, XCM: -100, YCM: 100, RadiusCM: 20, PlantedAt: &may},
{PlantID: basil.ID, XCM: 0, YCM: 150, RadiusCM: 20, PlantedAt: &june, Count: &three},
{PlantID: basil.ID, XCM: 100, YCM: 100, RadiusCM: 20, PlantedAt: &june},
} {
if _, err := s.CreatePlanting(ctx, owner, bed.ID, in); err != nil {
t.Fatalf("place basil: %v", err)
}
}
desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
if err != nil {
t.Fatalf("DescribeGarden: %v", err)
}
groups := map[string]DescribeGroup{}
for _, gr := range desc.Objects[0].Plantings {
groups[gr.Plant] = gr
}
if len(groups) != 2 {
t.Fatalf("groups = %d (%+v), want one per plant", len(groups), desc.Objects[0].Plantings)
}
b := groups["Beans"]
if b.Plops <= maxListedPlops {
t.Fatalf("the beans fill made %d plops; the test needs more than %d to exercise the listing cap", b.Plops, maxListedPlops)
}
if b.Each != nil {
t.Errorf("a %d-plop group listed its plops individually", b.Plops)
}
if b.Where != "north half" {
t.Errorf("beans where = %q, want %q", b.Where, "north half")
}
if b.PlantedAt != may {
t.Errorf("beans plantedAt = %q, want %q", b.PlantedAt, may)
}
if b.Plants != b.Plops {
t.Errorf("grid beans: plants %d ≠ plops %d (one plant per grid plop)", b.Plants, b.Plops)
}
ba := groups["Basil"]
if ba.Plops != 3 || len(ba.Each) != 3 {
t.Errorf("basil: plops %d, each %d; want 3 and 3 (a small group lists its plops)", ba.Plops, len(ba.Each))
}
if ba.Where != "south half" {
t.Errorf("basil where = %q, want %q", ba.Where, "south half")
}
if ba.PlantedAt != may+"…"+june {
t.Errorf("basil plantedAt = %q, want the range %q", ba.PlantedAt, may+"…"+june)
}
// Two derived counts (π·20²/25² ≈ 2 each) plus the explicit 3.
if want := 2*derivedCount(20, 25) + 3; ba.Plants != want {
t.Errorf("basil plants = %d, want %d", ba.Plants, want)
}
// The position is what lets a move keep the layout; "south" alone can't. The
// three basil plops were placed at exactly these local points.
placedAt := map[[2]float64]bool{{-100, 100}: true, {0, 150}: true, {100, 100}: true}
for _, e := range ba.Each {
if e.PlantedAt == "" || e.Version == 0 || e.ID == 0 {
t.Errorf("listed plop %+v is missing id, version or date", e)
}
if !placedAt[[2]float64{e.XCM, e.YCM}] {
t.Errorf("listed plop %+v is not at a position a basil was placed at", e)
}
delete(placedAt, [2]float64{e.XCM, e.YCM})
}
if len(placedAt) != 0 {
t.Errorf("positions never listed: %v", placedAt)
}
// The big group's ids are a call away, narrowed to one plant.
listed, err := s.ListObjectPlantings(ctx, owner, bed.ID, &beans.ID)
if err != nil {
t.Fatalf("ListObjectPlantings: %v", err)
}
if len(listed) != b.Plops {
t.Errorf("listed %d beans, want %d", len(listed), b.Plops)
}
for _, p := range listed {
if p.PlantID != beans.ID || p.PlantedAt != may || p.Plant != "Beans" {
t.Errorf("listed plop %+v, want a May bean", p)
break
}
}
// A stranger gets not-found, like everything else behind the garden ACL.
stranger := seedUser(t, s, "[email protected]")
if _, err := s.ListObjectPlantings(ctx, stranger, bed.ID, nil); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger ListObjectPlantings err = %v, want ErrNotFound", err)
}
}
// TestSummarizeWhere pins the words a group's location comes out in: the
// compass names NamedRegion understands when the group fits one, "throughout"
// for a whole-bed fill, a short list for a few scattered plops, and a bounding
// box for anything else — never a column down the middle called a "half".
func TestSummarizeWhere(t *testing.T) {
o := &domain.GardenObject{WidthCM: 200, HeightCM: 100}
at := func(pts ...[2]float64) []domain.Planting {
out := make([]domain.Planting, 0, len(pts))
for _, p := range pts {
out = append(out, domain.Planting{XCM: p[0], YCM: p[1]})
}
return out
}
for _, tc := range []struct {
name string
in []domain.Planting
want string
}{
{"single", at([2]float64{0, -10}), "north"},
{"ne corner", at([2]float64{10, -10}, [2]float64{80, -40}), "NE corner"},
{"south half", at([2]float64{-80, 10}, [2]float64{80, 40}), "south half"},
{"column down the middle", at([2]float64{0, -40}, [2]float64{0, 0}, [2]float64{0, 40}), "north, center, south"},
{"whole bed", at([2]float64{-90, -40}, [2]float64{90, -40}, [2]float64{-90, 40}, [2]float64{90, 40}, [2]float64{0, 0}), "throughout"},
{"middle third", at([2]float64{-30, -40}, [2]float64{30, -40}, [2]float64{-30, 0}, [2]float64{30, 0}, [2]float64{-30, 40}, [2]float64{30, 40}), "x -30…30, y -40…40 cm from the centre"},
} {
if got := summarizeWhere(o, tc.in); got != tc.want {
t.Errorf("%s: summarizeWhere = %q, want %q", tc.name, got, tc.want)
}
}
}
// TestClearPlantingsOnePlantOnTheDayTold — "take the beets out, leave the
// garlic", dated the gardener's day: the whole-bed clear's narrower sibling, and
// what the assistant needed instead of 116 single removals.
func TestClearPlantingsOnePlantOnTheDayTold(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Mixed", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 200)
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
beet := seedNamedPlant(t, s, owner, "Beet", 10)
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "west", PlantID: garlic.ID}); err != nil {
t.Fatalf("fill garlic: %v", err)
}
beets, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "east", PlantID: beet.ID})
if err != nil {
t.Fatalf("fill beets: %v", err)
}
day := "2026-08-22"
n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID, RemovedAt: &day})
if err != nil {
t.Fatalf("ClearPlantings: %v", err)
}
if n != len(beets) {
t.Errorf("cleared %d, want the %d beets", n, len(beets))
}
rows, err := s.store.ListPlantingsForObject(ctx, bed.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
for _, r := range rows {
switch {
case r.PlantID == beet.ID && (r.RemovedAt == nil || *r.RemovedAt != day):
t.Errorf("beet %d removedAt = %v, want %q", r.ID, r.RemovedAt, day)
case r.PlantID == garlic.ID && r.RemovedAt != nil:
t.Errorf("garlic %d was removed by a clear aimed at the beets", r.ID)
}
}
sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if want := fmt.Sprintf("Removed Beet from %s (%d plantings)", objectLabel(bed), n); sets[0].Summary != want {
t.Errorf("summary = %q, want %q", sets[0].Summary, want)
}
// Nothing left of that plant clears nothing, cleanly; a bad date is refused
// before anything is touched.
if n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID}); err != nil || n != 0 {
t.Errorf("second clear = (%d, %v), want (0, nil)", n, err)
}
bad := "22/08/2026"
if _, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{RemovedAt: &bad}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
}
}
// TestFillByRectangleAttributesSeed — a fill can be aimed at any rectangle of the
// object's local frame (the middle third, a strip along one edge), not only a
// compass name, and can charge its plops to a seed lot so the lot's "remaining"
// means something.
func TestFillByRectangleAttributesSeed(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Rect", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 240, 120)
beet := seedNamedPlant(t, s, owner, "Beet", 10)
lot, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"})
if err != nil {
t.Fatalf("lot: %v", err)
}
created, err := s.Fill(ctx, owner, bed.ID, FillSpec{
Region: Region{MinX: -40, MinY: -60, MaxX: 40, MaxY: 60}, PlantID: beet.ID, Layout: FillGrid, SeedLotID: &lot.ID,
})
if err != nil {
t.Fatalf("Fill: %v", err)
}
if len(created) == 0 {
t.Fatal("the rectangle fill planted nothing")
}
for _, p := range created {
if p.XCM < -40 || p.XCM > 40 || p.YCM < -60 || p.YCM > 60 {
t.Errorf("plop at (%v,%v) is outside the rectangle", p.XCM, p.YCM)
}
if p.SeedLotID == nil || *p.SeedLotID != lot.ID {
t.Errorf("plop %d seedLotId = %v, want the lot", p.ID, p.SeedLotID)
}
}
got, err := s.GetSeedLot(ctx, owner, lot.ID)
if err != nil {
t.Fatalf("GetSeedLot: %v", err)
}
if got.Used != float64(len(created)) || got.Remaining != 500-float64(len(created)) {
t.Errorf("lot used/remaining = %v/%v, want %d/%v", got.Used, got.Remaining, len(created), 500-float64(len(created)))
}
// Someone else's lot, or a lot of another plant, refuses the whole fill.
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "all", PlantID: garlic.ID, SeedLotID: &lot.ID}); err == nil {
t.Error("a fill charged to a lot of a different plant succeeded")
}
// No name and no rectangle is "nowhere", not "one plop in the middle" (which
// is what hexCenters makes of a zero-area region).
for _, r := range []Region{{}, {MinX: 10, MinY: -10, MaxX: 10, MaxY: 10}, {MinX: 20, MinY: 0, MaxX: -20, MaxY: 10}} {
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{Region: r, PlantID: garlic.ID}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("empty rectangle %+v: err = %v, want ErrInvalidInput", r, err)
}
}
// A rectangle that misses the bed (it is 240 wide, so ±120) — or only
// touches its edge — is an error, not a successful fill of nothing.
for _, r := range []Region{{MinX: 200, MinY: -10, MaxX: 300, MaxY: 10}, {MinX: 120, MinY: -10, MaxX: 200, MaxY: 10}} {
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{Region: r, PlantID: garlic.ID}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("off-bed rectangle %+v: err = %v, want ErrInvalidInput", r, err)
}
}
// Partly outside is fine: the part inside gets planted.
if created, err := s.Fill(ctx, owner, bed.ID, FillSpec{Region: Region{MinX: 80, MinY: -10, MaxX: 300, MaxY: 10}, PlantID: garlic.ID}); err != nil || len(created) == 0 {
t.Errorf("overhanging rectangle: %d plops, %v; want some", len(created), err)
}
}
// TestDescribeGardenByYear — "what was in this bed last year?" is the question
// rotation advice hangs on, and a describe of what is growing now cannot answer
// it. With a year, describe is the season view: every plop whose time in the
// ground overlapped the year, pulled ones included, each group saying how many
// came out and when.
func TestDescribeGardenByYear(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Seasons", WidthCM: 2000, HeightCM: 2000, Notes: "Zone 6a"})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
beans := seedNamedPlant(t, s, owner, "Beans", 10)
basil := seedNamedPlant(t, s, owner, "Basil", 25)
plantAndPull := func(plantID int64, x float64, planted, pulled string) {
t.Helper()
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plantID, XCM: x, YCM: -100, RadiusCM: 20, PlantedAt: &planted})
if err != nil {
t.Fatalf("plant %d: %v", plantID, err)
}
if pulled != "" {
if _, err := s.RemovePlanting(ctx, owner, pl.ID, pl.Version, &pulled); err != nil {
t.Fatalf("pull %d: %v", pl.ID, err)
}
}
}
plantAndPull(garlic.ID, -100, "2025-10-15", "2026-07-01") // overwintered: in both years
plantAndPull(beans.ID, 0, "2025-05-01", "2025-09-01") // 2025 only
plantAndPull(basil.ID, 100, "2026-06-01", "") // growing now
groupsOf := func(year *int) map[string]DescribeGroup {
t.Helper()
desc, err := s.DescribeGarden(ctx, owner, g.ID, year)
if err != nil {
t.Fatalf("DescribeGarden(%v): %v", year, err)
}
if (year == nil) != (desc.Year == nil) || (year != nil && *desc.Year != *year) {
t.Errorf("describe(%v) reports year %v", year, desc.Year)
}
if desc.Notes != "Zone 6a" || desc.Version != g.Version {
t.Errorf("describe carries notes %q version %d; want the garden's (%q, %d)", desc.Notes, desc.Version, "Zone 6a", g.Version)
}
out := map[string]DescribeGroup{}
for _, gr := range desc.Objects[0].Plantings {
out[gr.Plant] = gr
}
return out
}
names := func(m map[string]DescribeGroup) []string {
var out []string
for n := range m {
out = append(out, n)
}
sort.Strings(out)
return out
}
now := groupsOf(nil)
if got := names(now); !reflect.DeepEqual(got, []string{"Basil"}) {
t.Errorf("now = %v, want only the basil still growing", got)
}
if b := now["Basil"]; b.Removed != 0 || b.RemovedAt != "" || b.Each[0].RemovedAt != "" {
t.Errorf("a live plop reports a removal: %+v", b)
}
y2025 := 2025
last := groupsOf(&y2025)
if got := names(last); !reflect.DeepEqual(got, []string{"Beans", "Garlic"}) {
t.Errorf("2025 = %v, want the beans and the overwintered garlic", got)
}
if b := last["Beans"]; b.Removed != 1 || b.RemovedAt != "2025-09-01" || b.PlantedAt != "2025-05-01" {
t.Errorf("2025 beans = %+v; want 1 removed on 2025-09-01, planted 2025-05-01", b)
}
if gl := last["Garlic"]; gl.Removed != 1 || gl.RemovedAt != "2026-07-01" || len(gl.Each) != 1 || gl.Each[0].RemovedAt != "2026-07-01" {
t.Errorf("2025 garlic = %+v; want its 2026 removal on the group and the plop", gl)
}
y2026 := 2026
this := groupsOf(&y2026)
if got := names(this); !reflect.DeepEqual(got, []string{"Basil", "Garlic"}) {
t.Errorf("2026 = %v, want the basil and the garlic pulled in July", got)
}
// A typo'd year is refused, not an empty garden.
bad := 20026
if _, err := s.DescribeGarden(ctx, owner, g.ID, &bad); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err)
}
}