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, "") cb.SetFileSize(int64(len(content))) // Preload all chunks so RuneIndexToByte reads from memory deterministically. for i := 0; i*chunkSize < len(content); i++ { cb.LoadChunk(i) } 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) } }