Steve chose option 3: keep clumps as the default primitive, add a grid/rows fill mode, so sketching and planning are different operations with different outputs rather than one model forced to be both. A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner", useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15 blobs, #77). FillLayout selects what a fill packs: - clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop. - grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could actually plant from. The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps no 15cm floor — its whole point is true spacing — while clump keeps it so a tiny-spacing plant doesn't make invisible clumps. Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid), so "plant the bed in rows" works. Tests: grid produces many more, single-plant plops than clump on the same bed (radius spacing/2, derived count 1); unknown layout is refused at both the service and the API. maxFillPlops still caps a grid fill of a huge bed. No frontend fill affordance exists yet (fill is agent-only in the UI; the fill UI was deferred in #82), so the mode toggle rides along when that's built — noted. Docs: DESIGN placement-model decision. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
248 lines
10 KiB
Go
248 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func fillPath(id int64) string { return objectPath(id) + "/fill" }
|
|
func clearPath(id int64) string { return objectPath(id) + "/clear" }
|
|
|
|
// makeFillPlant creates a custom plant and returns its id. (A near-identical
|
|
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
|
|
// one shared helper is a fine follow-up, kept separate here only to avoid a
|
|
// merge collision on the shared symbol.)
|
|
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
|
t.Helper()
|
|
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
|
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
|
}, cookie)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
|
}
|
|
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
|
}
|
|
|
|
// seedFillableBed makes a garden with one plantable bed and a custom plant,
|
|
// returning (gardenID, objectID, plantID).
|
|
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
|
|
t.Helper()
|
|
gid := createGardenAPI(t, r, cookie, "G")
|
|
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
|
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
|
|
}, cookie)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
|
|
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
|
|
return gid, objID, plantID
|
|
}
|
|
|
|
// countChangeSets reads the history page and reports how many change sets exist.
|
|
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
|
|
t.Helper()
|
|
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
|
return len(sets)
|
|
}
|
|
|
|
// TestFillAndClearAPI covers the two routes end to end through the router.
|
|
//
|
|
// These exist because both operations were previously reachable ONLY through the
|
|
// agent toolbox, so on an instance with no model configured the most valuable
|
|
// bulk operation in the app did not exist at all.
|
|
func TestFillAndClearAPI(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
|
|
|
// Fill by compass name.
|
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
"plantId": plantID, "region": "all",
|
|
}, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
body := decodeMap(t, w.Body.Bytes())
|
|
created := int(body["created"].(float64))
|
|
if created == 0 {
|
|
t.Fatalf("fill created nothing: %s", w.Body.String())
|
|
}
|
|
if plops, _ := body["plantings"].([]any); len(plops) != created {
|
|
t.Errorf("created=%d but returned %d plantings", created, len(plops))
|
|
}
|
|
|
|
// Clear it: one call, and it reports what it removed.
|
|
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
|
|
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
|
|
}
|
|
|
|
// Clearing an already-empty bed is a no-op, not an error.
|
|
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("second clear: status %d", w.Code)
|
|
}
|
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
|
t.Errorf("second clear removed %d, want 0", n)
|
|
}
|
|
|
|
// Grid layout (#77) packs individual plants at true spacing — many more, small
|
|
// plops than the clump default on the same bed.
|
|
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
"plantId": plantID, "region": "all", "layout": "grid",
|
|
}, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= created {
|
|
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, created)
|
|
}
|
|
// An unknown layout is a 400, not a silent clump fill.
|
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
|
t.Fatalf("clear before bad-layout: %d", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
"plantId": plantID, "region": "all", "layout": "spiral",
|
|
}, cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("unknown layout = %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
|
// its own corner of the bed.
|
|
func TestFillRegionSelectionAPI(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
|
|
|
|
// Neither → 400. Both → 400. Accepting both and silently preferring one
|
|
// would make a client bug look like a geometry bug.
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("no region = %d, want 400", w.Code)
|
|
}
|
|
both := map[string]any{
|
|
"plantId": plantID, "region": "all",
|
|
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("both region and rect = %d, want 400", w.Code)
|
|
}
|
|
// An unknown compass name is rejected rather than silently filling nothing.
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
"plantId": plantID, "region": "middle-ish",
|
|
}, cookie); w.Code != http.StatusBadRequest {
|
|
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:
|
|
// +x east, -y north.
|
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
"plantId": plantID,
|
|
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
|
|
}, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
|
|
if len(plops) == 0 {
|
|
t.Fatal("rect fill created nothing")
|
|
}
|
|
for _, raw := range plops {
|
|
p := raw.(map[string]any)
|
|
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
|
|
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
|
|
// endpoint exists to restore.
|
|
//
|
|
// The UI used to clear a bed with a loop of PATCHes, and since every service
|
|
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
|
|
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
|
|
// directly: multi-row operations record together so they undo as one unit.
|
|
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
|
|
|
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
|
"plantId": plantID, "region": "all",
|
|
}, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
|
if created < 4 {
|
|
t.Fatalf("need several plops to make this meaningful, got %d", created)
|
|
}
|
|
|
|
before := countChangeSets(t, r, cookie, gid)
|
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
|
t.Fatalf("clear: status %d", w.Code)
|
|
}
|
|
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
|
|
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
|
|
}
|
|
}
|
|
|
|
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
|
|
// stranger gets 404 because existence is masked.
|
|
func TestFillClearPermissionsAPI(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
owner := registerAndCookie(t, r, "[email protected]")
|
|
viewer := registerAndCookie(t, r, "[email protected]")
|
|
stranger := registerAndCookie(t, r, "[email protected]")
|
|
|
|
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
|
|
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
|
|
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
|
|
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
fillBody := map[string]any{"plantId": plantID, "region": "all"}
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
|
|
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
|
|
t.Errorf("viewer clear = %d, want 403", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
|
|
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
|
|
t.Errorf("stranger clear = %d, want 404", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous fill = %d, want 401", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous clear = %d, want 401", w.Code)
|
|
}
|
|
}
|