Files
xarmian 0cc033edd8 feat(items): content-anchored patch writes on an item with no live editor (PLAN-3578 step 1) (#2055)
POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.

expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).

CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 06:20:09 -04:00

58 lines
2.0 KiB
Go

package bodypatch
import (
"errors"
"reflect"
"testing"
)
func TestApply(t *testing.T) {
body := "alpha\nbeta\ngamma beta\n"
for _, c := range []struct {
name string
edits []Edit
want string
err any
}{
{"single unique", []Edit{{Old: "alpha", New: "ALPHA"}}, "ALPHA\nbeta\ngamma beta\n", nil},
{"edits apply in order to the evolving body", []Edit{{Old: "alpha", New: "x"}, {Old: "x\nbeta", New: "y"}}, "y\ngamma beta\n", nil},
{"missing", []Edit{{Old: "delta", New: "d"}}, "", &AnchorMissingError{}},
{"ambiguous without hint", []Edit{{Old: "beta", New: "b"}}, "", &AnchorAmbiguousError{}},
{"hint picks a match", []Edit{{Old: "beta", New: "B", LineHint: 3}}, "alpha\nbeta\ngamma B\n", nil},
{"hint on no match is still ambiguous", []Edit{{Old: "beta", New: "B", LineHint: 1}}, "", &AnchorAmbiguousError{}},
{"hint ignored when unique", []Edit{{Old: "gamma", New: "G", LineHint: 1}}, "alpha\nbeta\nG beta\n", nil},
{"empty old refused", []Edit{{Old: "alpha", New: "a"}, {Old: "", New: "z"}}, "", &EmptyAnchorError{}},
{"overlapping occurrences are ambiguous", []Edit{{Old: "aa", New: "b"}}, "", &AnchorAmbiguousError{}},
{"a later failure applies nothing", []Edit{{Old: "alpha", New: "a"}, {Old: "nope", New: "z"}}, "", &AnchorMissingError{}},
} {
t.Run(c.name, func(t *testing.T) {
in := body
if c.name == "overlapping occurrences are ambiguous" {
in = "aaa"
}
got, err := Apply(in, c.edits)
if c.err != nil {
target := reflect.New(reflect.TypeOf(c.err).Elem()).Interface()
if err == nil || !errors.As(err, &target) {
t.Fatalf("err = %v, want %T", err, c.err)
}
if got != "" {
t.Fatalf("a refused patch returned a body: %q", got)
}
return
}
if err != nil || got != c.want {
t.Fatalf("got %q, %v; want %q", got, err, c.want)
}
})
}
}
func TestAmbiguousNamesEveryLine(t *testing.T) {
_, err := Apply("x\ny x\nx", []Edit{{Old: "x", New: "z"}})
var amb *AnchorAmbiguousError
if !errors.As(err, &amb) || !reflect.DeepEqual(amb.Lines, []int{1, 2, 3}) {
t.Fatalf("err = %v", err)
}
}