Address Gadfly review on #14: bounds trap, date order, count cap
Build image / build-and-push (push) Successful in 5s

- UpdatePlanting: only re-check the object-bounds when the position is actually
  being moved. A plop orphaned outside its object by a later resize stays
  editable/removable instead of becoming a row you can't fix or delete.
- finalizePlanting: reject removed_at before planted_at.
- derivedCount: guard Inf (not just NaN) and cap the result at maxExplicitCount
  (1e6) — the same ceiling a manual override honors — so a huge radius / tiny
  spacing can't overflow a 32-bit int or return an absurd value.
- Refresh stale docs that referenced #14 as not-yet-landed (FullGarden,
  ListActivePlantingsForGarden, ListReferencedPlants) and note the date-only
  layout beside timeLayout.

Deliberately did NOT add a plantable re-check to Update/Delete: existing plops
must stay editable/removable even if their object was later marked non-plantable
(same trap as the bounds case).

Tests: derived-count cap, removed-before-planted rejection, and edit/move of a
plop orphaned by an object shrink.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 22:39:48 -04:00
co-authored by Claude Opus 4.8
parent 2f3699f7fa
commit 8f736048b9
7 changed files with 106 additions and 20 deletions
+66 -2
View File
@@ -51,6 +51,14 @@ func TestDerivedCountFormula(t *testing.T) {
}
}
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, "[email protected]")
@@ -230,8 +238,8 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
t.Errorf("full.Plants = %d, want 1 referenced plant", len(full.Plants))
}
// Soft-remove → it leaves /full.
rm := "2026-07-01"
// 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)
@@ -242,6 +250,62 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
}
}
func TestPlantingRemovedBeforePlantedRejected(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
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, "[email protected]")
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, "[email protected]")