diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index 040eb6b..304624b 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -23,6 +23,8 @@ func sendKeys(tm *teatest.TestModel, keys ...string) { tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) case "ctrl+d": tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD}) + case "ctrl+v": + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV}) default: tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) } diff --git a/internal/editor/integration_visual_test.go b/internal/editor/integration_visual_test.go new file mode 100644 index 0000000..f933e38 --- /dev/null +++ b/internal/editor/integration_visual_test.go @@ -0,0 +1,272 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// --- Visual Mode Selection State Tests --- + +func TestVisualModeSelectionState(t *testing.T) { + t.Run("test 'v' enters visual mode and sets anchor at cursor", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "v") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.AnchorX() != 3 { + t.Errorf("AnchorX() = %d, want 3", m.AnchorX()) + } + if m.AnchorY() != 0 { + t.Errorf("AnchorY() = %d, want 0", m.AnchorY()) + } + }) + + t.Run("test 'vl' moves cursor right, anchor stays", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "l") + + m := getFinalModel(t, tm) + if m.AnchorX() != 0 { + t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) + } + if m.CursorX() != 1 { + t.Errorf("CursorX() = %d, want 1", m.CursorX()) + } + }) + + t.Run("test 'vh' creates backward selection", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "h") + + m := getFinalModel(t, tm) + if m.AnchorX() != 3 { + t.Errorf("AnchorX() = %d, want 3", m.AnchorX()) + } + if m.CursorX() != 2 { + t.Errorf("CursorX() = %d, want 2", m.CursorX()) + } + }) + + t.Run("test 'vj' extends selection down", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "v", "j") + + m := getFinalModel(t, tm) + if m.AnchorX() != 2 { + t.Errorf("AnchorX() = %d, want 2", m.AnchorX()) + } + if m.AnchorY() != 0 { + t.Errorf("AnchorY() = %d, want 0", m.AnchorY()) + } + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) + + t.Run("test 'V' enters visual line mode and sets anchor at cursor", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "V") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + if m.AnchorY() != 1 { + t.Errorf("AnchorY() = %d, want 1", m.AnchorY()) + } + }) + + t.Run("test 'ctrl+v' enters visual block mode and sets anchor at cursor", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 1}) + sendKeys(tm, "ctrl+v") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualBlockMode { + t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode()) + } + if m.AnchorX() != 2 { + t.Errorf("AnchorX() = %d, want 2", m.AnchorX()) + } + if m.AnchorY() != 1 { + t.Errorf("AnchorY() = %d, want 1", m.AnchorY()) + } + }) + + t.Run("test 'esc' returns to normal mode from visual", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "l", "l", "esc") + + m := getFinalModel(t, tm) + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) +} + +// --- Visual Mode Delete Tests --- + +func TestVisualModeDelete(t *testing.T) { + t.Run("test 'vd' deletes single char under cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "d") + + m := getFinalModel(t, tm) + if m.Line(0) != "ello" { + t.Errorf("Line(0) = %q, want \"ello\"", m.Line(0)) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test 'vlll d' deletes four chars on same line", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "l", "l", "l", "d") + + m := getFinalModel(t, tm) + if m.Line(0) != "o world" { + t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0)) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test 'v' backward selection 'hh d' deletes correct range", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "h", "h", "d") + + // anchor=3, cursor=1 → normalized start=1, end=3 → delete "ell" → "ho" + m := getFinalModel(t, tm) + if m.Line(0) != "ho" { + t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0)) + } + if m.CursorX() != 1 { + t.Errorf("CursorX() = %d, want 1", m.CursorX()) + } + }) + + t.Run("test 'vj d' deletes char selection across two lines", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "v", "j", "d") + + // start=(2,0), end=(2,1) → prefix="he", suffix="ld" → "held" + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "held" { + t.Errorf("Line(0) = %q, want \"held\"", m.Line(0)) + } + if m.CursorX() != 2 { + t.Errorf("CursorX() = %d, want 2", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'Vd' deletes current line", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "V", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "world" { + t.Errorf("Line(0) = %q, want \"world\"", m.Line(0)) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'Vjd' deletes two lines", func(t *testing.T) { + lines := []string{"hello", "world", "testing"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "V", "j", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "testing" { + t.Errorf("Line(0) = %q, want \"testing\"", m.Line(0)) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'Vkd' deletes two lines with backward selection", func(t *testing.T) { + lines := []string{"hello", "world", "testing"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2}) + sendKeys(tm, "V", "k", "d") + + // anchor=line2, cursor=line1 → normalized start=line1, end=line2 → delete both + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "hello" { + t.Errorf("Line(0) = %q, want \"hello\"", m.Line(0)) + } + }) + + t.Run("test 'ctrl+v ljd' deletes block selection", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "ctrl+v", "l", "j", "d") + + // anchor=(0,0), cursor=(1,1) → block cols 0-1, lines 0-1 + // "hello"[:0]+"hello"[2:] = "llo" + // "world"[:0]+"world"[2:] = "rld" + m := getFinalModel(t, tm) + if m.Line(0) != "llo" { + t.Errorf("Line(0) = %q, want \"llo\"", m.Line(0)) + } + if m.Line(1) != "rld" { + t.Errorf("Line(1) = %q, want \"rld\"", m.Line(1)) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'ctrl+v' backward col selection deletes correct block", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "ctrl+v", "h", "h", "j", "d") + + // anchor=(3,0), cursor=(1,1) → cols min(3,1)=1 to max(3,1)=3, lines 0-1 + // "hello"[:1]+"hello"[4:] = "h"+"o" = "ho" + // "world"[:1]+"world"[4:] = "w"+"d" = "wd" + m := getFinalModel(t, tm) + if m.Line(0) != "ho" { + t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0)) + } + if m.Line(1) != "wd" { + t.Errorf("Line(1) = %q, want \"wd\"", m.Line(1)) + } + }) +}