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.
225 lines
7.0 KiB
Go
225 lines
7.0 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"pad/internal/editor"
|
|
)
|
|
|
|
// lineFile builds a file of nLines lines, each exactly 512 bytes:
|
|
// "LINE%06d:" (11) + 500 'x' (500) + "\n" (1). Line i therefore starts at
|
|
// byte i*512, so with the default 64 KiB chunk size, lines 128, 256, 384, ...
|
|
// start exactly on chunk boundaries. The file ends with '\n', so the line
|
|
// index has nLines+1 entries (a trailing empty line).
|
|
func lineFile(nLines int) string {
|
|
var sb strings.Builder
|
|
for i := 0; i < nLines; i++ {
|
|
fmt.Fprintf(&sb, "LINE%06d:", i)
|
|
sb.WriteString(strings.Repeat("x", 500))
|
|
sb.WriteByte('\n')
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
const (
|
|
chunkBytes = 64 * 1024
|
|
lineBytes = 512
|
|
)
|
|
|
|
// TestRealFile_ChunkBoundary_InsertAtBoundary inserts at a line that starts
|
|
// exactly on a chunk boundary and verifies the buffer and disk agree.
|
|
func TestRealFile_ChunkBoundary_InsertAtBoundary(t *testing.T) {
|
|
content := lineFile(500) // 256 KiB, 4 chunks
|
|
h, path := realFileHarness(t, "boundary.txt", content)
|
|
defer h.Cleanup()
|
|
|
|
pos := 128 * lineBytes // start of LINE000128 == byte 65536 == chunk edge
|
|
if pos != chunkBytes {
|
|
t.Fatalf("test premise broken: %d != %d", pos, chunkBytes)
|
|
}
|
|
original, _ := h.FullContent()
|
|
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = pos
|
|
editor.HandleInsert("INSERTED-LINE\n")
|
|
}); err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
want := original[:pos] + "INSERTED-LINE\n" + original[pos:]
|
|
got, _ := h.FullContent()
|
|
if got != want {
|
|
t.Fatalf("buffer mismatch after boundary insert (len %d vs %d)", len(got), len(want))
|
|
}
|
|
// 500 lines + trailing empty line + 1 new line = 502 index entries.
|
|
checkLineIndex(t, h, want, 502)
|
|
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if disk := readDisk(t, path); disk != want {
|
|
t.Fatalf("disk mismatch after boundary insert (len %d vs %d)", len(disk), len(want))
|
|
}
|
|
}
|
|
|
|
// TestRealFile_ChunkBoundary_DeleteAcrossBoundary deletes a range that spans
|
|
// a chunk boundary (via selection) and verifies consistency.
|
|
func TestRealFile_ChunkBoundary_DeleteAcrossBoundary(t *testing.T) {
|
|
content := lineFile(500)
|
|
h, path := realFileHarness(t, "boundary.txt", content)
|
|
defer h.Cleanup()
|
|
|
|
pos := 256 * lineBytes // second chunk edge (byte 131072)
|
|
lo, hi := pos-10, pos+10
|
|
original, _ := h.FullContent()
|
|
|
|
if err := h.WithState(func(st *editor.State) {
|
|
editor.SetSelection(lo, hi)
|
|
editor.HandleBackspace()
|
|
}); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
want := original[:lo] + original[hi:]
|
|
got, _ := h.FullContent()
|
|
if got != want {
|
|
t.Fatalf("buffer mismatch after cross-boundary delete (len %d vs %d)", len(got), len(want))
|
|
}
|
|
// The range includes the '\n' ending line 255 (byte pos-1), so lines 255
|
|
// and 256 merge into one: 501 entries -> 500.
|
|
checkLineIndex(t, h, want, 500)
|
|
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if disk := readDisk(t, path); disk != want {
|
|
t.Fatalf("disk mismatch after cross-boundary delete")
|
|
}
|
|
}
|
|
|
|
// TestRealFile_ChunkBoundary_SustainedTypingAtBoundary inserts a large string
|
|
// at a chunk edge, forcing chunk splits (per-edit copy bound) and line-index
|
|
// shifts, then verifies the result byte-for-byte.
|
|
func TestRealFile_ChunkBoundary_SustainedTypingAtBoundary(t *testing.T) {
|
|
content := lineFile(500)
|
|
h, path := realFileHarness(t, "boundary.txt", content)
|
|
defer h.Cleanup()
|
|
|
|
pos := 384 * lineBytes // third chunk edge
|
|
original, _ := h.FullContent()
|
|
|
|
// 3000 chars with a single trailing newline: inserts one new line at the
|
|
// top of LINE000384 and pushes everything after it down.
|
|
pasted := strings.Repeat("p", 2990) + "PASTE-END\n" // len 3000
|
|
if len(pasted) != 3000 {
|
|
t.Fatalf("pasted len = %d", len(pasted))
|
|
}
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = pos
|
|
editor.HandleInsert(pasted)
|
|
}); err != nil {
|
|
t.Fatalf("paste: %v", err)
|
|
}
|
|
want := original[:pos] + pasted + original[pos:]
|
|
got, _ := h.FullContent()
|
|
if got != want {
|
|
t.Fatalf("buffer mismatch after sustained typing at boundary (len %d vs %d)", len(got), len(want))
|
|
}
|
|
// 501 original entries (incl. trailing empty line) + 1 new line.
|
|
checkLineIndex(t, h, want, 502)
|
|
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if disk := readDisk(t, path); disk != want {
|
|
t.Fatalf("disk mismatch after sustained typing at boundary")
|
|
}
|
|
}
|
|
|
|
// TestRealFile_ChunkBoundary_UTF8SplitAcrossBoundary verifies a file whose
|
|
// multi-byte rune straddles a chunk boundary loads intact and edits cleanly
|
|
// around that rune.
|
|
func TestRealFile_ChunkBoundary_UTF8SplitAcrossBoundary(t *testing.T) {
|
|
// "é" is 2 bytes at offset 65535..65537, split by the 65536 chunk edge.
|
|
content := strings.Repeat("A", chunkBytes-1) + "é" + strings.Repeat("B", 100)
|
|
h, path := realFileHarness(t, "utf8.txt", content)
|
|
defer h.Cleanup()
|
|
|
|
got, _ := h.FullContent()
|
|
if got != content {
|
|
t.Fatalf("loaded buffer != original (rune split across chunks corrupted? len %d vs %d)", len(got), len(content))
|
|
}
|
|
|
|
// Delete the é (2 bytes) from after it.
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = chunkBytes - 1 + 2
|
|
editor.HandleBackspace()
|
|
}); err != nil {
|
|
t.Fatalf("backspace: %v", err)
|
|
}
|
|
want := strings.Repeat("A", chunkBytes-1) + strings.Repeat("B", 100)
|
|
got, _ = h.FullContent()
|
|
if got != want {
|
|
t.Fatalf("buffer = %d bytes, want %d (é not removed cleanly)", len(got), len(want))
|
|
}
|
|
|
|
// Insert a fresh é before the B run: exercises insert at the same spot.
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = chunkBytes - 1
|
|
editor.HandleInsert("é")
|
|
}); err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
got, _ = h.FullContent()
|
|
if got != content {
|
|
t.Fatalf("buffer != original after re-insert of é")
|
|
}
|
|
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if disk := readDisk(t, path); disk != content {
|
|
t.Fatalf("disk != original after é round-trip")
|
|
}
|
|
}
|
|
|
|
// TestRealFile_ChunkBoundary_EditsAtBothEnds performs inserts at byte 0 and at
|
|
// EOF on a multi-chunk file, the two extreme positions relative to chunks.
|
|
func TestRealFile_ChunkBoundary_EditsAtBothEnds(t *testing.T) {
|
|
content := lineFile(500)
|
|
h, path := realFileHarness(t, "boundary.txt", content)
|
|
defer h.Cleanup()
|
|
|
|
original, _ := h.FullContent()
|
|
|
|
// Prepend at byte 0.
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = 0
|
|
editor.HandleInsert("TOP\n")
|
|
}); err != nil {
|
|
t.Fatalf("prepend: %v", err)
|
|
}
|
|
|
|
// Append at EOF.
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = int(st.Editor.ChunkedBuffer.FileLen())
|
|
editor.HandleInsert("BOTTOM\n")
|
|
}); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
want := "TOP\n" + original + "BOTTOM\n"
|
|
got, _ := h.FullContent()
|
|
if got != want {
|
|
t.Fatalf("buffer mismatch after end edits (len %d vs %d)", len(got), len(want))
|
|
}
|
|
// 501 original entries + 2 (prepend + append).
|
|
checkLineIndex(t, h, want, 503)
|
|
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if disk := readDisk(t, path); disk != want {
|
|
t.Fatalf("disk mismatch after end edits")
|
|
}
|
|
}
|