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 starts mid-file, so the EditEvent.Range (absolute // file runes, addressed from IMEOffsetRune) must be mapped through the // leading context and the window 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."), with no leading context // (all ASCII, so rune offset == byte offset). TheState.Editor.IMEWindowStartByte = 6 TheState.Editor.IMEWindowText = "World, this is a test." TheState.Editor.IMEContext = "" TheState.Editor.IMEContextStartByte = 6 TheState.Editor.IMEOffsetRune = 6 // The IME reports absolute file runes: "World" is runes [6,11). HandleReplaceRange(6, 11, "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 absolute-rune // mapping 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." st.Editor.IMEContext = "" st.Editor.IMEContextStartByte = 6 st.Editor.IMEOffsetRune = 6 HandleReplaceRange(6, 11, "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 := TheState.Editor.CursorPosition; cp != 11 { t.Fatalf("cursor = %d, want 11", cp) } } // TestHandleReplaceRange_WithContext verifies the leading-context mapping: // the snippet is context+window and the IME's absolute rune offsets may land // inside the context (before the window). The context contains a multi-byte // rune so rune != byte there, which pins the rune->byte walk. A second, fresh // state covers a range that spans the context/window boundary. func TestHandleReplaceRange_WithContext(t *testing.T) { // File "héllo world, this" (18 bytes): h(0) é(1-2) l(3) l(4) o(5) // sp(6) w(7) o(8) r(9) l(10) d(11) ,(12) sp(13) t(14) h(15) i(16) s(17). // Window starts at byte 6 (the space), so window = " world, this"; the // context is the preceding runes "héllo" (5 runes, 6 bytes), starting at // byte 0. The snippet spans absolute runes [0, ...), so IMEOffsetRune=0. st := newStringState("héllo world, this") st.Editor.IMEWindowStartByte = 6 st.Editor.IMEWindowText = " world, this" st.Editor.IMEContext = "héllo" st.Editor.IMEContextStartByte = 0 st.Editor.IMEOffsetRune = 0 // Replace absolute rune [1,2) ("é", bytes 1..3) with "@": inside context. HandleReplaceRange(1, 2, "@") if want := "h@llo world, this"; st.Editor.Buffer != want { t.Fatalf("context replace: got %q, want %q", st.Editor.Buffer, want) } if cp := TheState.Editor.CursorPosition; cp != 2 { t.Fatalf("cursor = %d, want 2", cp) } } // TestHandleReplaceRange_SpansContextWindow verifies a replace range that // straddles the context/window boundary maps correctly through both. func TestHandleReplaceRange_SpansContextWindow(t *testing.T) { st := newStringState("héllo world, this") st.Editor.IMEWindowStartByte = 6 st.Editor.IMEWindowText = " world, this" st.Editor.IMEContext = "héllo" st.Editor.IMEContextStartByte = 0 st.Editor.IMEOffsetRune = 0 // Absolute runes [4,7): rune4='o'(context, byte5) + rune5=sp(window, // byte6) + rune6='w'(window, byte7) -> bytes [5,8) = "o w". Keeping // bytes 0..4 ("héll") and 8+ ("orld, this"). HandleReplaceRange(4, 7, "ZZ") if want := "héllZZorld, this"; st.Editor.Buffer != want { t.Fatalf("spanning replace: got %q, want %q", st.Editor.Buffer, want) } if cp := TheState.Editor.CursorPosition; cp != 7 { t.Fatalf("cursor = %d, want 7", cp) } } // TestRuneAnchorTracking verifies the incremental rune-count cache: it must // follow edits (imeAnchorEdit) and agree with a naive full count after a // sequence of mutations at various positions. func TestRuneAnchorTracking(t *testing.T) { st := newChunkedState(t, "hello wörld, this is a test", 8) check := func(what string) { t.Helper() full, _ := st.Editor.ChunkedBuffer.FullContent() for _, pos := range []int{0, 3, 9, len(full)} { if got, want := st.imeRuneOffsetAt(pos), countRunes(full[:pos]); got != want { t.Fatalf("%s: imeRuneOffsetAt(%d) = %d, want %d", what, pos, got, want) } } } check("initial") testInsertAt(t, st, 3, "é") check("insert mid") testInsertAt(t, st, int(st.Editor.ChunkedBuffer.FileLen()), "é") check("insert end") testDeleteAt(t, st, 2, 4) check("delete") } // testInsertAt inserts text at absolute byte pos on the test state, adjusting // the IME rune anchor the same way the production edit paths do. func testInsertAt(t *testing.T, st *State, pos int, text string) { t.Helper() st.Editor.ChunkedBuffer.Insert(pos, text) st.Editor.ChunkedBuffer.UpdateLineIndexAfterInsert(pos, text) st.Editor.CursorPosition = pos + len(text) imeAnchorEdit(pos, "", text) } // testDeleteAt deletes n bytes at absolute byte pos on the test state. func testDeleteAt(t *testing.T, st *State, pos, n int) { t.Helper() del := st.Editor.ChunkedBuffer.Content(pos, pos+n) st.Editor.ChunkedBuffer.Delete(pos, n) st.Editor.ChunkedBuffer.UpdateLineIndexAfterDelete(pos, pos+n) imeAnchorEdit(pos, del, "") }