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
This commit is contained in:
@@ -57,6 +57,7 @@ func TestCreateGardenValidation(t *testing.T) {
|
||||
{Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / >
|
||||
{Name: "X", HeightCM: math.Inf(1)}, // +Inf
|
||||
{Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm
|
||||
{Name: "X", GridSizeCM: maxGardenCM + 1}, // grid over the cap
|
||||
}
|
||||
for i, in := range cases {
|
||||
if _, err := s.CreateGarden(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
@@ -70,6 +71,51 @@ func TestCreateGardenValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenGridSettings(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
ctx := context.Background()
|
||||
|
||||
// An omitted grid size lands the default; snap defaults off.
|
||||
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "G"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGarden: %v", err)
|
||||
}
|
||||
if g.GridSizeCM != defaultGardenGridCM {
|
||||
t.Errorf("grid = %v, want default %d", g.GridSizeCM, defaultGardenGridCM)
|
||||
}
|
||||
if g.SnapToGrid {
|
||||
t.Error("snap should default off")
|
||||
}
|
||||
|
||||
// An update sets an explicit grid and turns snap on; both persist.
|
||||
up, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
|
||||
Name: "G", WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
|
||||
GridSizeCM: 25, SnapToGrid: true,
|
||||
}, g.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateGarden: %v", err)
|
||||
}
|
||||
got, err := s.GetGarden(ctx, owner, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGarden: %v", err)
|
||||
}
|
||||
if got.GridSizeCM != 25 || !got.SnapToGrid {
|
||||
t.Errorf("persisted grid=%v snap=%v, want 25/true", got.GridSizeCM, got.SnapToGrid)
|
||||
}
|
||||
|
||||
// An update omitting the grid size (0) is re-defaulted, not stored as 0.
|
||||
up2, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
|
||||
Name: "G", WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
|
||||
}, up.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateGarden(reset): %v", err)
|
||||
}
|
||||
if up2.GridSizeCM != defaultGardenGridCM || up2.SnapToGrid {
|
||||
t.Errorf("after omit grid=%v snap=%v, want default/false", up2.GridSizeCM, up2.SnapToGrid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGardensOwnedOnly(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
alice := seedUser(t, s, "[email protected]")
|
||||
|
||||
Reference in New Issue
Block a user