REST fill/clear on objects; clear-bed becomes one change set #89

Merged
steve merged 3 commits from feat/fill-clear-rest into main 2026-07-22 02:53:28 +00:00
3 changed files with 50 additions and 17 deletions
Showing only changes of commit 7a6f6963f9 - Show all commits
+30 -10
View File
@@ -19,6 +19,24 @@ import (
// service methods `internal/agent/tools.go` calls, so the permission checks and // service methods `internal/agent/tools.go` calls, so the permission checks and
// the one-change-set-per-operation guarantee come along unchanged. // the one-change-set-per-operation guarantee come along unchanged.
// fillRect is an explicit rectangle in the object's local frame, the alternative
// to a compass name. A named type (not an inline anonymous struct) to match the
// rest of internal/api and so it can carry its own validity check.
type fillRect struct {
MinX float64 `json:"minXCm"`
MinY float64 `json:"minYCm"`
MaxX float64 `json:"maxXCm"`
MaxY float64 `json:"maxYCm"`
}
// degenerate reports whether the rect encloses no area. Such a rect (including
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
// and plant a single plop at the object's centre — a surprising result for what
// is really malformed input.
func (r fillRect) degenerate() bool {
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
}
// fillRequest is the body for POST /objects/:id/fill. // fillRequest is the body for POST /objects/:id/fill.
// //
// A region is given EITHER by compass name ("ne", "south half", "all") or as an // A region is given EITHER by compass name ("ne", "south half", "all") or as an
@@ -29,12 +47,7 @@ import (
type fillRequest struct { type fillRequest struct {
PlantID int64 `json:"plantId" binding:"required"` PlantID int64 `json:"plantId" binding:"required"`
Region string `json:"region"` Region string `json:"region"`
Rect *struct { Rect *fillRect `json:"rect"`
MinX float64 `json:"minXCm"`
MinY float64 `json:"minYCm"`
MaxX float64 `json:"maxXCm"`
MaxY float64 `json:"maxYCm"`
} `json:"rect"`
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing // SpacingOverrideCM plants tighter or looser than the plant's mature spacing
// without editing the catalog entry. // without editing the catalog entry.
SpacingOverrideCM *float64 `json:"spacingOverrideCm"` SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
@@ -62,11 +75,18 @@ func (h *handlers) fillObject(c *gin.Context) {
created []domain.Planting created []domain.Planting
err error err error
) )
if named { if rect := req.Rect; rect != nil {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM) // Reject a zero-area rect here rather than let it plant one stray plop.
} else { // (Binding the pointer to `rect` also keeps the deref visibly guarded,
region := service.Region{MinX: req.Rect.MinX, MinY: req.Rect.MinY, MaxX: req.Rect.MaxX, MaxY: req.Rect.MaxY} // instead of reading req.Rect.MinX under an invariant from a line above.)
if rect.degenerate() {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
return
}
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM) created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM)
} else {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
} }
if err != nil { if err != nil {
writeServiceError(c, err) writeServiceError(c, err)
+15 -2
View File
@@ -61,7 +61,7 @@ func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID
func TestFillAndClearAPI(t *testing.T) { func TestFillAndClearAPI(t *testing.T) {
r := authEngine(t, localCfg()) r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]") cookie := registerAndCookie(t, r, "[email protected]")
gid, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20) _, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
// Fill by compass name. // Fill by compass name.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{ w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
@@ -96,7 +96,6 @@ func TestFillAndClearAPI(t *testing.T) {
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 { if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
t.Errorf("second clear removed %d, want 0", n) t.Errorf("second clear removed %d, want 0", n)
} }
_ = gid
} }
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only // TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
@@ -125,6 +124,20 @@ func TestFillRegionSelectionAPI(t *testing.T) {
t.Errorf("bad region name = %d, want 400", w.Code) t.Errorf("bad region name = %d, want 400", w.Code)
} }
// A zero-area rect is malformed input, not "plant one at the centre". An empty
// `"rect": {}` decodes to all-zeros and must be caught the same way.
for _, rect := range []map[string]any{
{}, // {} → 0,0,0,0
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
} {
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
}
}
// A rect confined to the NE corner produces plops only there. Local frame: // A rect confined to the NE corner produces plops only there. Local frame:
// +x east, -y north. // +x east, -y north.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{ w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
1
+3 -3
View File
@@ -18,12 +18,12 @@ export function ClearBedModal({
onClose: () => void onClose: () => void
}) { }) {
const clear = useClearObject(gardenId) const clear = useClearObject(gardenId)
const n = plopCount
return ( return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}> <Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '} Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history. <span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p> </p>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -33,7 +33,7 @@ export function ClearBedModal({
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
disabled={clear.isPending || n === 0} disabled={clear.isPending || plopCount === 0}
onClick={() => clear.mutate(objectId, { onSuccess: onClose })} onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
> >
{clear.isPending ? 'Clearing' : 'Clear bed'} {clear.isPending ? 'Clearing' : 'Clear bed'}