Build image / build-and-push (push) Successful in 5s
- clampTo's doc justified itself by stopping hexCenters "looping forever", which stopped being true when hexCenters became count-bounded. Say what it actually does now, and note the inversion the new guard relies on. - Trim the changelog prose from hexCenters' doc down to the one line that earns its keep: don't re-anchor at the min corner, and why. - Rename a test local from `max` so it stops shadowing the builtin. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
396 lines
14 KiB
Go
396 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"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, "[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)
|
||
}
|
||
}
|
||
|
||
// 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 := hexCenters(r, tc.radius, tc.spacing)
|
||
if len(pts) == 0 {
|
||
t.Fatal("no centers")
|
||
}
|
||
|
||
// 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.
|
||
func TestHexCentersTinyRegion(t *testing.T) {
|
||
pts := hexCenters(rect(-5, -5, 5, 5), 15, 10)
|
||
if len(pts) != 1 || pts[0].x != 0 || pts[0].y != 0 {
|
||
t.Errorf("tiny region gave %+v, want a single centered plop", pts)
|
||
}
|
||
}
|
||
|
||
// 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, "[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)
|
||
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, "[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, 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)
|
||
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, 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); !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
|
||
}
|