The tree was formatted with an older gofmt; go1.27's gofmt additionally wants: EOF exactly one newline (no trailing blank lines), imports sorted alphabetically within a block, mixed-precedence binary expressions re-spaced for grouping ((a+b)/c), single-field composite literals un-aligned, adjacent one-line method signatures aligned, and one-line bodies containing a compound statement expanded. Applied repo-wide (31 files under internal/); pure formatting, no semantic changes — build and the full test suite pass.
251 lines
8.1 KiB
Go
251 lines
8.1 KiB
Go
package editor
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pad/internal/io/pool/mock"
|
|
)
|
|
|
|
// newStringState builds a minimal State that uses the (small-file) string
|
|
// buffer fallback rather than a ChunkedBuffer.
|
|
func newStringState(buf string) *State {
|
|
st := NewState()
|
|
TheState = st
|
|
st.Editor.Buffer = buf
|
|
st.Editor.ChunkedBuffer = nil
|
|
st.Editor.CursorPosition = len(buf)
|
|
return st
|
|
}
|
|
|
|
// newChunkedState builds a minimal State backed by a ChunkedBuffer over a
|
|
// mock filesystem. chunkSize is small so a short file spans several chunks.
|
|
func newChunkedState(t *testing.T, content string, chunkSize int) *State {
|
|
t.Helper()
|
|
mockFS := mock.NewFileSystem()
|
|
filename := "/ime_range.txt"
|
|
mockFS.AddFile(filename, []byte(content), time.Now())
|
|
cb := NewChunkedBuffer(filename, chunkSize, mockFS, "")
|
|
// Full-load via the on-open path (all chunks resident), matching production.
|
|
cb.SetContent([]byte(content))
|
|
st := NewState()
|
|
TheState = st
|
|
st.Editor.Filename = filename
|
|
st.Editor.ChunkedBuffer = cb
|
|
st.Editor.Buffer = ""
|
|
st.Editor.CursorPosition = len(content)
|
|
return st
|
|
}
|
|
|
|
func TestRuneIndexToByteStr(t *testing.T) {
|
|
cases := []struct {
|
|
s string
|
|
n int
|
|
want int
|
|
}{
|
|
{"", 0, 0},
|
|
{"abc", 0, 0},
|
|
{"abc", 1, 1},
|
|
{"abc", 3, 3},
|
|
{"abc", 10, 3}, // past end -> len(s)
|
|
// "héllo": h(1) é(2) l(1) l(1) o(1) -> byte offsets 0,1,3,4,5
|
|
{"héllo", 0, 0},
|
|
{"héllo", 1, 1}, // after 'h'
|
|
{"héllo", 2, 3}, // after 'é' (2 bytes)
|
|
{"héllo", 5, 6}, // end
|
|
// "日本語": each rune is 3 bytes
|
|
{"日本語", 0, 0},
|
|
{"日本語", 1, 3},
|
|
{"日本語", 2, 6},
|
|
{"日本語", 3, 9},
|
|
}
|
|
for _, c := range cases {
|
|
if got := runeIndexToByteStr(c.s, c.n); got != c.want {
|
|
t.Errorf("runeIndexToByteStr(%q, %d) = %d, want %d", c.s, c.n, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuneIndexToByte_Chunked(t *testing.T) {
|
|
// Use a 4-byte chunk size so "hello world" (11 bytes) spans 3 chunks.
|
|
st := newChunkedState(t, "hello world", 4)
|
|
cb := st.Editor.ChunkedBuffer
|
|
|
|
// All ASCII: rune index == byte offset.
|
|
for n := 0; n <= 11; n++ {
|
|
if got := cb.RuneIndexToByte(n); got != n {
|
|
t.Errorf("ASCII RuneIndexToByte(%d) = %d, want %d", n, got, n)
|
|
}
|
|
}
|
|
|
|
// Non-ASCII spanning chunk boundaries. "abéfg" = a(1) b(1) é(2) f(1) g(1)
|
|
// = 6 bytes; rune byte offsets are 0,1,2,4,5 and the end is 6.
|
|
st2 := newChunkedState(t, "abéfg", 2)
|
|
cb2 := st2.Editor.ChunkedBuffer
|
|
want := []int{0, 1, 2, 4, 5, 6}
|
|
for n, w := range want {
|
|
if got := cb2.RuneIndexToByte(n); got != w {
|
|
t.Errorf("RuneIndexToByte(%d) = %d, want %d (s=\"abéfg\", chunk=2)", n, got, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Insert_String(t *testing.T) {
|
|
st := newStringState("Hello World")
|
|
// Insert at rune 5 (byte 5, the space) -> "Hello World"
|
|
HandleReplaceRange(5, 5, "!")
|
|
// Insert before the space: "Hello" + "!" + " World"
|
|
want := "Hello! World"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
// Cursor should be at the end of the inserted text: startByte(5) + len("!")
|
|
if st.Editor.CursorPosition != 6 {
|
|
t.Fatalf("cursor = %d, want 6", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Replace_String(t *testing.T) {
|
|
// This is the core of item 3: a swipe/autocorrect commit replaces the
|
|
// selected region without duplicating it.
|
|
st := newStringState("Hello World")
|
|
// Replace runes [6,11) == "World" with "there"
|
|
HandleReplaceRange(6, 11, "there")
|
|
want := "Hello there"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
// Cursor at end of inserted text: startByte(6) + len("there") = 11
|
|
if st.Editor.CursorPosition != 11 {
|
|
t.Fatalf("cursor = %d, want 11", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Delete_String(t *testing.T) {
|
|
st := newStringState("Hello World")
|
|
// Delete runes [5,11) == " World" (empty text)
|
|
HandleReplaceRange(5, 11, "")
|
|
want := "Hello"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
if st.Editor.CursorPosition != 5 {
|
|
t.Fatalf("cursor = %d, want 5", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Unicoded_String(t *testing.T) {
|
|
// "héllo wörld": replace the 2nd word's runes.
|
|
// runes: h(0) é(1) l(2) l(3) o(4) ' '(5) w(6) ö(7) r(8) l(9) d(10)
|
|
st := newStringState("héllo wörld")
|
|
// Replace runes [6,11) == "wörld" with "there"
|
|
HandleReplaceRange(6, 11, "there")
|
|
want := "héllo there"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
// startByte for rune 6 = 7 (h=1,é=2,l=1,l=1,o=1,' '=1 -> 7 bytes), + len("there")=5
|
|
if st.Editor.CursorPosition != 12 {
|
|
t.Fatalf("cursor = %d, want 12", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Replace_Chunked(t *testing.T) {
|
|
// Chunked path: replace a region that spans multiple small chunks.
|
|
content := "Hello World, this is a test."
|
|
st := newChunkedState(t, content, 8)
|
|
cb := st.Editor.ChunkedBuffer
|
|
|
|
// Replace runes [12,15) == "this"[0:3]="thi"? let's pick [5,11) = " World"
|
|
HandleReplaceRange(5, 11, " there")
|
|
want := "Hello there, this is a test."
|
|
got, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("buffer = %q, want %q", got, want)
|
|
}
|
|
// startByte for rune 5 = 5 (ASCII), + len(" there") = 6 -> 11
|
|
if st.Editor.CursorPosition != 11 {
|
|
t.Fatalf("cursor = %d, want 11", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Unicode_Chunked(t *testing.T) {
|
|
// Non-ASCII content in a chunked buffer, replacing across rune/byte
|
|
// mismatch. "héllo" -> replace rune 1 ('é') with 'e'.
|
|
st := newChunkedState(t, "héllo", 2)
|
|
cb := st.Editor.ChunkedBuffer
|
|
HandleReplaceRange(1, 2, "e")
|
|
want := "hello"
|
|
got, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("buffer = %q, want %q", got, want)
|
|
}
|
|
// startByte for rune 1 = 1, + len("e") = 1 -> 2
|
|
if st.Editor.CursorPosition != 2 {
|
|
t.Fatalf("cursor = %d, want 2", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
// TestHandleReplaceRange_SwappedBounds guards against start>end (the IME can
|
|
// deliver either order); it must normalize and behave as [min, max).
|
|
func TestHandleReplaceRange_SwappedBounds(t *testing.T) {
|
|
st := newStringState("abcdef")
|
|
// Provide swapped bounds for range [1,3) ("bc") -> replace with "XY"
|
|
HandleReplaceRange(3, 1, "XY")
|
|
want := "aXYdef"
|
|
if !strings.Contains(st.Editor.Buffer, "XY") {
|
|
t.Fatalf("buffer = %q, want to contain %q", st.Editor.Buffer, "XY")
|
|
}
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
}
|
|
|
|
// TestHandleReplaceRange_WindowOffset_Chunked verifies the scrolled-viewport
|
|
// path: the IME snippet is a window that does NOT start at buffer byte 0, so
|
|
// the EditEvent.Range is window-relative and must be offset by the window
|
|
// start to address the buffer.
|
|
func TestHandleReplaceRange_WindowOffset_Chunked(t *testing.T) {
|
|
newChunkedState(t, "Hello World, this is a test.", 8)
|
|
// Simulate a scrolled viewport: layout would have set the visible window
|
|
// to start at byte 6 ("World, this is a test.").
|
|
TheState.Editor.IMEWindowStartByte = 6
|
|
TheState.Editor.IMEWindowText = "World, this is a test."
|
|
// Replace runes [0,5) of the window ("World") with "There".
|
|
HandleReplaceRange(0, 5, "There")
|
|
cb := TheState.Editor.ChunkedBuffer
|
|
got, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatal("FullContent failed: ", err)
|
|
}
|
|
if want := "Hello There, this is a test."; got != want {
|
|
t.Fatalf("windowed replace: got %q, want %q", got, want)
|
|
}
|
|
// Cursor: window start (6) + len("There") (5) = 11.
|
|
if cp := TheState.Editor.CursorPosition; cp != 11 {
|
|
t.Fatalf("cursor = %d, want 11", cp)
|
|
}
|
|
}
|
|
|
|
// TestHandleReplaceRange_WindowOffset_String verifies the same window-relative
|
|
// logic on the small-file (string) buffer.
|
|
func TestHandleReplaceRange_WindowOffset_String(t *testing.T) {
|
|
st := newStringState("Hello World, this is a test.")
|
|
st.Editor.IMEWindowStartByte = 6
|
|
st.Editor.IMEWindowText = "World, this is a test."
|
|
HandleReplaceRange(0, 5, "There")
|
|
if want := "Hello There, this is a test."; st.Editor.Buffer != want {
|
|
t.Fatalf("windowed replace: got %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
if cp := st.Editor.CursorPosition; cp != 11 {
|
|
t.Fatalf("cursor = %d, want 11", cp)
|
|
}
|
|
}
|