Files
xarmian 199fd208a8 fix(items): undo of an emptied line; a patch that changes nothing is refused (PLAN-3578 retro fixes 5+6) (#2089)
* fix(items): undo of an emptied line, and a patch that changes nothing is refused (PLAN-3578)

Retro CONVE-735 findings on #2058 and #2055.

Undo: an edit that deleted a line's only content recorded empty,
line-clipped context on both sides, so its inverse had no anchor and the
undo was refused as an empty anchor. ApplyWithContext now reaches across
the line breaks when the clipped context around an edit that wrote nothing
is empty. A patch that empties the WHOLE body still leaves nothing to
anchor on; its undo now says so and points at History instead of "old is
empty".

No-op: an edit whose old equals its new, or edits that cancel out, used to
answer success and advance seq with no version behind it, against "every
patch is a version". Both are refused 400, writing nothing: per edit up
front in the handler, and the combined case through bodypatch.ErrNoChange
on the no-room path.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(items): a no-op patch through an open editor is refused too (PLAN-3578)

Codex round 1 on d6c3cc381 (P2): edits that cancel out were refused on
the no-room path but still written on the open-editor path, where the ack
branch ignored ErrNoChange and wrote a version and a seq for a no-op. When
the tab's before and after are equal, the patch is now refused with the
same no-change 400, writing nothing.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(api): the patch endpoint's no-op refusal and the emptied-body undo (PLAN-3578)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 21:10:03 -04:00

90 lines
3.6 KiB
Go

package bodypatch
import (
"errors"
"strings"
"testing"
)
// PLAN-3578 step 3 (lead ruling): an undo is the inverse patch anchored on
// each edit's context, and it refuses unless every anchor occurs exactly once.
func roundTrip(t *testing.T, body string, edits []Edit) {
t.Helper()
after, applied, err := ApplyWithContext(body, edits)
if err != nil {
t.Fatalf("apply: %v", err)
}
if want, _ := Apply(body, edits); want != after {
t.Fatalf("ApplyWithContext disagrees with Apply: %q vs %q", after, want)
}
back, err := Apply(after, Invert(applied))
if err != nil {
t.Fatalf("undo refused: %v (applied %+v)", err, applied)
}
if back != body {
t.Fatalf("undo gave %q, want %q", back, body)
}
}
func TestUndoReplaceDeleteInsert(t *testing.T) {
body := "intro line\nthe quick brown fox jumps\nlast line\n"
t.Run("replace", func(t *testing.T) { roundTrip(t, body, []Edit{{Old: "quick", New: "slow"}}) })
t.Run("delete", func(t *testing.T) { roundTrip(t, body, []Edit{{Old: "brown ", New: ""}}) })
t.Run("insert", func(t *testing.T) { roundTrip(t, body, []Edit{{Old: "fox", New: "fox, then a dog,"}}) })
t.Run("delete a whole line", func(t *testing.T) { roundTrip(t, body, []Edit{{Old: "the quick brown fox jumps\n", New: ""}}) })
t.Run("several edits undo in reverse", func(t *testing.T) {
roundTrip(t, body, []Edit{{Old: "intro", New: "INTRO"}, {Old: "INTRO line\nthe", New: "x"}, {Old: "last", New: ""}})
})
t.Run("multibyte context is not split", func(t *testing.T) {
roundTrip(t, strings.Repeat("é", 30)+"target"+strings.Repeat("ü", 30), []Edit{{Old: "target", New: "T"}})
})
}
func TestUndoRefusesAfterALaterEditToItsText(t *testing.T) {
body := "alpha beta gamma\n"
after, applied, err := ApplyWithContext(body, []Edit{{Old: "beta", New: "BETA"}})
if err != nil {
t.Fatal(err)
}
// Someone rewrites the text around the edit afterwards.
later := strings.Replace(after, "alpha BETA", "omega BETA", 1)
if _, err := Apply(later, Invert(applied)); !errors.As(err, new(*AnchorMissingError)) {
t.Fatalf("an undo over a later change must refuse; got %v", err)
}
}
func TestUndoRefusesAnAmbiguousAnchor(t *testing.T) {
body := "x\n"
after, applied, err := ApplyWithContext(body, []Edit{{Old: "x", New: "y"}})
if err != nil {
t.Fatal(err)
}
// The same line now exists twice.
twice := after + after
if _, err := Apply(twice, Invert(applied)); !errors.As(err, new(*AnchorAmbiguousError)) {
t.Fatalf("an ambiguous undo anchor must refuse; got %v", err)
}
}
func TestContextIsBoundedAndLineClipped(t *testing.T) {
_, applied, err := ApplyWithContext("first line\n"+strings.Repeat("a", 100)+"Z"+strings.Repeat("b", 100)+"\nlast", []Edit{{Old: "Z", New: "z"}})
if err != nil {
t.Fatal(err)
}
a := applied[0]
if len(a.CtxBefore) != ContextChars || len(a.CtxAfter) != ContextChars || strings.Contains(a.CtxBefore+a.CtxAfter, "\n") {
t.Fatalf("context = %q / %q", a.CtxBefore, a.CtxAfter)
}
}
// Retro CONVE-735 finding on #2058: an edit that deletes a line's only
// content left empty line-clipped context on both sides, so the inverse's
// anchor was "" and the undo was refused as an empty anchor.
func TestUndoOfALineEmptiedOut(t *testing.T) {
t.Run("middle line", func(t *testing.T) { roundTrip(t, "alpha\nbeta\ngamma\n", []Edit{{Old: "beta", New: ""}}) })
t.Run("first line", func(t *testing.T) { roundTrip(t, "alpha\nbeta\n", []Edit{{Old: "alpha", New: ""}}) })
t.Run("last line, no trailing newline", func(t *testing.T) { roundTrip(t, "alpha\nbeta", []Edit{{Old: "beta", New: ""}}) })
t.Run("between blank lines", func(t *testing.T) { roundTrip(t, "a\n\nb\n\nc", []Edit{{Old: "b", New: ""}}) })
}