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, "a@example.com") 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, "a@example.com") 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, "a@example.com") 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, "a@example.com") 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, "a@example.com") 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, "a@example.com") 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, "alice@example.com") bob := seedUser(t, s, "bob@example.com") 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, "a@example.com") 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, "a@example.com") 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) } }