Files
pansy/internal/service/objects_test.go
T
steveandClaude Opus 4.8 3f4d5627d8
Build image / build-and-push (push) Successful in 15s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s
Add configurable grid + snap to the layout system
Two independent grids, each with a size and a snap toggle:

- Garden grid (owner-set, in the garden settings form): drives the
  editor's visual grid and, when snapping is on, snaps objects on
  place/move and snaps their dimensions to whole grid steps on resize.
- Bed grid (per plantable object, in the bed Inspector): when snapping
  is on, plants snap to the bed's grid on place/move, and the grid is
  drawn inside the focused bed. Plant radius stays free.

Snapping defaults off on both, so existing gardens keep free placement.
Grid spacing shares the [1cm, 100m] range and defaults to 1m (garden) /
30cm (bed); 0 is treated as unset and re-defaulted.

Backend: migration 0005 adds grid_size_cm/snap_to_grid to gardens and
garden_objects, threaded through domain/store/service/api with service +
api round-trip tests. Frontend: new geometry snap helpers (unit-tested),
schema/type plumbing, canvas + overlay snapping, and the two forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-19 02:51:44 -04:00

273 lines
10 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"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// seedGarden creates a garden owned by owner and returns it.
func seedGarden(t *testing.T, s *Service, owner int64) *domain.Garden {
t.Helper()
g, err := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Yard", WidthCM: 1000, HeightCM: 1000})
if err != nil {
t.Fatalf("seed garden: %v", err)
}
return g
}
func ptrBool(b bool) *bool { return &b }
func strPtr(s string) *string { return &s }
func TestCreateObjectDefaults(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
// A bed at the garden center; no shape/plantable given.
o, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, Name: " Bed 1 ", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 100,
})
if err != nil {
t.Fatalf("CreateObject: %v", err)
}
if o.Shape != domain.ShapeRect {
t.Errorf("shape = %q, want default rect", o.Shape)
}
if !o.Plantable {
t.Error("a bed should default to plantable")
}
if o.Name != "Bed 1" {
t.Errorf("name = %q, want trimmed", o.Name)
}
if o.Version != 1 || o.GardenID != g.ID {
t.Errorf("unexpected object: %+v", o)
}
}
func TestObjectGridSettings(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
// An omitted grid size defaults; snap defaults off.
o, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 100,
})
if err != nil {
t.Fatalf("CreateObject: %v", err)
}
if o.GridSizeCM != defaultObjectGridCM {
t.Errorf("grid = %v, want default %d", o.GridSizeCM, defaultObjectGridCM)
}
if o.SnapToGrid {
t.Error("snap should default off")
}
// Patching grid + snap round-trips and leaves other fields untouched.
grid := 15.0
snap := true
up, err := s.UpdateObject(ctx, owner, o.ID, ObjectPatch{GridSizeCM: &grid, SnapToGrid: &snap}, o.Version)
if err != nil {
t.Fatalf("UpdateObject: %v", err)
}
if up.GridSizeCM != 15 || !up.SnapToGrid {
t.Errorf("after patch grid=%v snap=%v, want 15/true", up.GridSizeCM, up.SnapToGrid)
}
if up.WidthCM != 200 || up.HeightCM != 100 {
t.Errorf("a grid patch changed dimensions: %vx%v", up.WidthCM, up.HeightCM)
}
}
func TestCreateObjectPlantableByKind(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
tree, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindTree, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
})
if err != nil {
t.Fatalf("create tree: %v", err)
}
if tree.Plantable {
t.Error("a tree should default to not plantable")
}
// Override: a plantable path.
path, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindPath, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Plantable: ptrBool(true),
})
if err != nil {
t.Fatalf("create path: %v", err)
}
if !path.Plantable {
t.Error("plantable override should win")
}
}
func TestCreateObjectRotationNormalized(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
o, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, RotationDeg: 450,
})
if err != nil {
t.Fatalf("CreateObject: %v", err)
}
if o.RotationDeg != 90 {
t.Errorf("rotation 450 normalized to %v, want 90", o.RotationDeg)
}
}
func TestCreateObjectValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner) // 1000×1000
bad := []ObjectInput{
{Kind: "spaceship", XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100}, // bad kind
{Kind: domain.KindBed, Shape: domain.ShapePolygon, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100}, // polygon reserved
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 0, HeightCM: 100}, // zero width
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: -5, HeightCM: 100}, // negative
{Kind: domain.KindBed, XCM: 5000, YCM: 5000, WidthCM: 100, HeightCM: 100}, // fully off-field
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Color: strPtr("nope")}, // bad color
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Props: strPtr("{not json")}, // bad props
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Name: strings.Repeat("x", maxObjectNameLen+1)}, // name too long
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, GridSizeCM: maxGardenCM + 1}, // grid over the cap
}
for i, in := range bad {
if _, err := s.CreateObject(context.Background(), owner, g.ID, in); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("case %d: err = %v, want ErrInvalidInput", i, err)
}
}
// Partial overhang IS allowed (center near the edge).
if _, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 990, YCM: 990, WidthCM: 200, HeightCM: 200,
}); err != nil {
t.Errorf("overhang at the edge should be allowed: %v", err)
}
// A valid hex color and valid props JSON pass.
if _, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
Color: strPtr("#3f8f4f"), Props: strPtr(`{"heightCm":40}`),
}); err != nil {
t.Errorf("valid color+props should pass: %v", err)
}
}
func TestUpdateObjectPartialAndVersion(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
o, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 100, YCM: 100, WidthCM: 100, HeightCM: 100,
})
// Move + rotate only; name/dimensions untouched.
nx, ny, rot := 300.0, 400.0, -90.0
updated, err := s.UpdateObject(context.Background(), owner, o.ID, ObjectPatch{XCM: &nx, YCM: &ny, RotationDeg: &rot}, o.Version)
if err != nil {
t.Fatalf("UpdateObject: %v", err)
}
if updated.XCM != 300 || updated.YCM != 400 {
t.Errorf("move didn't apply: %+v", updated)
}
if updated.RotationDeg != 270 {
t.Errorf("rotation -90 normalized to %v, want 270", updated.RotationDeg)
}
if updated.Name != "Bed" || updated.WidthCM != 100 {
t.Errorf("untouched fields changed: %+v", updated)
}
if updated.Version != o.Version+1 {
t.Errorf("version = %d, want %d", updated.Version, o.Version+1)
}
// Stale version → conflict + current row.
current, err := s.UpdateObject(context.Background(), owner, o.ID, ObjectPatch{XCM: &nx}, o.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Version != 2 {
t.Errorf("conflict didn't return current row: %+v", current)
}
}
func TestObjectCrossUserIsNotFound(t *testing.T) {
s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, alice)
o, _ := s.CreateObject(context.Background(), alice, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
})
nx := 1.0
if _, err := s.CreateObject(context.Background(), bob, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100}); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob create err = %v, want ErrNotFound", err)
}
if _, err := s.UpdateObject(context.Background(), bob, o.ID, ObjectPatch{XCM: &nx}, o.Version); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob update err = %v, want ErrNotFound", err)
}
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob delete err = %v, want ErrNotFound", err)
}
if _, err := s.GardenFull(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob /full err = %v, want ErrNotFound", err)
}
}
func TestDeleteObject(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
o, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
})
if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil {
t.Fatalf("DeleteObject: %v", err)
}
full, err := s.GardenFull(context.Background(), owner, g.ID)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if len(full.Objects) != 0 {
t.Errorf("object still present after delete: %d", len(full.Objects))
}
}
func TestGardenFullShape(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 300, YCM: 300, WidthCM: 100, HeightCM: 100})
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindContainer, Shape: domain.ShapeCircle, XCM: 600, YCM: 600, WidthCM: 60, HeightCM: 60})
full, err := s.GardenFull(context.Background(), owner, g.ID)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if full.Garden == nil || full.Garden.ID != g.ID {
t.Error("full.Garden missing")
}
if len(full.Objects) != 2 {
t.Errorf("objects = %d, want 2", len(full.Objects))
}
// Plantings/plants are empty (non-nil) until #14.
if full.Plantings == nil || len(full.Plantings) != 0 {
t.Errorf("plantings = %v, want empty non-nil", full.Plantings)
}
if full.Plants == nil || len(full.Plants) != 0 {
t.Errorf("plants = %v, want empty non-nil", full.Plants)
}
}