summaryrefslogtreecommitdiff
path: root/internal/ui/widgets/textviewentry_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/widgets/textviewentry_test.go')
-rw-r--r--internal/ui/widgets/textviewentry_test.go381
1 files changed, 381 insertions, 0 deletions
diff --git a/internal/ui/widgets/textviewentry_test.go b/internal/ui/widgets/textviewentry_test.go
new file mode 100644
index 0000000..bbb20ef
--- /dev/null
+++ b/internal/ui/widgets/textviewentry_test.go
@@ -0,0 +1,381 @@
+package widgets
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTVE() *TextViewEntry {
+ e := &TextViewEntry{}
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+// ── splitRaw ─────────────────────────────────────────────────────────────────
+
+func TestSplitRaw(t *testing.T) {
+ tests := []struct {
+ input string
+ want []string
+ }{
+ {"", []string{""}},
+ {"a\nb", []string{"a", "b"}},
+ {"a\r\nb", []string{"a", "b"}},
+ {"a\rb", []string{"a", "b"}},
+ {"a\nb\nc", []string{"a", "b", "c"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := splitRaw(tt.input)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+// ── buildVisualLines ─────────────────────────────────────────────────────────
+
+func TestBuildVisualLines_NoWrap(t *testing.T) {
+ lines := buildVisualLines([]string{"GET /foo HTTP/1.1", "Host: example.com"}, 80)
+ assert.Len(t, lines, 2)
+}
+
+func TestBuildVisualLines_Wraps(t *testing.T) {
+ long := strings.Repeat("x", 100)
+ lines := buildVisualLines([]string{long}, 40)
+
+ assert.Greater(t, len(lines), 2, "expected ≥3 visual lines for 100-char line at width 40")
+
+ for _, vl := range lines {
+ assert.Equal(t, 0, vl.logicalIdx, "all chunks should map to logicalIdx 0")
+ }
+
+ expected := 0
+ for _, vl := range lines {
+ assert.Equal(t, expected, vl.colOffset)
+ expected += len([]rune(vl.raw))
+ }
+}
+
+func TestBuildVisualLines_EmptyLine(t *testing.T) {
+ lines := buildVisualLines([]string{"GET /foo HTTP/1.1", "", `{"key":"val"}`}, 80)
+ assert.Len(t, lines, 3)
+}
+
+func TestBuildVisualLines_EmptyInput(t *testing.T) {
+ lines := buildVisualLines([]string{""}, 80)
+ assert.Len(t, lines, 1)
+}
+
+// ── logicalToVisual ──────────────────────────────────────────────────────────
+
+func TestLogicalToVisual_Simple(t *testing.T) {
+ visual := buildVisualLines([]string{"hello", "world"}, 80)
+
+ v, col := logicalToVisual(visual, 0, 0)
+ assert.Equal(t, 0, v)
+ assert.Equal(t, 0, col)
+
+ v, col = logicalToVisual(visual, 1, 3)
+ assert.Equal(t, 1, visual[v].logicalIdx)
+ assert.Equal(t, 3, col)
+}
+
+func TestLogicalToVisual_EndOfLine(t *testing.T) {
+ visual := buildVisualLines([]string{"hello"}, 80)
+
+ v, col := logicalToVisual(visual, 0, 5)
+ assert.Equal(t, 0, v)
+ assert.Equal(t, 5, col)
+}
+
+func TestLogicalToVisual_WrappedLine(t *testing.T) {
+ // "01234567890123456789" wrapped at 10 → chunks [0:10] [10:20]
+ visual := buildVisualLines([]string{"01234567890123456789"}, 10)
+
+ v, col := logicalToVisual(visual, 0, 11)
+ assert.Equal(t, 10, visual[v].colOffset, "col 11 should land in chunk starting at offset 10")
+ assert.Equal(t, 1, col)
+}
+
+func TestLogicalToVisual_EndOfWrappedLine(t *testing.T) {
+ // "01234567890123456789" wrapped at 10 → chunks [0:10] [10:20]
+ visual := buildVisualLines([]string{"01234567890123456789"}, 10)
+
+ v, _ := logicalToVisual(visual, 0, 20)
+ assert.Equal(t, visual[len(visual)-1].colOffset, visual[v].colOffset, "col at end should land in last chunk")
+}
+
+// ── normaliseSelection ───────────────────────────────────────────────────────
+
+func TestNormaliseSelection_Forward(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 0, 2
+ e.selLine, e.selCol = 1, 5
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestNormaliseSelection_Reversed(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 1, 5
+ e.selLine, e.selCol = 0, 2
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestNormaliseSelection_SamePosition(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 0, 3
+ e.selLine, e.selCol = 0, 3
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 3, sc)
+ assert.Equal(t, 0, el)
+ assert.Equal(t, 3, ec)
+}
+
+// ── selectedText ─────────────────────────────────────────────────────────────
+
+func TestSelectedText_SameLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 0
+ e.selLine, e.selCol = 0, 5
+ e.hasSelection = true
+
+ assert.Equal(t, "hello", e.selectedText())
+}
+
+func TestSelectedText_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "line one\nline two"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 5
+ e.selLine, e.selCol = 1, 4
+ e.hasSelection = true
+
+ assert.Equal(t, "one\nline", e.selectedText())
+}
+
+func TestSelectedText_NoSelection(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.hasSelection = false
+
+ assert.Equal(t, "", e.selectedText())
+}
+
+// ── deleteSelection ──────────────────────────────────────────────────────────
+
+func TestDeleteSelection_SameLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 6
+ e.selLine, e.selCol = 0, 11
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello ", e.GetText())
+ assert.Equal(t, 6, e.cursorCol)
+ assert.False(t, e.hasSelection)
+}
+
+func TestDeleteSelection_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "line one\nline two\nline three"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 5
+ e.selLine, e.selCol = 2, 4
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "line three", e.GetText())
+}
+
+func TestDeleteSelection_ReversedAnchor(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 11
+ e.selLine, e.selCol = 0, 6
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello ", e.GetText())
+}
+
+func TestDeleteSelection_NoSelection(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.hasSelection = false
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello", e.GetText())
+}
+
+// ── pushUndo / undo ──────────────────────────────────────────────────────────
+
+func TestPushUndo_StoresCursorPosition(t *testing.T) {
+ e := newTVE()
+ e.pushUndo("hello", 0, 3)
+
+ require.Len(t, e.undoStack, 1)
+ assert.Equal(t, "hello", e.undoStack[0].raw)
+ assert.Equal(t, 0, e.undoStack[0].cursorLine)
+ assert.Equal(t, 3, e.undoStack[0].cursorCol)
+}
+
+func TestPushUndo_StackLimit(t *testing.T) {
+ e := newTVE()
+ for i := 0; i < 110; i++ {
+ e.pushUndo("x", 0, 0)
+ }
+ assert.LessOrEqual(t, len(e.undoStack), 100)
+}
+
+func TestUndo_RestoresTextAndCursor(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.cursorLine, e.cursorCol = 0, 5
+
+ e.pushUndo("hello", 0, 5)
+
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.visualCacheRaw = ""
+ e.cursorLine = 0
+ e.cursorCol = 11
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ undo := e.undoStack[len(e.undoStack)-1]
+ e.undoStack = e.undoStack[:len(e.undoStack)-1]
+ e.rawFull = undo.raw
+ e.visualCacheRaw = ""
+ e.cursorLine = undo.cursorLine
+ e.cursorCol = undo.cursorCol
+ e.mu.Unlock()
+
+ assert.Equal(t, "hello", e.GetText())
+ assert.Equal(t, 0, e.cursorLine)
+ assert.Equal(t, 5, e.cursorCol)
+}
+
+// ── visual cache ─────────────────────────────────────────────────────────────
+
+func TestVisualCache_InvalidatedOnCommit(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ e.visualCache = buildVisualLines([]string{"hello"}, 80)
+ e.visualCacheRaw = "hello"
+ e.visualCacheWidth = 80
+ e.mu.Unlock()
+
+ e.commitLines([]string{"world"})
+
+ e.mu.Lock()
+ cacheRaw := e.visualCacheRaw
+ e.mu.Unlock()
+
+ assert.Equal(t, "", cacheRaw, "commitLines should invalidate visual cache")
+}
+
+func TestVisualCache_HitOnSecondCall(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "GET /foo HTTP/1.1\nHost: example.com"
+ e.visualCacheRaw = ""
+ e.mu.Unlock()
+
+ // Prime the cache manually without needing a renderer.
+ cpl := 80
+ e.mu.Lock()
+ built := buildVisualLines(splitRaw(e.rawFull), cpl)
+ e.visualCache = built
+ e.visualCacheRaw = e.rawFull
+ e.visualCacheWidth = cpl
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ c1 := e.visualCache
+ c2 := e.visualCache
+ e.mu.Unlock()
+
+ require.NotEmpty(t, c1)
+ assert.Equal(t, &c1[0], &c2[0], "expected same slice on repeated access")
+}
+
+func TestVisualCache_MissAfterMutation(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ cpl := 80
+ e.visualCache = buildVisualLines([]string{"hello"}, cpl)
+ e.visualCacheRaw = "hello"
+ e.visualCacheWidth = cpl
+ e.mu.Unlock()
+
+ e.commitLines([]string{"world"})
+
+ e.mu.Lock()
+ cacheRaw := e.visualCacheRaw
+ e.mu.Unlock()
+
+ assert.Equal(t, "", cacheRaw, "cache should be invalidated after mutation")
+}
+
+// ── commitLines ──────────────────────────────────────────────────────────────
+
+func TestCommitLines_UpdatesText(t *testing.T) {
+ e := newTVE()
+ e.commitLines([]string{"new text"})
+ assert.Equal(t, "new text", e.GetText())
+}
+
+func TestCommitLines_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.commitLines([]string{"line one", "line two"})
+ assert.Equal(t, "line one\nline two", e.GetText())
+}