Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
458 lines
15 KiB
Go
458 lines
15 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"pad/internal/io/pool/types"
|
|
)
|
|
|
|
// newTestBuffer builds a fully-loaded (in-range) buffer from content using the
|
|
// on-open path (SetContent), so all chunks are resident.
|
|
func newTestBuffer(t *testing.T, content []byte) *ChunkedBuffer {
|
|
t.Helper()
|
|
cb := NewChunkedBuffer("/test.txt", DefaultChunkSize, nil, "")
|
|
cb.SetContent(content)
|
|
return cb
|
|
}
|
|
|
|
// TestSetContent_MultipleChunks verifies the whole-file load splits content
|
|
// into ordered chunks and Content reconstructs it exactly.
|
|
func TestSetContent_MultipleChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
// 200KB / 64KB = 3 full chunks (64KB each) + 1 partial (8KB) = 4 chunks.
|
|
if got := len(cb.chunks); got != 4 {
|
|
t.Fatalf("expected 4 chunks, got %d", got)
|
|
}
|
|
if cb.FileLen() != int64(len(content)) {
|
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(content))
|
|
}
|
|
full, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent error: %v", err)
|
|
}
|
|
if full != string(content) {
|
|
t.Fatalf("FullContent mismatch after SetContent")
|
|
}
|
|
// Spot-check a range that spans the chunk-0/chunk-1 boundary.
|
|
seg := cb.Content(64*1024-10, 64*1024+10)
|
|
if seg != string(content[64*1024-10:64*1024+10]) {
|
|
t.Fatalf("Content across chunk boundary mismatch: got %q", seg)
|
|
}
|
|
}
|
|
|
|
// TestInsertShiftsLaterChunks is the core drift-fix test: an insert in chunk 0
|
|
// must shift every later byte, and Content must still return the correct byte
|
|
// at the shifted offset (the old fixed-slot i*chunkSize model failed here).
|
|
func TestInsertShiftsLaterChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
const insLen = 3
|
|
cb.Insert(0, strings.Repeat("X", insLen))
|
|
|
|
// A byte originally at position 100000 (chunk 1) is now at 100003.
|
|
want := content[100000]
|
|
if got := cb.Content(100000+insLen, 100001+insLen); got != string(want) {
|
|
t.Fatalf("byte at shifted offset 100003 = %q, want %q", got, string(want))
|
|
}
|
|
// The inserted prefix is present.
|
|
if got := cb.Content(0, insLen); got != strings.Repeat("X", insLen) {
|
|
t.Fatalf("prefix = %q, want %q", got, strings.Repeat("X", insLen))
|
|
}
|
|
// Full content is the insert + original.
|
|
full, _ := cb.FullContent()
|
|
if full != strings.Repeat("X", insLen)+string(content) {
|
|
t.Fatalf("FullContent after insert mismatch (len=%d, want %d)", len(full), insLen+len(content))
|
|
}
|
|
}
|
|
|
|
// TestDeleteShiftsLaterChunks verifies a deletion in an early chunk shifts
|
|
// later bytes left and Content/FullContent stay exact.
|
|
func TestDeleteShiftsLaterChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
const delLen = 100
|
|
cb.Delete(0, delLen)
|
|
|
|
// A byte originally at 100000 is now at 99900.
|
|
want := content[100000]
|
|
if got := cb.Content(100000-delLen, 100001-delLen); got != string(want) {
|
|
t.Fatalf("byte at shifted offset = %q, want %q", got, string(want))
|
|
}
|
|
full, _ := cb.FullContent()
|
|
if full != string(content[delLen:]) {
|
|
t.Fatalf("FullContent after delete mismatch (len=%d, want %d)", len(full), len(content)-delLen)
|
|
}
|
|
}
|
|
|
|
// TestDeleteSpanningChunks verifies a deletion that spans multiple chunk
|
|
// boundaries is exact.
|
|
func TestDeleteSpanningChunks(t *testing.T) {
|
|
content := make([]byte, 200*1024)
|
|
for i := range content {
|
|
content[i] = byte(i % 251)
|
|
}
|
|
cb := newTestBuffer(t, content)
|
|
|
|
// Delete 64KB+50 bytes starting at 30KB (spans chunks 0,1,2).
|
|
const start = 30 * 1024
|
|
const n = 64*1024 + 50
|
|
cb.Delete(start, n)
|
|
|
|
expected := make([]byte, 0, len(content)-n)
|
|
expected = append(expected, content[:start]...)
|
|
expected = append(expected, content[start+n:]...)
|
|
full, _ := cb.FullContent()
|
|
if !bytes.Equal([]byte(full), expected) {
|
|
t.Fatalf("FullContent after spanning delete mismatch (len=%d, want %d)", len(full), len(expected))
|
|
}
|
|
if cb.FileLen() != int64(len(expected)) {
|
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(expected))
|
|
}
|
|
}
|
|
|
|
// TestRuneIndexToByteAfterEdit verifies the IME rune->byte bridge stays correct
|
|
// after a length-changing edit (ASCII + multibyte).
|
|
func TestRuneIndexToByteAfterEdit(t *testing.T) {
|
|
// 2 chunks of ASCII so the edit crosses into chunk arithmetic.
|
|
var b bytes.Buffer
|
|
for i := 0; i < 100*1024; i++ {
|
|
b.WriteByte(byte('a' + i%26))
|
|
}
|
|
cb := newTestBuffer(t, b.Bytes())
|
|
|
|
// Insert a multibyte rune ("é" = 2 bytes) at rune 0.
|
|
cb.Insert(0, "é")
|
|
// Rune 0 is now 'é' (bytes 0..1). Rune 1 is the original first 'a' at byte 2.
|
|
if got := cb.RuneIndexToByte(0); got != 0 {
|
|
t.Fatalf("RuneIndexToByte(0)=%d, want 0", got)
|
|
}
|
|
if got := cb.RuneIndexToByte(1); got != 2 {
|
|
t.Fatalf("RuneIndexToByte(1)=%d, want 2", got)
|
|
}
|
|
// New rune R (R>0) is the original rune R-1 (originally at byte R-1), now
|
|
// at byte (R-1)+2 = R+1 (é is 1 rune, 2 bytes). Check one deep in chunk 1
|
|
// (past the 64KB boundary) to confirm the shift survives chunk boundaries.
|
|
deep := 70000
|
|
if got := cb.RuneIndexToByte(deep); got != deep+1 {
|
|
t.Fatalf("RuneIndexToByte(%d)=%d, want %d", deep, got, deep+1)
|
|
}
|
|
}
|
|
|
|
// TestContent_BoundsAndEmpty guards edge cases in the rewritten Content.
|
|
func TestContent_BoundsAndEmpty(t *testing.T) {
|
|
cb := newTestBuffer(t, []byte("hello world"))
|
|
if got := cb.Content(0, 5); got != "hello" {
|
|
t.Fatalf("Content(0,5)=%q, want hello", got)
|
|
}
|
|
if got := cb.Content(6, 11); got != "world" {
|
|
t.Fatalf("Content(6,11)=%q, want world", got)
|
|
}
|
|
// Out-of-range end clamps to fileLen.
|
|
if got := cb.Content(0, 1000); got != "hello world" {
|
|
t.Fatalf("Content(0,1000)=%q, want full", got)
|
|
}
|
|
// start >= end is empty.
|
|
if got := cb.Content(5, 5); got != "" {
|
|
t.Fatalf("Content(5,5)=%q, want empty", got)
|
|
}
|
|
// Empty buffer.
|
|
empty := newTestBuffer(t, nil)
|
|
if got := empty.Content(0, 10); got != "" {
|
|
t.Fatalf("empty Content=%q, want empty", got)
|
|
}
|
|
if got, _ := empty.FullContent(); got != "" {
|
|
t.Fatalf("empty FullContent=%q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestAppendAtEnd inserts at the very end of the buffer (pos == fileLen), which
|
|
// maps to the last chunk's end.
|
|
func TestAppendAtEnd(t *testing.T) {
|
|
cb := newTestBuffer(t, []byte("abcdef"))
|
|
cb.Insert(6, "XYZ")
|
|
full, _ := cb.FullContent()
|
|
if full != "abcdefXYZ" {
|
|
t.Fatalf("append at end = %q, want abcdefXYZ", full)
|
|
}
|
|
if cb.FileLen() != 9 {
|
|
t.Fatalf("FileLen=%d, want 9", cb.FileLen())
|
|
}
|
|
}
|
|
|
|
// assertMaxChunkSize verifies the chunking invariant: no chunk may exceed
|
|
// twice the target size (Insert splits oversized chunks in half).
|
|
func assertMaxChunkSize(t *testing.T, cb *ChunkedBuffer) {
|
|
t.Helper()
|
|
for i, c := range cb.chunks {
|
|
if len(c) > 2*cb.chunkSize {
|
|
t.Fatalf("chunk %d is %d bytes, want <= %d (2 x %d)", i, len(c), 2*cb.chunkSize, cb.chunkSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInsert_SustainedTyping_SplitsOversizedChunks simulates sustained typing
|
|
// at one spot (many small inserts into the same chunk). Without the split, the
|
|
// target chunk grows without bound and every edit copies the whole grown
|
|
// chunk (quadratic total). With the split, chunks stay O(chunkSize).
|
|
func TestInsert_SustainedTyping_SplitsOversizedChunks(t *testing.T) {
|
|
const chunkSize = 64
|
|
cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "")
|
|
seed := strings.Repeat("seed line\n", chunkSize/8) // 64 bytes = 1 chunk
|
|
cb.SetContent([]byte(seed))
|
|
|
|
// Type 2000 characters, each inserted immediately after the previous one,
|
|
// starting at position 10 (inside chunk 0).
|
|
var typed strings.Builder
|
|
pos := 10
|
|
for i := 0; i < 2000; i++ {
|
|
r := rune('a' + i%26)
|
|
cb.Insert(pos, string(r))
|
|
pos++
|
|
typed.WriteRune(r)
|
|
if i%200 == 0 {
|
|
assertMaxChunkSize(t, cb)
|
|
}
|
|
}
|
|
assertMaxChunkSize(t, cb)
|
|
want := seed[:10] + typed.String() + seed[10:]
|
|
full, _ := cb.FullContent()
|
|
if full != want {
|
|
t.Fatalf("FullContent mismatch after sustained inserts (len %d vs %d)", len(full), len(want))
|
|
}
|
|
if cb.FileLen() != int64(len(want)) {
|
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(want))
|
|
}
|
|
}
|
|
|
|
// TestInsert_LargePaste_EmptyBuffer verifies that a large first paste into an
|
|
// empty buffer is chunked (not one oversized chunk) and reconstructs exactly.
|
|
func TestInsert_LargePaste_EmptyBuffer(t *testing.T) {
|
|
cb := NewChunkedBuffer("/test.txt", 64*1024, nil, "")
|
|
paste := strings.Repeat("0123456789", 100*1024) // 1 MB
|
|
cb.Insert(0, paste)
|
|
assertMaxChunkSize(t, cb)
|
|
if got := len(cb.chunks); got != 16 {
|
|
t.Fatalf("expected 16 chunks for 1MB/64KB, got %d", got)
|
|
}
|
|
full, _ := cb.FullContent()
|
|
if full != paste {
|
|
t.Fatalf("FullContent mismatch after large paste")
|
|
}
|
|
}
|
|
|
|
// TestInsert_SplitContentIntegrity checks that Content stays exact across the
|
|
// NEW chunk boundary created by a split, including spans that cross it.
|
|
func TestInsert_SplitContentIntegrity(t *testing.T) {
|
|
const chunkSize = 64
|
|
cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "")
|
|
cb.SetContent([]byte(strings.Repeat("x", 64)))
|
|
// 200 inserts of 2 chars at pos 0: the first chunk grows to 464+ bytes and
|
|
// must be split several times.
|
|
for i := 0; i < 200; i++ {
|
|
cb.Insert(0, "AB")
|
|
}
|
|
want := strings.Repeat("AB", 200) + strings.Repeat("x", 64)
|
|
full, _ := cb.FullContent()
|
|
if full != want {
|
|
t.Fatalf("FullContent mismatch: got %d bytes, want %d", len(full), len(want))
|
|
}
|
|
// Span across several chunk boundaries near the front.
|
|
seg := cb.Content(0, 200)
|
|
if seg != want[:200] {
|
|
t.Fatalf("Content(0,200) mismatch across split boundary")
|
|
}
|
|
seg = cb.Content(50, 300)
|
|
if seg != want[50:300] {
|
|
t.Fatalf("Content(50,300) mismatch across split boundary")
|
|
}
|
|
}
|
|
|
|
// int32SlicesEqual reports whether two []int32 are element-wise equal.
|
|
func int32SlicesEqual(a, b []int32) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// lineStartOffsets computes the ground-truth line-start byte offsets for
|
|
// content: position 0 plus the byte after each '\n'.
|
|
func lineStartOffsets(content []byte) []int32 {
|
|
off := make([]int32, 0, 16)
|
|
off = append(off, 0)
|
|
for i, b := range content {
|
|
if b == '\n' {
|
|
off = append(off, int32(i+1))
|
|
}
|
|
}
|
|
return off
|
|
}
|
|
|
|
// assertLineIndexMatchesOracle verifies the buffer's incrementally-maintained
|
|
// LineIndex is exactly the one a full recomputation from the content would
|
|
// produce. This is the regression guard for newline handling in
|
|
// UpdateLineIndexAfterInsert / UpdateLineIndexAfterDelete.
|
|
func assertLineIndexMatchesOracle(t *testing.T, cb *ChunkedBuffer, label string) {
|
|
t.Helper()
|
|
full, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("%s: FullContent: %v", label, err)
|
|
}
|
|
want := lineStartOffsets([]byte(full))
|
|
if cb.LineIndex == nil {
|
|
t.Fatalf("%s: LineIndex is nil", label)
|
|
}
|
|
if !int32SlicesEqual(cb.LineIndex.Offsets, want) {
|
|
t.Fatalf("%s: line index drift\n got %v\n want %v", label, cb.LineIndex.Offsets, want)
|
|
}
|
|
if cb.LineIndex.Size != int64(len(full)) {
|
|
t.Fatalf("%s: LineIndex.Size=%d, want %d", label, cb.LineIndex.Size, len(full))
|
|
}
|
|
}
|
|
|
|
// TestLineIndex_InsertNewlines drives inserts that add newlines through the
|
|
// incremental update and checks the oracle invariant after each step.
|
|
func TestLineIndex_InsertNewlines(t *testing.T) {
|
|
base := "aaa\nbbb\nccc"
|
|
cb := newTestBuffer(t, []byte(base))
|
|
cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base)))
|
|
|
|
steps := []struct {
|
|
pos int
|
|
text string
|
|
}{
|
|
{7, "\n"}, // empty line after "bbb"
|
|
{0, "\n"}, // leading empty line
|
|
{len(base) + 2, "x\ny\n"}, // append two lines at EOF (after edits)
|
|
{4, "QQ\n"}, // insert at start of "bbb" line, with newline
|
|
{1000000, "zz"}, // insert far past EOF (clamps in Insert)
|
|
}
|
|
for i, s := range steps {
|
|
// Insert clamps pos to EOF; use the same clamped pos for both the
|
|
// buffer edit and the index update, as production code does (the
|
|
// cursor is always within the buffer).
|
|
p := s.pos
|
|
if p > int(cb.FileLen()) {
|
|
p = int(cb.FileLen())
|
|
}
|
|
cb.Insert(p, s.text)
|
|
cb.UpdateLineIndexAfterInsert(p, s.text)
|
|
assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("insert step %d (pos=%d text=%q)", i, p, s.text))
|
|
}
|
|
}
|
|
|
|
// TestLineIndex_DeleteNewlines drives deletions that remove newlines / whole
|
|
// lines / from line starts through the incremental update and checks the
|
|
// oracle invariant after each step.
|
|
func TestLineIndex_DeleteNewlines(t *testing.T) {
|
|
base := "aaa\nbbb\nccc\nddd"
|
|
cb := newTestBuffer(t, []byte(base))
|
|
cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base)))
|
|
|
|
// (start, end) ranges chosen to exercise: mid-line, across a newline,
|
|
// a whole line, from a line start, and the file start.
|
|
steps := []struct {
|
|
start, end int
|
|
}{
|
|
{3, 4}, // delete the '\n' after aaa -> merge aaa+bbb
|
|
{4, 7}, // delete "bbb" (after merge: content is aaabbb\nddd...)
|
|
{0, 3}, // delete from file start
|
|
{3, 8}, // delete "bb\n" region
|
|
}
|
|
for i, s := range steps {
|
|
full, _ := cb.FullContent()
|
|
if s.start > len(full) || s.end > len(full) {
|
|
t.Fatalf("step %d: range [%d,%d) beyond content len %d", i, s.start, s.end, len(full))
|
|
}
|
|
cb.Delete(s.start, s.end-s.start)
|
|
cb.UpdateLineIndexAfterDelete(s.start, s.end)
|
|
assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("delete step %d [%d,%d)", i, s.start, s.end))
|
|
}
|
|
}
|
|
|
|
// TestLineIndex_MixedEditSequence interleaves inserts and deletes (including
|
|
// IME-style replace = delete+insert at the same point) and checks the oracle
|
|
// after each operation.
|
|
func TestLineIndex_MixedEditSequence(t *testing.T) {
|
|
base := "hello\nworld\nfoo\nbar\nbaz"
|
|
cb := newTestBuffer(t, []byte(base))
|
|
cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base)))
|
|
|
|
ops := []struct {
|
|
desc string
|
|
isIns bool
|
|
pos int
|
|
text string // for insert
|
|
start int // for delete
|
|
end int // for delete
|
|
}{
|
|
{desc: "insert newline mid", isIns: true, pos: 3, text: "\n"},
|
|
{desc: "delete across nl", start: 6, end: 10},
|
|
{desc: "replace with multi-line", isIns: false, pos: 2, text: "X\nY\nZ", start: 2, end: 4},
|
|
{desc: "delete whole line", start: 0, end: 4},
|
|
{desc: "insert at eof", isIns: true, pos: 999, text: "end\n"},
|
|
}
|
|
for i, op := range ops {
|
|
full, _ := cb.FullContent()
|
|
switch {
|
|
case op.isIns:
|
|
p := op.pos
|
|
if p > len(full) {
|
|
p = len(full)
|
|
}
|
|
cb.Insert(p, op.text)
|
|
cb.UpdateLineIndexAfterInsert(p, op.text)
|
|
case op.text != "":
|
|
// replace: delete [start,end) then insert at start, mirroring
|
|
// HandleReplaceRange ordering.
|
|
s, e := op.start, op.end
|
|
if s > len(full) {
|
|
s = len(full)
|
|
}
|
|
if e > len(full) {
|
|
e = len(full)
|
|
}
|
|
if e > s {
|
|
cb.Delete(s, e-s)
|
|
cb.UpdateLineIndexAfterDelete(s, e)
|
|
}
|
|
cb.Insert(s, op.text)
|
|
cb.UpdateLineIndexAfterInsert(s, op.text)
|
|
default:
|
|
s, e := op.start, op.end
|
|
if s > len(full) {
|
|
s = len(full)
|
|
}
|
|
if e > len(full) {
|
|
e = len(full)
|
|
}
|
|
if e > s {
|
|
cb.Delete(s, e-s)
|
|
cb.UpdateLineIndexAfterDelete(s, e)
|
|
}
|
|
}
|
|
assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d (%s)", i, op.desc))
|
|
}
|
|
}
|