package service import ( "context" "errors" "fmt" "math" "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, "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, 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, "a@example.com") 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) } } } } // TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object // entirely. clampTo inverts such a region rather than emptying it, and an // inverted region must plant nothing — not one plop at some point off the bed. func TestFillRegionOutsideObjectPlantsNothing(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, 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 err != nil { t.Fatalf("FillRegion: %v", err) } if len(created) != 0 { t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created) } } // 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, 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, "a@example.com") 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, "a@example.com") 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, "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, 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, "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, 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, "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, 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); 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, 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) 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, "a@example.com") 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, "a@example.com") 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) 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) } 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) } } // 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, "s@example.com") 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, "a@example.com") 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, "a@example.com") 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") } }