package service import ( "context" "errors" "testing" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // seedBed creates a plantable 200ร—200 bed centered in a 1000ร—1000 garden. func seedBed(t *testing.T, s *Service, owner, gardenID int64) *domain.GardenObject { t.Helper() o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{ Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200, }) if err != nil { t.Fatalf("seed bed: %v", err) } return o } // seedOwnPlant creates a custom plant owned by owner with the given spacing. func seedOwnPlant(t *testing.T, s *Service, owner int64, spacingCM float64) *domain.Plant { t.Helper() p, err := s.CreatePlant(context.Background(), owner, PlantInput{ Name: "Testroot", Category: domain.CategoryHerb, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "๐ŸŒฟ", }) if err != nil { t.Fatalf("seed plant: %v", err) } return p } func TestDerivedCountFormula(t *testing.T) { cases := []struct { r, sp float64 want int }{ {0.1, 10, 1}, // tiny radius floors to 1 {0, 10, 1}, // zero radius โ†’ 1 {10, 0, 1}, // zero spacing โ†’ 1 {10, 10, 3}, // ฯ€ยท100/100 = 3.14 โ†’ 3 {50, 10, 79}, // ฯ€ยท2500/100 = 78.5 โ†’ 79 {30, 15, 13}, // ฯ€ยท900/225 = 12.57 โ†’ 13 } for i, c := range cases { if got := derivedCount(c.r, c.sp); got != c.want { t.Errorf("case %d: derivedCount(%v, %v) = %d, want %d", i, c.r, c.sp, got, c.want) } } } func TestDerivedCountCapped(t *testing.T) { // A huge radius with a tiny spacing would produce an absurd (32-bit // overflowing) count; it's capped at the same ceiling as a manual override. if got := derivedCount(10_000, 0.1); got != maxExplicitCount { t.Errorf("derivedCount(10000, 0.1) = %d, want cap %d", got, maxExplicitCount) } } func TestCreatePlantingDefaultsAndDerivedCount(t *testing.T) { 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) pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 10, YCM: -20, RadiusCM: 10, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } if pl.Count != nil { t.Errorf("count = %v, want nil (derived)", *pl.Count) } if pl.DerivedCount != 3 { t.Errorf("derivedCount = %d, want 3", pl.DerivedCount) } if pl.PlantedAt == nil { t.Error("plantedAt should default to today, got nil") } if pl.Version != 1 || pl.ObjectID != bed.ID { t.Errorf("unexpected planting: %+v", pl) } } func TestPlantingCountOverrideWins(t *testing.T) { 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) cnt := 12 pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 50, Count: &cnt, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } if pl.Count == nil || *pl.Count != 12 { t.Errorf("count override = %v, want 12", pl.Count) } // derivedCount is still computed alongside the override (79 for r=50, sp=10). if pl.DerivedCount != 79 { t.Errorf("derivedCount = %d, want 79", pl.DerivedCount) } } func TestUpdatePlantingMoveResizeAndClearCount(t *testing.T) { 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) cnt := 5 pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, Count: &cnt, }) // Move + grow radius, and clear the count override back to derived. nx, ny, nr := 30.0, -40.0, 50.0 updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &nx, YCM: &ny, RadiusCM: &nr, SetCount: true, Count: nil}, pl.Version) if err != nil { t.Fatalf("UpdatePlanting: %v", err) } if updated.XCM != 30 || updated.YCM != -40 || updated.RadiusCM != 50 { t.Errorf("move/resize didn't apply: %+v", updated) } if updated.Count != nil { t.Errorf("count should be cleared, got %v", *updated.Count) } if updated.DerivedCount != 79 { // r=50, sp=10 t.Errorf("derivedCount after grow = %d, want 79", updated.DerivedCount) } if updated.Version != pl.Version+1 { t.Errorf("version = %d, want %d", updated.Version, pl.Version+1) } } func TestPlantingNonPlantableObjectRejected(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) plant := seedOwnPlant(t, s, owner, 10) tree, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{ Kind: domain.KindTree, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, }) if _, err := s.CreatePlanting(context.Background(), owner, tree.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("planting into a tree err = %v, want ErrInvalidInput", err) } } func TestPlantingForeignPlantRejectedBuiltinAllowed(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") bob := seedUser(t, s, "bob@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) // Bob's custom plant is invisible to owner โ†’ invalid reference. bobPlant := seedOwnPlant(t, s, bob, 10) if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: bobPlant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("foreign plant err = %v, want ErrInvalidInput", err) } // An unknown plant id is likewise invalid. if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: 999999, XCM: 0, YCM: 0, RadiusCM: 10, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("unknown plant err = %v, want ErrInvalidInput", err) } // A built-in plant is plantable by anyone. builtins, _ := s.ListPlants(context.Background(), owner) if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: builtins[0].ID, XCM: 0, YCM: 0, RadiusCM: 10, }); err != nil { t.Errorf("planting a built-in should work: %v", err) } } func TestPlantingBoundsCheck(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) // 200ร—200 โ†’ local bounds ยฑ100 plant := seedOwnPlant(t, s, owner, 10) // Center outside the object's local bounds โ†’ rejected (radius may overhang, // but the center may not). if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 150, YCM: 0, RadiusCM: 10, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("out-of-bounds center err = %v, want ErrInvalidInput", err) } // On the edge is allowed. if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 100, YCM: -100, RadiusCM: 30, }); err != nil { t.Errorf("edge-of-bounds center should be allowed: %v", err) } // A negative radius is rejected; an unspecified (zero) one means ONE plant โ€” // half the plant's spacing, the editor's tap-to-place size โ€” so a caller that // just says "put a tomato here" gets a tomato-sized plop, not an error. if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: -1, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("negative radius err = %v, want ErrInvalidInput", err) } one, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0, }) if err != nil { t.Fatalf("zero radius: %v, want the one-plant default", err) } if one.RadiusCM != plant.SpacingCM/2 || one.DerivedCount != 1 { t.Errorf("zero radius โ†’ radius %v (count %d), want spacing/2 = %v (count 1)", one.RadiusCM, one.DerivedCount, plant.SpacingCM/2) } } func TestPlantingSoftRemoveLeavesFull(t *testing.T) { 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) pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }) // It shows in /full, with a derived count. full, _ := s.GardenFull(context.Background(), owner, g.ID, nil) if len(full.Plantings) != 1 { t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings)) } if full.Plantings[0].DerivedCount != 3 { t.Errorf("full derivedCount = %d, want 3", full.Plantings[0].DerivedCount) } if len(full.Plants) != 1 { t.Errorf("full.Plants = %d, want 1 referenced plant", len(full.Plants)) } // Soft-remove โ†’ it leaves /full. (On/after today's default planted_at.) rm := "2030-06-01" if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil { t.Fatalf("soft-remove: %v", err) } full, _ = s.GardenFull(context.Background(), owner, g.ID, nil) if len(full.Plantings) != 0 { t.Errorf("removed plop still in /full: %d", len(full.Plantings)) } } func TestPlantingRemovedBeforePlantedRejected(t *testing.T) { 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) // planted_at defaults to today; a removed_at in the distant past is invalid. pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }) past := "2000-01-01" if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{SetRemovedAt: true, RemovedAt: &past}, pl.Version); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("removed-before-planted err = %v, want ErrInvalidInput", err) } } func TestUpdatePlantingEditableAfterObjectShrinks(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) // 200ร—200 โ†’ local bounds ยฑ100 plant := seedOwnPlant(t, s, owner, 10) // A plop near the bed's edge. pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 90, YCM: 0, RadiusCM: 10, }) // Shrink the bed to 100ร—100 (local bounds ยฑ50): the plop's center (90) is now // outside the object. w, h := 100.0, 100.0 if _, err := s.UpdateObject(context.Background(), owner, bed.ID, ObjectPatch{WidthCM: &w, HeightCM: &h}, bed.Version); err != nil { t.Fatalf("shrink bed: %v", err) } // Editing the out-of-bounds plop without moving it (radius here) must still // work โ€” otherwise it'd be un-removable. nr := 15.0 updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version) if err != nil { t.Fatalf("radius edit on orphaned plop should work: %v", err) } // But actually moving it still enforces the new bounds. outside := 90.0 if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &outside}, updated.Version); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("moving to out-of-bounds should be rejected: %v", err) } // Moving it back inside works. inside := 10.0 if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &inside}, updated.Version); err != nil { t.Errorf("moving in-bounds should work: %v", err) } } func TestPlantingVersionConflict(t *testing.T) { 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) pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }) nr := 20.0 if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); err != nil { t.Fatalf("first update: %v", err) } current, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version) if !errors.Is(err, domain.ErrVersionConflict) { t.Fatalf("stale update err = %v, want ErrVersionConflict", err) } if current == nil || current.Version != 2 { t.Fatalf("conflict didn't return current row: %+v", current) } if current.DerivedCount < 1 { t.Errorf("conflict current row should carry a derived count, got %d", current.DerivedCount) } } func TestPlantingCrossUserIsNotFound(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") bob := seedUser(t, s, "bob@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 10) pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }) nr := 5.0 if _, err := s.UpdatePlanting(context.Background(), bob, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); !errors.Is(err, domain.ErrNotFound) { t.Errorf("bob update err = %v, want ErrNotFound", err) } if err := s.DeletePlanting(context.Background(), bob, pl.ID); !errors.Is(err, domain.ErrNotFound) { t.Errorf("bob delete err = %v, want ErrNotFound", err) } // Bob can't place into owner's bed either. if _, err := s.CreatePlanting(context.Background(), bob, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }); !errors.Is(err, domain.ErrNotFound) { t.Errorf("bob create err = %v, want ErrNotFound", err) } } func TestDeletePlanting(t *testing.T) { 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) pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, }) if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil { t.Fatalf("DeletePlanting: %v", err) } full, _ := s.GardenFull(context.Background(), owner, g.ID, nil) if len(full.Plantings) != 0 { t.Errorf("planting still present after delete: %d", len(full.Plantings)) } } // TestMovePlantingAcrossBedsKeepsTheDate โ€” "move the tomatoes to the other bed" // is not "pull them up and plant new ones today". The assistant had only the // latter for want of this, and the plants lost their planting date on the way. func TestMovePlantingAcrossBedsKeepsTheDate(t *testing.T) { ctx := context.Background() s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) from, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "A", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200}) if err != nil { t.Fatalf("bed A: %v", err) } to, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "B", XCM: 900, YCM: 500, WidthCM: 200, HeightCM: 200}) if err != nil { t.Fatalf("bed B: %v", err) } path, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindPath, Name: "Path", XCM: 700, YCM: 900, WidthCM: 400, HeightCM: 100}) if err != nil { t.Fatalf("path: %v", err) } if path.Plantable { no := false if path, err = s.UpdateObject(ctx, owner, path.ID, ObjectPatch{Plantable: &no}, path.Version); err != nil { t.Fatalf("make the path unplantable: %v", err) } } plant := seedOwnPlant(t, s, owner, 30) may := "2026-05-20" pl, err := s.CreatePlanting(ctx, owner, from.ID, PlantingInput{PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 15, PlantedAt: &may}) if err != nil { t.Fatalf("plant: %v", err) } moved, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: -50, YCM: 20}, pl.Version) if err != nil { t.Fatalf("MovePlanting: %v", err) } if moved.ObjectID != to.ID || moved.XCM != -50 || moved.YCM != 20 { t.Errorf("moved to object %d at (%v,%v), want B (%d) at (-50,20)", moved.ObjectID, moved.XCM, moved.YCM, to.ID) } if moved.PlantedAt == nil || *moved.PlantedAt != may { t.Errorf("plantedAt after the move = %v, want %q kept", moved.PlantedAt, may) } if moved.Version != pl.Version+1 || moved.DerivedCount == 0 { t.Errorf("moved row version %d (count %d), want %d and a derived count", moved.Version, moved.DerivedCount, pl.Version+1) } // It reads as a move in history, and undo puts it back in A. sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0) if err != nil { t.Fatalf("history: %v", err) } if want := "Moved " + plant.Name + " from A to B"; sets[0].Summary != want { t.Errorf("summary = %q, want %q", sets[0].Summary, want) } if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 { t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts) } back, err := s.store.GetPlanting(ctx, pl.ID) if err != nil { t.Fatalf("get: %v", err) } if back.ObjectID != from.ID || back.XCM != 10 { t.Errorf("after undo the plop is in object %d at x=%v, want A (%d) at 10", back.ObjectID, back.XCM, from.ID) } // Refused: a position outside the target, a target that can't hold plants, a // bed in another garden โ€” and a stale version conflicts like any edit. cur := back if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: 500, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("out-of-bounds move err = %v, want ErrInvalidInput", err) } if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &path.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("move into a path err = %v, want ErrInvalidInput", err) } other := seedGarden(t, s, owner) far, err := s.CreateObject(ctx, owner, other.ID, ObjectInput{Kind: domain.KindBed, Name: "Far", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200}) if err != nil { t.Fatalf("far bed: %v", err) } if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &far.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("move into another garden err = %v, want ErrInvalidInput", err) } if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version-1); !errors.Is(err, domain.ErrVersionConflict) { t.Errorf("stale version err = %v, want ErrVersionConflict", err) } // A within-bed move is just a position change. within, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version) if err != nil || within.ObjectID != from.ID || within.XCM != 5 { t.Errorf("within-bed move = %+v, %v; want the same bed at x=5", within, err) } }