Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+17
-1
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) {
|
||||
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
||||
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
|
||||
default:
|
||||
slog.Error("api: unhandled service error", "error", err)
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
|
||||
@@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
|
||||
// sentinel reads "invalid input"; a service that wraps it with a reason —
|
||||
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
|
||||
// — has that reason shown to the person verbatim, minus the sentinel prefix.
|
||||
// So anything wrapped this way is written for the keyboard, not the log (see
|
||||
// the note on domain.ErrInvalidInput).
|
||||
func inputMessage(err error) string {
|
||||
msg := err.Error()
|
||||
base := domain.ErrInvalidInput.Error()
|
||||
if msg == base {
|
||||
return msg
|
||||
}
|
||||
return strings.TrimPrefix(msg, base+": ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// TestInputMessage: the bare sentinel stays generic; a wrapped reason reaches
|
||||
// the person without the "invalid input: " prefix in front of it.
|
||||
func TestInputMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{domain.ErrInvalidInput, "invalid input"},
|
||||
{fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, "nonesuch/model"), `chat model "nonesuch/model": unknown provider`},
|
||||
{fmt.Errorf("loading: %w", domain.ErrInvalidInput), "loading: invalid input"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !errors.Is(c.err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("%v should still be an ErrInvalidInput", c.err)
|
||||
}
|
||||
if got := inputMessage(c.err); got != c.want {
|
||||
t.Errorf("inputMessage(%v) = %q, want %q", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -55,6 +55,10 @@ type objectFillRequest struct {
|
||||
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
||||
// is refused by the service (#77).
|
||||
Layout string `json:"layout"`
|
||||
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
|
||||
// local day; omitted, the server uses UTC today — which is tomorrow for an
|
||||
// evening gardener west of Greenwich, so clients that know better say so.
|
||||
PlantedAt *string `json:"plantedAt"`
|
||||
}
|
||||
|
||||
func (h *handlers) fillObject(c *gin.Context) {
|
||||
@@ -88,9 +92,9 @@ func (h *handlers) fillObject(c *gin.Context) {
|
||||
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, service.FillLayout(req.Layout))
|
||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
||||
} else {
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -159,10 +161,22 @@ func TestSettingsRejectsBadModel(t *testing.T) {
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
v := settingsVersion(t, r, admin)
|
||||
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad model: status %d, want 400", w.Code)
|
||||
}
|
||||
// The message says which field and which spec, so the page can show a reason
|
||||
// rather than a bare "invalid input".
|
||||
var body struct {
|
||||
Error struct{ Code, Message string } `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode bad-model body: %v", err)
|
||||
}
|
||||
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
|
||||
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
|
||||
}
|
||||
// agentEnabled must be a bool or null, not a string.
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||
|
||||
Reference in New Issue
Block a user