Files
pad/internal/models/close_state_test.go
T
xarmian a231d0b65a fix(BUG-2367): a move or copy that would reopen, close or un-abandon an item must name the status instead of inheriting whatever survived migration (#1516)
Before this, every migrate door carried whichever done-field value survived
migration, or injected the destination default. A done task moved into
ideas silently reopened as `new`, and an abandoned item could land counted
as shipped.

Now every door (single move, bulk move, cross-workspace copy and its
preflight) compares the item's CloseState (open / done / abandoned, derived
from IsTerminalItem + IsAbandonedItem) before and after. A change is
refused unless the caller names the destination done field. Only the state
is compared, so two different values that both mean done carry without
asking. A destination with no done select field is exempt.

- move: 400 state_change_requires_value, details {field, options, from, to}
- bulk move: the same refusal in failed[]; `move` + `status` counts as supplied
- copy: 400 validation_error with the same sentence, after the unique check,
  in the preflight's order
- preflight: a needs_value row with reason `state_change`, which the web
  dialog's existing picker renders
- CLI: `hint: pad item move REF COLL --field status=<a|b|c>`

ToolSurfaceVersion 0.52 (behaviour bump). Missing needs_value routes on the
pane-menu move, CLI move and MCP are BUG-3200.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 15:53:06 -04:00

57 lines
2.8 KiB
Go

package models
import "testing"
// BUG-2367 item 4: only the STATE is compared (lead ruling, day 78).
func TestMigrateCloseStateChange(t *testing.T) {
tasks := CollectionSchema{Fields: []FieldDef{{Key: "status", Type: "select",
Options: []string{"open", "in-progress", "done", "cancelled"},
TerminalOptions: []string{"done", "cancelled"},
AbandonedOptions: []string{"cancelled"}}}}
ideas := CollectionSchema{Fields: []FieldDef{{Key: "status", Label: "Status", Type: "select",
Options: []string{"new", "planned", "implemented", "rejected"},
TerminalOptions: []string{"implemented", "rejected"},
AbandonedOptions: []string{"rejected"}, Default: "new"}}}
docs := CollectionSchema{Fields: []FieldDef{{Key: "category", Type: "text"}}}
none := CollectionSettings{}
cases := []struct {
name string
src map[string]any
srcS CollectionSchema
dst map[string]any
dstS CollectionSchema
supplied string
want *[2]CloseState
}{
{"done task defaulted to open idea", map[string]any{"status": "done"}, tasks, map[string]any{"status": "new"}, ideas, "", &[2]CloseState{CloseStateDone, CloseStateOpen}},
{"done and implemented are both delivered", map[string]any{"status": "done"}, tasks, map[string]any{"status": "implemented"}, ideas, "", nil},
{"cancelled to implemented flips abandoned to done", map[string]any{"status": "cancelled"}, tasks, map[string]any{"status": "implemented"}, ideas, "", &[2]CloseState{CloseStateAbandoned, CloseStateDone}},
{"cancelled to rejected stays abandoned", map[string]any{"status": "cancelled"}, tasks, map[string]any{"status": "rejected"}, ideas, "", nil},
{"open to open", map[string]any{"status": "open"}, tasks, map[string]any{"status": "new"}, ideas, "", nil},
{"supplied value is the override", map[string]any{"status": "done"}, tasks, map[string]any{"status": "new"}, ideas, "status", nil},
{"destination with no done field has no state", map[string]any{"status": "done"}, tasks, map[string]any{}, docs, "", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := MigrateCloseStateChange(tc.src, tc.srcS, none, tc.dst, tc.dstS, none,
func(k string) bool { return k == tc.supplied })
if tc.want == nil {
if got != nil {
t.Fatalf("want no change, got %+v", got)
}
return
}
if got == nil || got.From != tc.want[0] || got.To != tc.want[1] {
t.Fatalf("want %v→%v, got %+v", tc.want[0], tc.want[1], got)
}
})
}
ch := MigrateCloseStateChange(map[string]any{"status": "done"}, tasks, none, map[string]any{"status": "new"}, ideas, none, nil)
want := `this would change the item from done (status "done") to open (status "new"); set status explicitly to one of: new, planned, implemented, rejected`
if ch.Message() != want {
t.Fatalf("message:\n got %s\nwant %s", ch.Message(), want)
}
}