package service import ( "context" "errors" "testing" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) func TestNamedRegion(t *testing.T) { o := &domain.GardenObject{WidthCM: 200, HeightCM: 100} // hw=100, hh=50 cases := []struct { name string minX, minY, maxX, maxY float64 }{ {"all", -100, -50, 100, 50}, {"north", -100, -50, 100, 0}, {"top half", -100, -50, 100, 0}, {"south", -100, 0, 100, 50}, {"bottom", -100, 0, 100, 50}, {"east", 0, -50, 100, 50}, {"right half", 0, -50, 100, 50}, {"west", -100, -50, 0, 50}, {"nw", -100, -50, 0, 0}, {"NE corner", 0, -50, 100, 0}, {"northeast", 0, -50, 100, 0}, {"sw", -100, 0, 0, 50}, {"se corner", 0, 0, 100, 50}, } for _, c := range cases { r, err := NamedRegion(o, c.name) if err != nil { t.Errorf("%q: unexpected error %v", c.name, err) continue } if r.MinX != c.minX || r.MinY != c.minY || r.MaxX != c.maxX || r.MaxY != c.maxY { t.Errorf("%q = [%v,%v,%v,%v], want [%v,%v,%v,%v]", c.name, r.MinX, r.MinY, r.MaxX, r.MaxY, c.minX, c.minY, c.maxX, c.maxY) } } if _, err := NamedRegion(o, "middle-ish"); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("unknown region err = %v, want ErrInvalidInput", err) } if _, err := NamedRegion(o, ""); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("empty region err = %v, want ErrInvalidInput", err) } if _, err := NamedRegion(nil, "all"); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("nil object err = %v, want ErrInvalidInput", err) } } func TestFillRegionCappedForHugeArea(t *testing.T) { ctx := context.Background() s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Huge", WidthCM: 8000, HeightCM: 8000}) bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap plant := seedOwnPlant(t, s, owner, 10) region, _ := NamedRegion(bed, "all") if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err) } } func TestDefaultPlopRadius(t *testing.T) { if got := defaultPlopRadius(4); got != 15 { // 1.5*4=6 → floored to 15 t.Errorf("radius(4) = %v, want 15", got) } if got := defaultPlopRadius(20); got != 30 { // 1.5*20 t.Errorf("radius(20) = %v, want 30", got) } } // seedFillBed makes a plantable bed of the given size centered in a big garden. func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject { t.Helper() o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{ Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: w, HeightCM: h, }) if err != nil { t.Fatalf("seed bed %vx%v: %v", w, h, err) } return o } func TestFillRegionDeterministicPacking(t *testing.T) { ctx := context.Background() s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000}) if err != nil { t.Fatalf("garden: %v", err) } bed := seedFillBed(t, s, owner, g.ID, 60, 60) // hw=hh=30 plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15 region, _ := NamedRegion(bed, "all") created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil) if err != nil { t.Fatalf("FillRegion: %v", err) } // Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2). if len(created) != 4 { t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created)) } for _, p := range created { if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 { t.Errorf("unexpected created plop: %+v", p) } if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 { t.Errorf("plop center out of bed bounds: %+v", p) } } // Re-filling the same region skips everything (each candidate sits exactly on // an existing plop → entirely inside it). again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil) if err != nil { t.Fatalf("second FillRegion: %v", err) } if len(again) != 0 { t.Errorf("re-fill created %d plops, want 0 (all covered)", len(again)) } } func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) { ctx := context.Background() s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000}) bed := seedFillBed(t, s, owner, g.ID, 400, 400) // Rotate the bed 45°; the fill must still target the LOCAL NE corner. rot := 45.0 bed, _ = s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{RotationDeg: &rot}, bed.Version) plant := seedOwnPlant(t, s, owner, 20) region, _ := NamedRegion(bed, "ne") created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil) if err != nil { t.Fatalf("FillRegion: %v", err) } if len(created) == 0 { t.Fatal("expected some plops in the NE corner") } for _, p := range created { // NE corner in local coords: x ≥ 0 (east), y ≤ 0 (north). if p.XCM < 0 || p.YCM > 0 { t.Errorf("plop not in local NE corner: %+v", p) } } } func TestClearObject(t *testing.T) { ctx := context.Background() s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 10) region, _ := NamedRegion(bed, "all") if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); err != nil { t.Fatalf("fill: %v", err) } n, err := s.ClearObject(ctx, owner, bed.ID) if err != nil { t.Fatalf("ClearObject: %v", err) } if n < 1 { t.Fatalf("cleared %d, want ≥ 1", n) } full, _ := s.GardenFull(ctx, owner, g.ID, 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); !errors.Is(err, domain.ErrForbidden) { t.Errorf("viewer fill = %v, want ErrForbidden", err) } if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) { t.Errorf("viewer clear = %v, want ErrForbidden", err) } // But a viewer can DescribeGarden (read). if _, err := s.DescribeGarden(ctx, viewer, g.ID); err != nil { t.Errorf("viewer describe = %v, want ok", err) } } // TestFillScenario is the DESIGN scenario: garlic in the NE corner, basil in the // NW, beans across the south — three distinct groups in the right places. func TestFillScenario(t *testing.T) { ctx := context.Background() s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) bed := seedFillBed(t, s, owner, g.ID, 400, 400) garlic := seedNamedPlant(t, s, owner, "Garlic", 15) basil := seedNamedPlant(t, s, owner, "Basil", 25) beans := seedNamedPlant(t, s, owner, "Beans", 10) fill := func(name string, plantID int64) { t.Helper() region, err := NamedRegion(bed, name) if err != nil { t.Fatalf("region %q: %v", name, err) } if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil); err != nil { t.Fatalf("fill %q: %v", name, err) } } fill("ne", garlic.ID) fill("nw", basil.ID) fill("south", beans.ID) desc, err := s.DescribeGarden(ctx, owner, g.ID) if err != nil { t.Fatalf("DescribeGarden: %v", err) } if len(desc.Objects) != 1 { t.Fatalf("objects = %d, want 1", len(desc.Objects)) } // Tally plant → the set of rough locations it appears in. locs := map[string]map[string]bool{} for _, p := range desc.Objects[0].Plantings { if locs[p.Plant] == nil { locs[p.Plant] = map[string]bool{} } locs[p.Plant][p.Location] = true } if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] { t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"]) } if !locs["Basil"]["NW corner"] { t.Errorf("basil locations = %v, want NW corner", locs["Basil"]) } // Beans fill the south half → their plops read as "south" (and possibly the // SE/SW corners at the edges), never north. for loc := range locs["Beans"] { if loc == "north" || loc == "NE corner" || loc == "NW corner" || loc == "center" { t.Errorf("beans appeared in %q, want only southern locations", loc) } } if len(locs["Beans"]) == 0 { t.Error("beans produced no plops") } } // seedNamedPlant creates a custom plant with a specific name + spacing. func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingCM float64) *domain.Plant { t.Helper() p, err := s.CreatePlant(context.Background(), owner, PlantInput{ Name: name, Category: domain.CategoryVegetable, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "🌱", }) if err != nil { t.Fatalf("seed plant %s: %v", name, err) } return p }