package e2e_test import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "gioui.org/io/key" "pad/internal/editor" "pad/internal/io/pool/real" "pad/internal/test/e2e" "pad/internal/ui" ) // realFileHarness writes content to /name, builds a harness whose // browser root and editor both use the real filesystem, opens /name and waits // for the load + line index. Returns the harness and the on-disk path. func realFileHarness(t *testing.T, name, content string) (*e2e.Harness, string) { t.Helper() dir := t.TempDir() diskPath := filepath.Join(dir, name) if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { t.Fatal(err) } h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Run() if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil { t.Fatalf("GoToBrowser: %v", err) } time.Sleep(100 * time.Millisecond) if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil { t.Fatalf("OpenFile: %v", err) } // "Loaded" means both async results are fully applied: the ReadFile // content (len(FullContent) == FileLen) AND the BuildLineIndex result // (LineIndex.Size == FileLen). Waiting on FileLen>0 && LineIndex!=nil // alone is a race: stat sets FileLen, and the two worker results can // interleave with test edits. loaded := false for i := 0; i < 100; i++ { v, err := h.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil { return false } full, err := cb.FullContent() return err == nil && int64(len(full)) == cb.FileLen() && cb.LineIndex.Size == cb.FileLen() }) if err != nil { t.Fatalf("Inspect: %v", err) } if v.(bool) { loaded = true break } time.Sleep(50 * time.Millisecond) } if !loaded { t.Fatal("timed out waiting for real file to load") } return h, diskPath } // readDisk reads the on-disk content of a test file. func readDisk(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("reading disk: %v", err) } return string(data) } // TestRealFile_BasicInsert verifies an insert on a small real file lands in // the buffer, flushes, and matches on disk. func TestRealFile_BasicInsert(t *testing.T) { h, path := realFileHarness(t, "small.txt", "hello world") defer h.Cleanup() if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 5 editor.HandleInsert(" there") }); err != nil { t.Fatalf("insert: %v", err) } got, _ := h.FullContent() if got != "hello there world" { t.Fatalf("buffer = %q, want %q", got, "hello there world") } if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "hello there world" { t.Fatalf("disk = %q, want %q", disk, "hello there world") } } // TestRealFile_BackspaceAndDelete verifies deletion edits on a real file, // including across the line-index update path. func TestRealFile_BackspaceAndDelete(t *testing.T) { h, path := realFileHarness(t, "small.txt", "0123456789") defer h.Cleanup() // Backspace at 5 deletes '4' (index 4); cursor moves to 4 (before '5'). if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 5 editor.HandleBackspace() }); err != nil { t.Fatalf("backspace: %v", err) } got, _ := h.FullContent() if got != "012356789" { t.Fatalf("buffer = %q, want %q", got, "012356789") } // Delete at cursor 4 removes '5' (the character now at index 4). if err := h.WithState(func(st *editor.State) { editor.HandleDelete() }); err != nil { t.Fatalf("delete: %v", err) } got, _ = h.FullContent() if got != "01236789" { t.Fatalf("buffer = %q, want %q", got, "01236789") } if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "01236789" { t.Fatalf("disk = %q, want %q", disk, "01236789") } } // TestRealFile_IMEReplaceRange simulates an IME commit replacing a range // (swipe-to-delete + replacement semantics) on a real file. func TestRealFile_IMEReplaceRange(t *testing.T) { h, path := realFileHarness(t, "small.txt", "hello world") defer h.Cleanup() // IME replaces [0,5) "hello" with "goodbye". if err := h.WithState(func(st *editor.State) { editor.HandleReplaceRange(0, 5, "goodbye") }); err != nil { t.Fatalf("replace: %v", err) } got, _ := h.FullContent() if got != "goodbye world" { t.Fatalf("buffer = %q, want %q", got, "goodbye world") } if pos, _ := h.CursorPosition(); pos != 7 { t.Fatalf("cursor = %d, want 7", pos) } if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "goodbye world" { t.Fatalf("disk = %q, want %q", disk, "goodbye world") } } // TestRealFile_IMEInsertAtCaret simulates a plain IME commit (empty range at // the caret) through the real HandleKeyDown event path. func TestRealFile_IMEInsertAtCaret(t *testing.T) { h, path := realFileHarness(t, "small.txt", "abc") defer h.Cleanup() if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 3 }); err != nil { t.Fatalf("set cursor: %v", err) } // Go through the full event handler, as main.go delivers it. h.SendInput([]ui.InputEvent{ {Handler: editor.HandleKeyDown, Data: key.EditEvent{Range: key.Range{Start: 3, End: 3}, Text: "def"}}, }) time.Sleep(50 * time.Millisecond) got, _ := h.FullContent() if got != "abcdef" { t.Fatalf("buffer = %q, want %q", got, "abcdef") } if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "abcdef" { t.Fatalf("disk = %q, want %q", disk, "abcdef") } } // TestRealFile_AutosavePersists verifies the 1s-debounced autosave writes the // edit to disk without an explicit Flush. func TestRealFile_AutosavePersists(t *testing.T) { h, path := realFileHarness(t, "small.txt", "before") defer h.Cleanup() if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 6 editor.HandleInsert("-after") }); err != nil { t.Fatalf("insert: %v", err) } // Wait for the autosave debounce (1 s) plus margin. deadline := time.Now().Add(5 * time.Second) for { if disk := readDisk(t, path); disk == "before-after" { break } if time.Now().After(deadline) { t.Fatalf("disk = %q, want %q (autosave did not fire)", readDisk(t, path), "before-after") } time.Sleep(100 * time.Millisecond) } } // TestRealFile_ShiftSelectionInsert verifies shift+arrow selection through the // real key event path and that typing replaces the selection; also checks the // selection reaches the rendered TextField element. func TestRealFile_ShiftSelectionInsert(t *testing.T) { h, path := realFileHarness(t, "small.txt", "hello world") defer h.Cleanup() if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 0 }); err != nil { t.Fatalf("set cursor: %v", err) } // Select "hello" (5 chars) with shift+right. for i := 0; i < 5; i++ { h.SendInput([]ui.InputEvent{ {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameRightArrow, Shift: true}}, }) } time.Sleep(50 * time.Millisecond) // Selection must be visible in state... v, err := h.Inspect(func(st *editor.State) any { return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd} }) if err != nil { t.Fatalf("inspect: %v", err) } sel := v.([2]int) s, e := sel[0], sel[1] if s != 0 || e != 5 { t.Fatalf("selection = [%d,%d), want [0,5)", s, e) } // ...and in the rendered TextField element (window-relative; at scroll 0 // window == file, so same values). Only the latest frame matters: earlier // frames predate the selection. time.Sleep(100 * time.Millisecond) // let the post-input frame be captured frames := h.GetFrames() last := frames[len(frames)-1] var found bool for _, elem := range last { if tf, ok := elem.(ui.TextField); ok && tf.ID() == "editor_text" { found = true if tf.SelectionStart != 0 || tf.SelectionEnd != 5 { t.Fatalf("TextField selection = [%d,%d), want [0,5)", tf.SelectionStart, tf.SelectionEnd) } } } if !found { t.Fatal("no editor_text TextField in latest frame") } // Typing replaces the selection. h.SendInput([]ui.InputEvent{ {Handler: editor.HandleKeyDown, Data: key.EditEvent{Range: key.Range{Start: 5, End: 5}, Text: "goodbye"}}, }) time.Sleep(50 * time.Millisecond) got, _ := h.FullContent() if got != "goodbye world" { t.Fatalf("buffer = %q, want %q", got, "goodbye world") } if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "goodbye world" { t.Fatalf("disk = %q, want %q", disk, "goodbye world") } } // TestRealFile_ShiftSelectionBackspace verifies backspace deletes a // shift-selected range through the real key event path. func TestRealFile_ShiftSelectionBackspace(t *testing.T) { h, path := realFileHarness(t, "small.txt", "0123456789") defer h.Cleanup() if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 2 }); err != nil { t.Fatalf("set cursor: %v", err) } // Shift+right x3 selects [2,5) = "234". for i := 0; i < 3; i++ { h.SendInput([]ui.InputEvent{ {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameRightArrow, Shift: true}}, }) } h.SendInput([]ui.InputEvent{ {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameDeleteBackward, Shift: false}}, }) time.Sleep(50 * time.Millisecond) got, _ := h.FullContent() if got != "0156789" { t.Fatalf("buffer = %q, want %q", got, "0156789") } if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "0156789" { t.Fatalf("disk = %q, want %q", disk, "0156789") } } // TestRealFile_MultilineEdit verifies insert and backspace across line // boundaries keep the line index consistent (line count + offsets). func TestRealFile_MultilineEdit(t *testing.T) { h, path := realFileHarness(t, "multi.txt", "aaa\nbbb\nccc") defer h.Cleanup() // Newline at end of line 1 -> 4 lines. if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 7 // after "bbb" editor.HandleInsert("\n") }); err != nil { t.Fatalf("insert: %v", err) } checkLineIndex(t, h, "aaa\nbbb\n\nccc", 4) // Backspace #1 removes the inserted '\n' (cursor is right after it). if err := h.WithState(func(st *editor.State) { editor.HandleBackspace() }); err != nil { t.Fatalf("backspace 1: %v", err) } checkLineIndex(t, h, "aaa\nbbb\nccc", 3) // Backspace #2 removes the trailing 'b' of line 2. if err := h.WithState(func(st *editor.State) { editor.HandleBackspace() }); err != nil { t.Fatalf("backspace 2: %v", err) } checkLineIndex(t, h, "aaa\nbb\nccc", 3) if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != "aaa\nbb\nccc" { t.Fatalf("disk = %q, want %q", disk, "aaa\nbb\nccc") } } // checkLineIndex verifies buffer content and that the incrementally-maintained // line index matches the expected line count and per-line offsets. // lo/hi/seg bound a failure-report window around offset at. func lo(at, n int) int { if at < n { return 0 } return at - n } func hi(at, n int) int { return at + n } func seg(s string, at, n int) string { start := lo(at, n) end := at + n if end > len(s) { end = len(s) } if start > end { start = end } return s[start:end] } func checkLineIndex(t *testing.T, h *e2e.Harness, wantContent string, wantLines int) { t.Helper() got, err := h.FullContent() if err != nil { t.Fatalf("FullContent: %v", err) } if got != wantContent { // Report compactly: large files make %q messages megabytes long. at := 0 for at < len(got) && at < len(wantContent) && got[at] == wantContent[at] { at++ } t.Fatalf("buffer mismatch at byte %d: got %d bytes, want %d\ngot [%d:%d]=%q\nwant [%d:%d]=%q", at, len(got), len(wantContent), lo(at, 40), hi(at, 40), seg(got, at, 40), lo(at, 40), hi(at, 40), seg(wantContent, at, 40)) } diag, err := h.Inspect(func(st *editor.State) any { li := st.Editor.ChunkedBuffer.LineIndex if li == nil { return "nil index" } if li.LineCount() != wantLines { return fmt.Sprintf("linecount=%d want=%d size=%d head=%v", li.LineCount(), wantLines, li.Size, li.Offsets[:min(10, len(li.Offsets))]) } head := "" if len(li.Offsets) > 10 { head = fmt.Sprintf(" head=%v", li.Offsets[:10]) } // Offsets must match a fresh computation from the content. offset := 0 for line := 0; line < wantLines; line++ { if li.ByteOffset(line) != offset { return fmt.Sprintf("mismatch at line %d: index=%d want=%d size=%d%s", line, li.ByteOffset(line), offset, li.Size, head) } idx := strings.IndexByte(got[offset:], '\n') if idx < 0 { break } offset += idx + 1 } return "ok" }) if err != nil { t.Fatalf("inspect: %v", err) } if diag.(string) != "ok" { t.Fatalf("line index inconsistent: %s (content=%d bytes, want %d lines)", diag, len(wantContent), wantLines) } }