Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
203 lines
5.6 KiB
Go
203 lines
5.6 KiB
Go
package editor
|
|
|
|
// Differential fuzzing of the production edit entry points (the layer that
|
|
// enforces rune granularity): HandleInsert, HandleBackspace, HandleDelete and
|
|
// HandleReplaceRange, including selection-aware variants, are driven with
|
|
// random operations against a shadow model. After every op we check:
|
|
//
|
|
// - buffer content == model (no bytes lost/duplicated/reordered),
|
|
// - content is still valid UTF-8 (rune granularity held),
|
|
// - the chunk-size invariant (no chunk > 2x target),
|
|
// - the LineIndex equals a full recomputation (oracle),
|
|
// - the cursor stays within the file.
|
|
//
|
|
// This catches corruption that only appears at the API boundary: rune-width
|
|
// bugs (utf8BackspaceWidth/utf8AdvanceWidth), selection handling, and the
|
|
// IME rune->byte conversion, none of which the raw buffer fuzz sees.
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"pad/internal/io/pool/types"
|
|
)
|
|
|
|
func fuzzStateAPI(t *testing.T, initial string, chunkSize, ops int) {
|
|
t.Helper()
|
|
st := newChunkedState(t, initial, chunkSize)
|
|
cb := st.Editor.ChunkedBuffer
|
|
cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(initial)), 0, int64(len(initial)))
|
|
|
|
model := initial
|
|
var cursor int // every op path sets this before the invariant reads it
|
|
rng := rand.New(rand.NewSource(4242))
|
|
modelCap := 32 * 1024
|
|
|
|
setSelection := func(a, b int) {
|
|
st.Editor.SelectionAnchor = a
|
|
st.Editor.SelectionStart = a
|
|
st.Editor.SelectionEnd = b
|
|
}
|
|
|
|
for i := 0; i < ops; i++ {
|
|
if len(model) > modelCap {
|
|
p := runeStartPositions([]byte(model))
|
|
a, b := p[len(p)/4], p[len(p)/2]
|
|
setSelection(a, b)
|
|
HandleBackspace() // deletes the selection
|
|
model = model[:a] + model[b:]
|
|
cursor = a
|
|
st.Editor.CursorPosition = a
|
|
}
|
|
|
|
pos := func() int {
|
|
p := runeStartPositions([]byte(model))
|
|
return p[rng.Intn(len(p))]
|
|
}
|
|
switch rng.Intn(100) {
|
|
case 0: // 28%: plain insert at a random position
|
|
c := pos()
|
|
text := fuzzRuneString(rng, fuzzInsertSize(rng, 300))
|
|
st.Editor.CursorPosition = c
|
|
HandleInsert(text)
|
|
model = model[:c] + text + model[c:]
|
|
cursor = c + len(text)
|
|
case 1: // 12%: plain backspace
|
|
c := pos()
|
|
st.Editor.CursorPosition = c
|
|
cursor = c
|
|
HandleBackspace()
|
|
if c > 0 {
|
|
w := utf8BackspaceWidth(model[max0(c-4):c])
|
|
model = model[:c-w] + model[c:]
|
|
cursor = c - w
|
|
}
|
|
case 2: // 12%: plain delete (cursor stays put)
|
|
c := pos()
|
|
st.Editor.CursorPosition = c
|
|
cursor = c
|
|
HandleDelete()
|
|
if c < len(model) {
|
|
w := utf8AdvanceWidth(model[c:min4(c+4, len(model))])
|
|
model = model[:c] + model[c+w:]
|
|
}
|
|
case 3: // 8%: backspace with live selection
|
|
if len(model) == 0 {
|
|
continue
|
|
}
|
|
a, b := randomSelection(rng, model)
|
|
setSelection(a, b)
|
|
st.Editor.CursorPosition = pos()
|
|
HandleBackspace()
|
|
model = model[:a] + model[b:]
|
|
cursor = a
|
|
case 4: // 8%: delete with live selection
|
|
if len(model) == 0 {
|
|
continue
|
|
}
|
|
a, b := randomSelection(rng, model)
|
|
setSelection(a, b)
|
|
st.Editor.CursorPosition = pos()
|
|
HandleDelete()
|
|
model = model[:a] + model[b:]
|
|
cursor = a
|
|
case 5: // 8%: insert with live selection (replace selection)
|
|
if len(model) == 0 {
|
|
continue
|
|
}
|
|
a, b := randomSelection(rng, model)
|
|
text := fuzzRuneString(rng, fuzzInsertSize(rng, 100))
|
|
setSelection(a, b)
|
|
st.Editor.CursorPosition = pos()
|
|
HandleInsert(text)
|
|
model = model[:a] + text + model[b:]
|
|
cursor = a + len(text)
|
|
default: // 24%: IME replace at absolute rune indices (empty window
|
|
// => the whole buffer is the window, as in newChunkedState).
|
|
nr := utf8.RuneCountInString(model)
|
|
ra := rng.Intn(nr + 1)
|
|
rb := ra + rng.Intn(nr-ra+1)
|
|
text := fuzzRuneString(rng, fuzzInsertSize(rng, 300))
|
|
bs := runeStartToByteOracle([]byte(model), ra)
|
|
be := runeStartToByteOracle([]byte(model), rb)
|
|
HandleReplaceRange(ra, rb, text)
|
|
model = model[:bs] + text + model[be:]
|
|
cursor = bs + len(text)
|
|
}
|
|
|
|
// --- Invariants after every op --------------------------------
|
|
full, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("op %d: FullContent: %v", i, err)
|
|
}
|
|
if full != model {
|
|
at := 0
|
|
for at < len(full) && at < len(model) && full[at] == model[at] {
|
|
at++
|
|
}
|
|
t.Fatalf("op %d: buffer != model (got %d, want %d bytes, first diff at %d)",
|
|
i, len(full), len(model), at)
|
|
}
|
|
if !utf8.ValidString(full) {
|
|
t.Fatalf("op %d: buffer is not valid UTF-8", i)
|
|
}
|
|
assertMaxChunkSize(t, cb)
|
|
assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d", i))
|
|
if got := st.Editor.CursorPosition; got != cursor {
|
|
t.Fatalf("op %d: cursor=%d, want %d (len %d)", i, got, cursor, len(model))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStateAPI_Fuzz_EditOps(t *testing.T) {
|
|
initial := fuzzRuneString(rand.New(rand.NewSource(7)), 300) +
|
|
"\nsecond line\n第三行\n" + fuzzRuneString(rand.New(rand.NewSource(8)), 300)
|
|
fuzzStateAPI(t, initial, 32, 2000)
|
|
}
|
|
|
|
func TestStateAPI_Fuzz_SmallFile(t *testing.T) {
|
|
// Tiny file, tiny chunks: every edit is at or across a boundary.
|
|
initial := "ab\ncd\n"
|
|
fuzzStateAPI(t, initial, 3, 1500)
|
|
}
|
|
|
|
// randomSelection returns a rune-aligned [a,b) with a < b suitable for
|
|
// setting a live selection (selActive requires end > start). If the model is
|
|
// empty it returns (0, 0) and the caller must skip the selection op.
|
|
func randomSelection(rng *rand.Rand, model string) (int, int) {
|
|
if len(model) == 0 {
|
|
return 0, 0
|
|
}
|
|
p := runeStartPositions([]byte(model))
|
|
if len(p) < 2 {
|
|
return 0, len(model)
|
|
}
|
|
for {
|
|
a := p[rng.Intn(len(p))]
|
|
b := p[rng.Intn(len(p))]
|
|
if a == b {
|
|
continue
|
|
}
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
return a, b
|
|
}
|
|
}
|
|
|
|
func max0(n int) int {
|
|
if n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func min4(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|